"""Comic Book Generator — Gradio app (live-demo build). Flow for the video: 1. Type an idea, press Enter / Generate. 2. A big STOPWATCH starts (gr.Timer) and ticks live while the comic is made — Gemma gatekeeps + writes a 25-page / 50-panel story, FLUX paints every panel, and the latest finished panel shows as a live thumbnail. 3. At the end the stopwatch FREEZES on the exact generation time (no cold start, because the GPUs are pre-warmed) and the full comic reader is revealed: header (with the time), two image+caption panels per page, and ◀ ▶ arrows to page through all 25 pages. Run: COMIC_BACKEND=mock python app.py # offline, no GPU COMIC_BACKEND=modal python app.py # live Gemma + FLUX on Modal (pre-warm first!) """ from __future__ import annotations import io import os import threading import time import gradio as gr from PIL import Image from comic import generate_comic, make_backends from comic.schema import PAGES, TOTAL_PANELS BACKEND = os.environ.get("COMIC_BACKEND", "mock") WRITER, ARTIST = make_backends(BACKEND) # bound once; pre-warm + generation share them # Module-level timing state, polled by the gr.Timer to tick the stopwatch smoothly while # the (long-running) generator streams. Single-user demo -> a global is fine. TIMER = {"t0": None, "running": False, "final": None} # ── small renderers ────────────────────────────────────────────────────────── def _pil(b): return Image.open(io.BytesIO(b)).convert("RGB") if b else None def _fmt(elapsed: float) -> str: m = int(elapsed // 60) s = elapsed - 60 * m return f"{m}:{s:04.1f}" if m else f"{s:.1f}s" _INK = "#16120d" _PAPER2 = "#fbf6e9" def _clock_html(elapsed: float, running: bool) -> str: col = "#e23b2e" if running else "#1f8a4c" sub = "INKING YOUR COMIC…" if running else "DONE · TOTAL TIME (GPUS PRE-WARMED)" return ( f"
" f"
{_fmt(elapsed)}
" f"
{sub}
" ) def _progress_html(message: str, progress: float = 0.0, tone: str = "info") -> str: colors = {"info": "#214bd6", "error": "#e23b2e", "refuse": "#f4ad15", "ok": "#1f8a4c"} bar = max(0.0, min(1.0, progress)) col = colors.get(tone, "#214bd6") return ( f"
" f"
{message}
" f"
" f"
" ) def _title_html(title: str, logline: str = "", time_note: str = "") -> str: sub = (f"
" f"{logline}
" if logline else "") tn = (f"
{time_note}
" if time_note else "") return ( f"
" f"
{title}
{sub}{tn}
" ) def _caption_html(text: str) -> str: text = text or "" return ( f"
{text}
" ) def _label_html(idx: int) -> str: return (f"
PAGE {idx + 1} / {PAGES}
") def _page_view(comic, idx: int): """(img1, cap1, img2, cap2, label) for a 0-based page index.""" panels = comic.page_panels(idx + 1) if comic else [] p1 = panels[0] if len(panels) > 0 else None p2 = panels[1] if len(panels) > 1 else None return ( _pil(p1.image) if p1 else None, _caption_html(p1.caption if p1 else ""), _pil(p2.image) if p2 else None, _caption_html(p2.caption if p2 else ""), _label_html(idx), ) # ── output bundle plumbing ─────────────────────────────────────────────────── _KEYS = ["input_view", "gen_view", "reader_view", "gen_btn", "clock", "status", "status0", "live_img", "gen_timer", "title", "img1", "cap1", "img2", "cap2", "label", "comic", "idx"] _POS = {k: i for i, k in enumerate(_KEYS)} def _bundle(**kw): out = [gr.skip()] * len(_KEYS) for k, v in kw.items(): out[_POS[k]] = v return tuple(out) # ── callbacks ──────────────────────────────────────────────────────────────── def on_generate(idea): """Generator: run the pipeline, tick the stopwatch, reveal the full comic at done.""" idea = (idea or "").strip() if not idea: yield _bundle(status0=_progress_html("Please describe the comic you want.", 0, "error")) return # start the stopwatch + switch to the generation view TIMER["t0"] = time.monotonic() TIMER["running"] = True TIMER["final"] = None yield _bundle( input_view=gr.update(visible=False), gen_view=gr.update(visible=True), gen_btn=gr.update(interactive=False), gen_timer=gr.update(active=True), clock=_clock_html(0.0, True), status=_progress_html("Reading your idea and planning the story…", 0.02), live_img=None, ) def elapsed(): return time.monotonic() - TIMER["t0"] comic = None for ev in generate_comic(idea, writer=WRITER, artist=ARTIST): if ev.comic is not None: comic = ev.comic if ev.kind in ("refused", "error"): TIMER["running"] = False tone = "refuse" if ev.kind == "refused" else "error" icon = "🚫 " if ev.kind == "refused" else "⚠ " yield _bundle( input_view=gr.update(visible=True), gen_view=gr.update(visible=False), gen_btn=gr.update(interactive=True), gen_timer=gr.update(active=False), status0=_progress_html(icon + ev.message, 0.0, tone)) return if ev.kind == "image" and ev.panel is not None and ev.panel.image: yield _bundle( comic=comic, clock=_clock_html(elapsed(), True), status=_progress_html(ev.message, ev.progress), live_img=_pil(ev.panel.image)) continue if ev.kind == "done": TIMER["running"] = False TIMER["final"] = elapsed() img1, cap1, img2, cap2, label = _page_view(comic, 0) note = f"✨ {TOTAL_PANELS} panels generated in {_fmt(TIMER['final'])}" yield _bundle( gen_view=gr.update(visible=False), reader_view=gr.update(visible=True), gen_timer=gr.update(active=False), clock=_clock_html(TIMER["final"], False), title=_title_html(comic.bible.title, comic.bible.logline, note), img1=img1, cap1=cap1, img2=img2, cap2=cap2, label=label, comic=comic, idx=0) return # status / bible / panels yield _bundle(comic=comic, clock=_clock_html(elapsed(), True), status=_progress_html(ev.message, ev.progress)) def on_clock_tick(): """Smoothly tick the stopwatch between generator yields; freeze when not running.""" if TIMER["running"] and TIMER["t0"] is not None: return gr.update(value=_clock_html(time.monotonic() - TIMER["t0"], True)) return gr.skip() def on_prev(comic, idx): if comic is None: return (gr.skip(),) * 6 idx = max(0, int(idx) - 1) img1, cap1, img2, cap2, label = _page_view(comic, idx) return img1, cap1, img2, cap2, label, idx def on_next(comic, idx): if comic is None: return (gr.skip(),) * 6 idx = min(PAGES - 1, int(idx) + 1) img1, cap1, img2, cap2, label = _page_view(comic, idx) return img1, cap1, img2, cap2, label, idx def on_new(): TIMER["running"] = False return ( gr.update(visible=True), # input_view gr.update(visible=False), # gen_view gr.update(visible=False), # reader_view gr.update(value="", interactive=True), # idea gr.update(interactive=True), # gen_btn gr.update(active=False), # gen_timer None, # live_img None, # comic 0, # idx ) def on_warm(): threading.Thread(target=WRITER.warm, daemon=True).start() threading.Thread(target=ARTIST.warm, daemon=True).start() note = ("Models warming… give them a moment, then generate." if BACKEND == "modal" else "Ready (mock backend).") return gr.update(value=_progress_html(note, 0.0)) # ── UI ─────────────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Anton&family=Spline+Sans:wght@400;500;600;700&display=swap'); :root { --paper:#f0e6cf; --paper2:#fbf6e9; --ink:#16120d; --ink-soft:#6d6453; --red:#e23b2e; --blue:#214bd6; --yellow:#f4ad15; } /* warm paper + halftone-dot page */ gradio-app, .gradio-container, body { background-color: var(--paper) !important; background-image: radial-gradient(rgba(22,18,13,0.07) 0.7px, transparent 0.8px) !important; background-size: 15px 15px !important; color: var(--ink) !important; font-family:'Spline Sans', ui-sans-serif, sans-serif !important; } .gradio-container {max-width: 940px !important; margin: 0 auto !important;} #landing {max-width: 720px; margin: 0 auto;} /* hero comic panel */ .comic-hero {position:relative; text-align:center; padding:38px 26px 24px; margin:10px 0 22px; background:var(--paper2); border:3px solid var(--ink); border-radius:5px; box-shadow:9px 9px 0 var(--ink); overflow:hidden;} .comic-hero::before {content:''; position:absolute; inset:0; background-image:radial-gradient(var(--ink) 1px, transparent 1.3px); background-size:11px 11px; opacity:.05; pointer-events:none;} .comic-hero .kicker {position:relative; display:inline-block; font-weight:700; font-size:12px; letter-spacing:3px; text-transform:uppercase; color:var(--paper2); background:var(--ink); padding:5px 13px; transform:rotate(-1.4deg);} .comic-hero h1 {position:relative; font-family:'Anton',sans-serif; font-weight:400; font-size:62px; line-height:0.9; letter-spacing:1px; margin:20px 0 0; text-transform:uppercase; color:var(--ink); text-shadow:3px 3px 0 rgba(226,59,46,0.9);} .comic-hero h1 .accent {color:var(--red); text-shadow:3px 3px 0 var(--ink);} .comic-hero p {position:relative; color:var(--ink-soft); font-size:16px; max-width:520px; margin:18px auto 0; line-height:1.55;} .comic-hero p b {color:var(--ink); font-weight:700;} .comic-hero .pills {position:relative; margin-top:18px; display:flex; gap:9px; justify-content:center; flex-wrap:wrap;} .comic-hero .pill {font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase; color:var(--ink); background:var(--paper); border:2px solid var(--ink); border-radius:3px; padding:5px 11px; box-shadow:2px 2px 0 var(--ink);} /* the idea textbox as an inked panel */ #idea_box {background:transparent !important; border:none !important; box-shadow:none !important;} #idea_box textarea {background:var(--paper2) !important; border:3px solid var(--ink) !important; box-shadow:6px 6px 0 var(--ink) !important; border-radius:4px !important; color:var(--ink) !important; font-family:'Spline Sans' !important; font-size:16px !important; line-height:1.5; min-height:104px; padding:14px 16px !important;} #idea_box textarea::placeholder {color:#a79c86 !important;} /* example chips + nav buttons as comic stickers */ #examples {gap:9px; justify-content:center; flex-wrap:wrap; margin-top:4px;} #examples button, #reader_col button {flex:0 0 auto !important; min-width:0 !important; background:var(--paper2) !important; color:var(--ink) !important; border:2.5px solid var(--ink) !important; box-shadow:3px 3px 0 var(--ink) !important; border-radius:3px !important; font-weight:600 !important; transition:transform .08s ease, box-shadow .08s ease !important;} #examples button:hover, #reader_col button:hover {transform:translate(-1px,-1px) !important; box-shadow:4px 4px 0 var(--ink) !important;} #examples button:active, #reader_col button:active {transform:translate(2px,2px) !important; box-shadow:1px 1px 0 var(--ink) !important;} /* the big generate CTA */ #gen_btn {margin-top:10px; background:var(--red) !important; color:#fff !important; border:3px solid var(--ink) !important; box-shadow:7px 7px 0 var(--ink) !important; border-radius:4px !important; font-family:'Anton',sans-serif !important; font-weight:400 !important; font-size:22px !important; letter-spacing:1.5px; text-transform:uppercase; transition:transform .1s ease, box-shadow .1s ease !important;} #gen_btn:hover {transform:translate(-2px,-2px) !important; box-shadow:10px 10px 0 var(--ink) !important;} #gen_btn:active {transform:translate(3px,3px) !important; box-shadow:3px 3px 0 var(--ink) !important;} /* staggered entrance */ @keyframes riseIn {from{opacity:0; transform:translateY(16px);} to{opacity:1; transform:none;}} .comic-hero {animation:riseIn .5s both;} #idea_box {animation:riseIn .5s .08s both;} #examples {animation:riseIn .5s .16s both;} #gen_btn {animation:riseIn .5s .24s both;} #reader_col {max-width: 760px; margin: 0 auto;} #gen_col {max-width: 680px; margin: 0 auto;} #panel_img img, #live_img img {border:3px solid var(--ink) !important; border-radius:4px; box-shadow:6px 6px 0 var(--ink);} """ _HERO = f"""
✦ AI Comic Studio  ·  Issue #01

Comic Book
Generator

Type a story idea and get a full {PAGES}-page, {TOTAL_PANELS}-panel comic — written by Gemma 4, inked by FLUX, start to finish in about a minute.

{PAGES} Pages {TOTAL_PANELS} Panels Consistent Cast Live Timer
""" _EXAMPLE_LABELS = ["🏚️ Lighthouse keeper", "🤖 Robot painter", "🗺️ Lost city", "🍜 Space-pirate noodle shop"] _EXAMPLE_TEXTS = [ "On a storm-battered coast, the last lighthouse keeper guards a secret that washes ashore. Moody, atmospheric mystery.", "A shy robot in a flooded future city learns to paint, and befriends a stray cat that changes everything.", "A brave cartographer and her apprentice brave the jungle to find a legendary lost city.", "A retired space-pirate runs a tiny noodle shop on a backwater moon, until her old crew comes calling.", ] def build_ui(): with gr.Blocks(title="Comic Book Generator") as demo: comic_state = gr.State(None) page_idx = gr.State(0) # ── input / landing view ── with gr.Column(visible=True, elem_id="landing") as input_view: gr.HTML(_HERO) idea = gr.Textbox( show_label=False, placeholder="Describe your comic… e.g. On a storm-battered coast, the " "last lighthouse keeper guards a secret that washes ashore.", lines=3, elem_id="idea_box", ) with gr.Row(elem_id="examples"): ex_btns = [gr.Button(lbl, size="sm") for lbl in _EXAMPLE_LABELS] gen_btn = gr.Button("✨ Generate comic", variant="primary", size="lg", elem_id="gen_btn") status0 = gr.HTML(_progress_html("Describe a comic, or tap an example, to begin.")) # ── generation view (stopwatch) ── with gr.Column(visible=False, elem_id="gen_col") as gen_view: clock = gr.HTML(_clock_html(0.0, True)) status = gr.HTML(_progress_html("Starting…", 0.0)) live_img = gr.Image(type="pil", height=360, interactive=False, show_label=True, label="latest panel", elem_id="live_img") # ── reader view ── with gr.Column(visible=False, elem_id="reader_col") as reader_view: title_html = gr.HTML() img1 = gr.Image(type="pil", height=380, interactive=False, show_label=False, elem_id="panel_img") cap1 = gr.HTML() img2 = gr.Image(type="pil", height=380, interactive=False, show_label=False, elem_id="panel_img") cap2 = gr.HTML() with gr.Row(): label = gr.HTML(_label_html(0)) prev_btn = gr.Button("←", scale=0, min_width=64) next_btn = gr.Button("→", scale=0, min_width=64) new_btn = gr.Button("+ New comic", variant="secondary") gen_timer = gr.Timer(0.1, active=False) # wiring. status (gen view) shows live progress; status0 (input view) shows # refusals/empty-input errors, since the gen view is hidden in those cases. gen_outputs = [input_view, gen_view, reader_view, gen_btn, clock, status, status0, live_img, gen_timer, title_html, img1, cap1, img2, cap2, label, comic_state, page_idx] gen_btn.click(on_generate, [idea], gen_outputs) idea.submit(on_generate, [idea], gen_outputs) # example chips populate the textbox (tap, then Generate) for b, text in zip(ex_btns, _EXAMPLE_TEXTS): b.click(lambda t=text: t, None, idea) gen_timer.tick(on_clock_tick, None, [clock]) nav_outputs = [img1, cap1, img2, cap2, label, page_idx] prev_btn.click(on_prev, [comic_state, page_idx], nav_outputs) next_btn.click(on_next, [comic_state, page_idx], nav_outputs) new_btn.click(on_new, None, [input_view, gen_view, reader_view, idea, gen_btn, gen_timer, live_img, comic_state, page_idx]) demo.load(on_warm, None, [status0]) return demo if __name__ == "__main__": demo = build_ui() demo.queue(default_concurrency_limit=8).launch( theme=gr.themes.Base(), css=CSS, share=os.environ.get("COMIC_SHARE") == "1", server_name=os.environ.get("GRADIO_SERVER_NAME", "127.0.0.1"), )