Spaces:
Running on Zero
Running on Zero
| """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"<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), | |
| ) | |
| # ── 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""" | |
| <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) | |
| # ── 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"), | |
| ) | |