ASTRALK commited on
Commit
e3fafda
Β·
verified Β·
1 Parent(s): 00207fd

Upload comic/writer.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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)