| """generate_comic β the full idea -> finished comic pipeline. |
| |
| It is a GENERATOR that yields progress events as it goes, so the Gradio app can show |
| live status and stream panels onto the page as they render (rather than freezing for |
| the whole ~minute of generation). Stages: |
| |
| 1. WRITER bible call: safety gate + story bible. If refused -> a 'refused' event, done. |
| 2. WRITER panel calls: 5 batches of 2 pages each, each fed a recap of prior panels |
| for continuity. Yields a 'panels' event per batch. |
| 3. ARTIST renders all 20 panels, one at a time, yielding an 'image' event each. |
| 4. 'done' event with the finished Comic. |
| |
| The pipeline is backend-agnostic (mock or modal) via make_backends(). Errors in a |
| single panel render are caught so one bad image never sinks the whole comic. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Iterator, Optional |
|
|
| from .backends import make_backends, WriterBackend, ArtistBackend |
| from .schema import Comic, ComicBible, Panel, TOTAL_PANELS |
| from . import writer as W |
| from .imaging import build_image_prompt, panel_seed |
|
|
| |
| |
| RENDER_BATCH = 4 |
|
|
|
|
| @dataclass |
| class GenerateEvent: |
| """One step of progress. `kind` drives the UI; payload fields are kind-specific.""" |
| kind: str |
| message: str = "" |
| comic: Optional[Comic] = None |
| panel: Optional[Panel] = None |
| progress: float = 0.0 |
|
|
|
|
| def _retry_json(fn, parse, attempts: int = 2): |
| """Call fn() -> reply, parse it; retry once on a parse/JSON failure.""" |
| last = None |
| for _ in range(attempts): |
| reply = fn() |
| try: |
| return parse(reply) |
| except Exception as e: |
| last = e |
| raise last if last else RuntimeError("generation failed") |
|
|
|
|
| def generate_comic( |
| idea: str, |
| writer: WriterBackend | None = None, |
| artist: ArtistBackend | None = None, |
| backend: str | None = None, |
| ) -> Iterator[GenerateEvent]: |
| """Yield GenerateEvents from raw idea to a fully rendered Comic.""" |
| if writer is None or artist is None: |
| w, a = make_backends(backend) |
| writer = writer or w |
| artist = artist or a |
|
|
| idea = (idea or "").strip() |
| if not idea: |
| yield GenerateEvent("error", "Please describe the comic you want.") |
| return |
|
|
| |
| yield GenerateEvent("status", "Reading your idea and planning the storyβ¦", progress=0.02) |
| try: |
| bible: ComicBible = _retry_json( |
| lambda: writer.chat(W.build_bible_messages(idea)), |
| W.parse_bible, |
| ) |
| except Exception as e: |
| yield GenerateEvent("error", f"Couldn't plan the comic ({type(e).__name__}). Try again.") |
| return |
|
|
| if not bible.approved: |
| reason = bible.refusal_reason or "That request can't be turned into a comic." |
| yield GenerateEvent("refused", reason) |
| return |
|
|
| comic = Comic(bible=bible) |
| yield GenerateEvent("bible", f"β{bible.title}β β {bible.logline}", comic=comic, progress=0.1) |
|
|
| |
| written: list[Panel] = [] |
| page_batches = W.batches() |
| n_batches = len(page_batches) |
| for bi, pages in enumerate(page_batches): |
| yield GenerateEvent( |
| "status", |
| f"Writing pages {pages[0]}β{pages[-1]} of {len(comic.bible.pages)}β¦", |
| comic=comic, |
| progress=0.1 + 0.3 * (bi / n_batches), |
| ) |
| recap = W.recap_from_panels(written) |
| try: |
| panels = _retry_json( |
| lambda: writer.chat(W.build_panel_messages(bible, pages, recap)), |
| lambda r: W.parse_panels(r, pages), |
| ) |
| except Exception as e: |
| yield GenerateEvent("error", f"Story writing failed on pages {pages} ({type(e).__name__}).") |
| return |
| |
| for p in panels: |
| p.image_prompt = build_image_prompt(p, bible) |
| written.extend(panels) |
| comic.panels = sorted(written, key=lambda x: x.index) |
| yield GenerateEvent("panels", f"Pages {pages[0]}β{pages[-1]} scripted.", |
| comic=comic, progress=0.1 + 0.3 * ((bi + 1) / n_batches)) |
|
|
| |
| ordered = sorted(comic.panels, key=lambda x: x.index) |
| total = len(ordered) or TOTAL_PANELS |
| done = 0 |
| for start in range(0, len(ordered), RENDER_BATCH): |
| chunk = ordered[start:start + RENDER_BATCH] |
| yield GenerateEvent( |
| "status", |
| f"Illustrating panels {start + 1}β{start + len(chunk)} of {total}β¦", |
| comic=comic, progress=0.4 + 0.6 * (start / total), |
| ) |
| prompts = [p.image_prompt for p in chunk] |
| seeds = [panel_seed(bible, p) for p in chunk] |
| try: |
| images = artist.render_batch(prompts, seeds) |
| except Exception as e: |
| images = [None] * len(chunk) |
| yield GenerateEvent("status", f"A render batch hiccupped ({type(e).__name__}); continuingβ¦", |
| comic=comic) |
| for panel, img in zip(chunk, images): |
| panel.image = img |
| done += 1 |
| yield GenerateEvent("image", f"Panel {done} ready.", comic=comic, panel=panel, |
| progress=0.4 + 0.6 * (done / total)) |
|
|
| yield GenerateEvent("done", f"β{bible.title}β is ready.", comic=comic, progress=1.0) |
|
|