Upload comic/writer.py with huggingface_hub
Browse files- comic/writer.py +260 -0
comic/writer.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemma prompting + parsing for the comic writer.
|
| 2 |
+
|
| 3 |
+
Two prompt families, both demanding STRICT JSON so parsing is reliable:
|
| 4 |
+
|
| 5 |
+
build_bible_messages(idea) -> Gemma call #1: safety gate + story bible.
|
| 6 |
+
build_panel_messages(bible, pages..) -> Gemma calls #2..N: the actual panels for a
|
| 7 |
+
small batch of pages, given the bible and a
|
| 8 |
+
recap of the story so far (continuity).
|
| 9 |
+
|
| 10 |
+
This module is backend-agnostic: it only builds message lists and parses replies.
|
| 11 |
+
The engine owns the model calls. Robust JSON extraction tolerates a stray ```json
|
| 12 |
+
fence, leftover <think> blocks, or prose around the object (belt-and-braces on top of
|
| 13 |
+
vLLM's --reasoning-parser, which already strips the thinking channel).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
from typing import List, Optional
|
| 21 |
+
|
| 22 |
+
from .schema import (
|
| 23 |
+
ComicBible, Panel, PageSynopsis, PAGES, PANELS_PER_PAGE,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# How many pages to script per panel call. 5 pages = 10 panels/call -> 5 calls for a
|
| 27 |
+
# 25-page (50-panel) comic. Small enough that each JSON reply stays well within the
|
| 28 |
+
# token budget (even with thinking) and the model keeps full continuity context; big
|
| 29 |
+
# enough to keep round-trips down.
|
| 30 |
+
PANEL_BATCH_PAGES = 5
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ββ JSON extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
|
| 36 |
+
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL | re.IGNORECASE)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _first_json_object(text: str) -> Optional[str]:
|
| 40 |
+
"""Return the first balanced {...} object in `text` (string/escape aware)."""
|
| 41 |
+
start = text.find("{")
|
| 42 |
+
if start < 0:
|
| 43 |
+
return None
|
| 44 |
+
depth = 0
|
| 45 |
+
in_str = False
|
| 46 |
+
esc = False
|
| 47 |
+
for i in range(start, len(text)):
|
| 48 |
+
c = text[i]
|
| 49 |
+
if in_str:
|
| 50 |
+
if esc:
|
| 51 |
+
esc = False
|
| 52 |
+
elif c == "\\":
|
| 53 |
+
esc = True
|
| 54 |
+
elif c == '"':
|
| 55 |
+
in_str = False
|
| 56 |
+
continue
|
| 57 |
+
if c == '"':
|
| 58 |
+
in_str = True
|
| 59 |
+
elif c == "{":
|
| 60 |
+
depth += 1
|
| 61 |
+
elif c == "}":
|
| 62 |
+
depth -= 1
|
| 63 |
+
if depth == 0:
|
| 64 |
+
return text[start:i + 1]
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def extract_json(text: str) -> dict:
|
| 69 |
+
"""Parse the model reply into a dict, tolerating fences/prose/thinking leftovers.
|
| 70 |
+
|
| 71 |
+
Raises ValueError if nothing JSON-like is found, so callers can surface a clear
|
| 72 |
+
error (and retry) rather than silently producing an empty comic.
|
| 73 |
+
"""
|
| 74 |
+
if not text or not text.strip():
|
| 75 |
+
raise ValueError("empty model reply")
|
| 76 |
+
cleaned = _THINK_RE.sub("", text).strip()
|
| 77 |
+
|
| 78 |
+
# Prefer a fenced block if present, else the raw text.
|
| 79 |
+
candidates = []
|
| 80 |
+
m = _FENCE_RE.search(cleaned)
|
| 81 |
+
if m:
|
| 82 |
+
candidates.append(m.group(1))
|
| 83 |
+
candidates.append(cleaned)
|
| 84 |
+
|
| 85 |
+
for cand in candidates:
|
| 86 |
+
for blob in (cand, _first_json_object(cand)):
|
| 87 |
+
if not blob:
|
| 88 |
+
continue
|
| 89 |
+
try:
|
| 90 |
+
obj = json.loads(blob)
|
| 91 |
+
if isinstance(obj, dict):
|
| 92 |
+
return obj
|
| 93 |
+
except json.JSONDecodeError:
|
| 94 |
+
continue
|
| 95 |
+
raise ValueError("no JSON object found in model reply")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ββ Call #1: gatekeeper + story bible ββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
BIBLE_SYSTEM = (
|
| 101 |
+
"You are a professional comic-book writer and art director. From a reader's "
|
| 102 |
+
"request you design a complete comic of exactly "
|
| 103 |
+
f"{PAGES} pages, {PANELS_PER_PAGE} panels per page ({PAGES * PANELS_PER_PAGE} "
|
| 104 |
+
"panels total). You are ALSO the content gatekeeper.\n\n"
|
| 105 |
+
"SAFETY FIRST. Refuse (approved=false) only if the request asks for: sexual "
|
| 106 |
+
"content involving minors; real, named people in sexual or defamatory scenes; "
|
| 107 |
+
"extreme gore or cruelty for shock value; hateful or harassing content toward a "
|
| 108 |
+
"protected group; or instructions that enable real-world harm. Ordinary fictional "
|
| 109 |
+
"adventure, action, peril, rivalry, mystery, romance, horror and comedy ARE allowed. "
|
| 110 |
+
"When you refuse, give one short, polite sentence and leave the other fields empty.\n\n"
|
| 111 |
+
"If approved, design a story BIBLE:\n"
|
| 112 |
+
"- a punchy title and a one-sentence logline;\n"
|
| 113 |
+
"- a FIXED cast of 1 to 4 main characters. Each gets a name and a single vivid, "
|
| 114 |
+
"concrete VISUAL description (species/build, age, hair, face, signature clothing, "
|
| 115 |
+
"colors, props) of about 25-40 words. This description is reused verbatim in every "
|
| 116 |
+
"image, so it must be self-contained and unambiguous;\n"
|
| 117 |
+
"- one global art_style line (medium, linework, shading) and one palette line, both "
|
| 118 |
+
"constant for the whole comic;\n"
|
| 119 |
+
f"- a {PAGES}-page synopsis: one vivid sentence per page, together forming a full arc "
|
| 120 |
+
"(setup, rising action, midpoint turn, climax, resolution).\n\n"
|
| 121 |
+
"Output STRICT JSON ONLY β no markdown, no commentary. Schema:\n"
|
| 122 |
+
"{\n"
|
| 123 |
+
' "approved": true,\n'
|
| 124 |
+
' "refusal_reason": "",\n'
|
| 125 |
+
' "title": "...",\n'
|
| 126 |
+
' "logline": "...",\n'
|
| 127 |
+
' "art_style": "...",\n'
|
| 128 |
+
' "palette": "...",\n'
|
| 129 |
+
' "characters": [ {"name": "...", "appearance": "..."} ],\n'
|
| 130 |
+
f' "pages": [ {{"page": 1, "synopsis": "..."}} ... exactly {PAGES} items ]\n'
|
| 131 |
+
"}"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build_bible_messages(idea: str) -> list:
|
| 136 |
+
user = (
|
| 137 |
+
f"Reader's request:\n\"\"\"\n{idea.strip()}\n\"\"\"\n\n"
|
| 138 |
+
f"Decide if it is allowed, then (if allowed) design the full {PAGES}-page comic "
|
| 139 |
+
"bible. Remember: character appearance descriptions are reused verbatim in every "
|
| 140 |
+
"panel image, so make them detailed and consistent. Output strict JSON only."
|
| 141 |
+
)
|
| 142 |
+
return [
|
| 143 |
+
{"role": "system", "content": BIBLE_SYSTEM},
|
| 144 |
+
{"role": "user", "content": user},
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def parse_bible(reply: str) -> ComicBible:
|
| 149 |
+
"""Parse call #1. On approval, pad/truncate pages to exactly PAGES entries."""
|
| 150 |
+
bible = ComicBible.from_dict(extract_json(reply))
|
| 151 |
+
if bible.approved:
|
| 152 |
+
# Normalise to exactly PAGES synopses so downstream batching is clean.
|
| 153 |
+
pages = bible.pages[:PAGES]
|
| 154 |
+
while len(pages) < PAGES:
|
| 155 |
+
pages.append(PageSynopsis(page=len(pages) + 1, synopsis=""))
|
| 156 |
+
for i, p in enumerate(pages, start=1):
|
| 157 |
+
p.page = i
|
| 158 |
+
bible.pages = pages
|
| 159 |
+
return bible
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ββ Calls #2..N: panel script for a batch of pages βββββββββββββββββββββββββββ
|
| 163 |
+
|
| 164 |
+
PANEL_SYSTEM = (
|
| 165 |
+
"You are the same comic-book writer, now scripting individual panels. You are given "
|
| 166 |
+
"the story bible (title, fixed cast with appearances, art style, palette, and the "
|
| 167 |
+
"full page-by-page synopsis) and a recap of the panels written so far. You write the "
|
| 168 |
+
f"{PANELS_PER_PAGE} panels for each requested page, continuing the story coherently.\n\n"
|
| 169 |
+
"For every panel produce:\n"
|
| 170 |
+
"- scene: a purely VISUAL description of what we see in the frame β camera/shot, which "
|
| 171 |
+
"named characters are present and what they are doing, setting and mood. No dialogue "
|
| 172 |
+
"or words that should appear AS text in the image (the image must be text-free).\n"
|
| 173 |
+
"- caption: the reader-facing text shown UNDER the panel: 1-2 short sentences of "
|
| 174 |
+
"narration, optionally one short line of spoken dialogue in quotes. This carries the "
|
| 175 |
+
"story between images.\n"
|
| 176 |
+
"- characters: the list of cast names present in the panel (use the bible's exact "
|
| 177 |
+
"names so their look stays consistent).\n\n"
|
| 178 |
+
"Keep continuity with the recap and the synopsis. Output STRICT JSON ONLY:\n"
|
| 179 |
+
'{ "panels": [ {"page": N, "panel": 1, "scene": "...", "caption": "...", '
|
| 180 |
+
'"characters": ["..."]} , ... ] }'
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _bible_brief(bible: ComicBible) -> str:
|
| 185 |
+
cast = "\n".join(f" - {c.name}: {c.appearance}" for c in bible.characters)
|
| 186 |
+
synopsis = "\n".join(f" Page {p.page}: {p.synopsis}" for p in bible.pages)
|
| 187 |
+
return (
|
| 188 |
+
f"TITLE: {bible.title}\n"
|
| 189 |
+
f"LOGLINE: {bible.logline}\n"
|
| 190 |
+
f"ART STYLE: {bible.art_style}\n"
|
| 191 |
+
f"PALETTE: {bible.palette}\n"
|
| 192 |
+
f"CAST (fixed appearances):\n{cast}\n"
|
| 193 |
+
f"FULL {PAGES}-PAGE SYNOPSIS:\n{synopsis}"
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def build_panel_messages(bible: ComicBible, pages: List[int], recap: str) -> list:
|
| 198 |
+
page_lines = "\n".join(
|
| 199 |
+
f" Page {n}: {bible.pages[n - 1].synopsis}" for n in pages
|
| 200 |
+
)
|
| 201 |
+
recap_block = recap.strip() or "(this is the opening β nothing has happened yet)"
|
| 202 |
+
user = (
|
| 203 |
+
f"{_bible_brief(bible)}\n\n"
|
| 204 |
+
f"STORY SO FAR (panels already written):\n{recap_block}\n\n"
|
| 205 |
+
f"NOW WRITE the {PANELS_PER_PAGE} panels for EACH of these pages, in order:\n"
|
| 206 |
+
f"{page_lines}\n\n"
|
| 207 |
+
f"Return exactly {len(pages) * PANELS_PER_PAGE} panels as strict JSON."
|
| 208 |
+
)
|
| 209 |
+
return [
|
| 210 |
+
{"role": "system", "content": PANEL_SYSTEM},
|
| 211 |
+
{"role": "user", "content": user},
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def parse_panels(reply: str, pages: List[int]) -> List[Panel]:
|
| 216 |
+
"""Parse a panel-batch reply into Panels, coercing to the expected page/panel grid.
|
| 217 |
+
|
| 218 |
+
Defensive: if the model returns the wrong count or scrambled page/panel numbers,
|
| 219 |
+
we slot the panels into the expected (page, panel) order so the comic stays whole.
|
| 220 |
+
"""
|
| 221 |
+
obj = extract_json(reply)
|
| 222 |
+
raw = obj.get("panels")
|
| 223 |
+
if not isinstance(raw, list):
|
| 224 |
+
raw = [obj] # tolerate a bare single panel object
|
| 225 |
+
|
| 226 |
+
expected = [(pg, pn) for pg in pages for pn in range(1, PANELS_PER_PAGE + 1)]
|
| 227 |
+
panels: List[Panel] = []
|
| 228 |
+
for slot, item in zip(expected, raw):
|
| 229 |
+
if not isinstance(item, dict):
|
| 230 |
+
continue
|
| 231 |
+
p = Panel.from_dict(item, default_page=slot[0], default_panel=slot[1])
|
| 232 |
+
# Force onto the expected grid slot β trust position over the model's numbering.
|
| 233 |
+
p.page, p.panel = slot
|
| 234 |
+
panels.append(p)
|
| 235 |
+
return panels
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def batches(pages_per_batch: int = PANEL_BATCH_PAGES) -> List[List[int]]:
|
| 239 |
+
"""Page-number batches covering 1..PAGES, e.g. [[1,2],[3,4],...,[9,10]]."""
|
| 240 |
+
out = []
|
| 241 |
+
for start in range(1, PAGES + 1, pages_per_batch):
|
| 242 |
+
out.append(list(range(start, min(start + pages_per_batch, PAGES + 1))))
|
| 243 |
+
return out
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def recap_from_panels(panels: List[Panel], last: int = 16) -> str:
|
| 247 |
+
"""A compact running summary fed back as continuity context for the next batch.
|
| 248 |
+
|
| 249 |
+
Only the most recent `last` panels are included β the full-arc page synopsis is
|
| 250 |
+
always in the prompt, so this just needs the immediate lead-in. Keeps prompts lean
|
| 251 |
+
and fast across a 50-panel comic.
|
| 252 |
+
"""
|
| 253 |
+
ordered = sorted(panels, key=lambda x: x.index)[-last:]
|
| 254 |
+
lines = []
|
| 255 |
+
for p in ordered:
|
| 256 |
+
cap = p.caption.replace("\n", " ").strip()
|
| 257 |
+
if len(cap) > 160:
|
| 258 |
+
cap = cap[:157] + "..."
|
| 259 |
+
lines.append(f" Page {p.page} panel {p.panel}: {cap}")
|
| 260 |
+
return "\n".join(lines)
|