"""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""
)
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""
)
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"),
)