| """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) |
|
|
| |
| |
| TIMER = {"t0": None, "running": False, "final": None} |
|
|
|
|
| |
|
|
| 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"<div style='text-align:center;background:{_PAPER2};border:3px solid {_INK};" |
| f"border-radius:5px;box-shadow:7px 7px 0 {_INK};padding:18px 10px;margin:4px 0 8px'>" |
| f"<div style='font-family:Anton,sans-serif;font-size:62px;color:{col};line-height:1;" |
| f"letter-spacing:2px'>{_fmt(elapsed)}</div>" |
| f"<div style='font-size:12px;font-weight:700;letter-spacing:2px;color:{_INK};" |
| f"margin-top:6px'>{sub}</div></div>" |
| ) |
|
|
|
|
| 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"<div style='font-family:\"Spline Sans\",system-ui;color:{_INK};font-size:14px'>" |
| f"<div style='margin-bottom:7px;font-weight:600'>{message}</div>" |
| f"<div style='background:#e3d8bd;border:2px solid {_INK};border-radius:4px;height:12px;" |
| f"overflow:hidden'>" |
| f"<div style='width:{bar*100:.0f}%;height:12px;background:{col};" |
| f"transition:width .3s'></div></div></div>" |
| ) |
|
|
|
|
| def _title_html(title: str, logline: str = "", time_note: str = "") -> str: |
| sub = (f"<div style='font-size:14px;color:#6d6453;margin-top:6px;font-style:italic'>" |
| f"{logline}</div>" if logline else "") |
| tn = (f"<div style='display:inline-block;font-size:12px;font-weight:700;letter-spacing:1px;" |
| f"text-transform:uppercase;color:#fff;background:#1f8a4c;border:2px solid {_INK};" |
| f"box-shadow:2px 2px 0 {_INK};padding:4px 10px;margin-top:10px'>{time_note}</div>" |
| if time_note else "") |
| return ( |
| f"<div style='text-align:center;padding:18px 16px;background:{_PAPER2};" |
| f"border:3px solid {_INK};border-radius:5px;box-shadow:7px 7px 0 {_INK}'>" |
| f"<div style='font-family:Anton,sans-serif;font-size:34px;letter-spacing:1px;" |
| f"text-transform:uppercase;color:{_INK};line-height:1'>{title}</div>{sub}{tn}</div>" |
| ) |
|
|
|
|
| def _caption_html(text: str) -> str: |
| text = text or "" |
| return ( |
| f"<div style='font-family:\"Spline Sans\",system-ui;font-size:15px;line-height:1.55;" |
| f"color:{_INK};background:{_PAPER2};border:3px solid {_INK};border-radius:4px;" |
| f"box-shadow:4px 4px 0 {_INK};padding:12px 15px;margin:6px 0 14px;font-weight:500'>{text}</div>" |
| ) |
|
|
|
|
| def _label_html(idx: int) -> str: |
| return (f"<div style='font-family:Anton,sans-serif;font-size:20px;letter-spacing:1px;" |
| f"color:{_INK};text-align:right;padding-top:8px'>PAGE {idx + 1} / {PAGES}</div>") |
|
|
|
|
| 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), |
| ) |
|
|
|
|
| |
| _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) |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(value="", interactive=True), |
| gr.update(interactive=True), |
| gr.update(active=False), |
| None, |
| None, |
| 0, |
| ) |
|
|
|
|
| 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)) |
|
|
|
|
| |
|
|
| 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""" |
| <div class='comic-hero'> |
| <div class='kicker'>✦ AI Comic Studio · Issue #01</div> |
| <h1>Comic <span class='accent'>Book</span><br>Generator</h1> |
| <p>Type a story idea and get a full <b>{PAGES}-page, {TOTAL_PANELS}-panel</b> comic — |
| written by <b>Gemma 4</b>, inked by <b>FLUX</b>, start to finish in about a minute.</p> |
| <div class='pills'> |
| <span class='pill'>{PAGES} Pages</span> |
| <span class='pill'>{TOTAL_PANELS} Panels</span> |
| <span class='pill'>Consistent Cast</span> |
| <span class='pill'>Live Timer</span> |
| </div> |
| </div> |
| """ |
|
|
| _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) |
|
|
| |
| 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.")) |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| |
| 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) |
|
|
| |
| 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"), |
| ) |
|
|