diff --git a/README.md b/README.md
index b796d01..8e665cc 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3
> **An honest trade-off.** Free, cloud-only Agnes with no GPU caps how far character consistency can reach. The strongest approaches (IP-Adapter / InsightFace) need a local GPU running SDXL/Flux — incompatible with Inkstone's zero-cost premise. So Inkstone trades *perfect* consistency for *zero-cost + no-GPU + out-of-the-box*, using L1+L2+L3 as the best feasible strategy. Stated plainly, not hidden.
-**Finished-page mode (default)** generates one designed comic page per image call (dynamic panels + in-image lettering). Free-tier models have ceilings on legible text and identity lock — Inkstone optimizes for page-shaped comics, not commercial print parity. If finished pages fail persistently, set `INKSTONE_RENDER_MODE=panel_compose` and re-run; the legacy panel + layout path reuses cached plans where possible.
+**Finished-page mode (default)** generates whole-page art with empty lettering chrome; Inkstone overlays caption/dialogue/sfx using real fonts (CJK-capable). This avoids model-painted glyph distortion. Set `INKSTONE_RENDER_MODE=panel_compose` for the legacy per-panel path. Unlettered art is cached under `pages/blank/` so resume can re-letter without another image API call.
@@ -68,7 +68,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3
-A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.5-flash` → **finished-page mode (default)** plans one comic page per image call (dynamic panels + in-image lettering) → each page is painted directly → pages are bound to PDF or stacked into a webtoon PNG. For the legacy path, set `INKSTONE_RENDER_MODE=panel_compose`: storyboard prompts → per-panel generation → `LayoutEngine` grid layout → export.
+A `txt` novel is split into segments → characters & scenes are extracted with `agnes-2.5-flash` → **finished-page mode (default)** plans one comic page per image call (blank lettering chrome on the art) → each page is painted → Inkstone overlays text with real fonts → pages are bound to PDF or stacked into a webtoon PNG. For the legacy path, set `INKSTONE_RENDER_MODE=panel_compose`: storyboard prompts → per-panel generation → `LayoutEngine` grid layout → export.
The core challenge — **cross-panel character consistency without a GPU** — is handled by a layered strategy:
@@ -159,6 +159,7 @@ Inkstone is configured through environment variables (copy `.env.example` → `.
| `OPENAI_COMPAT_*` | | — | Base URL / key / models when `PROVIDER=openai_compat`. |
| `INKSTONE_L3` | | `0` | Enable the experimental L3 PIL/OpenCV face overlay (`1` to turn on). |
| `INKSTONE_RENDER_MODE` | | `finished_page` | Default: one finished comic page per image call. Set `panel_compose` to use the legacy storyboard → panel → layout path (recovery when finished pages fail). |
+| `INKSTONE_PAGE_SIZE` | | `1024x1536` | Finished-page image size. If the provider rejects the size, Inkstone retries once with `1024x1024`. |
| `INKSTONE_FONT_PATH` | | (auto) | TrueType/OpenType font for dialogue bubbles. |
| `INKSTONE_WEBTOON_MAX_PIXELS` | | `200000000` | Refuse single-strip webtoon compose above this pixel budget. `0` disables. |
| `INKSTONE_UI_HOST` / `INKSTONE_UI_PORT` | | `127.0.0.1` / `8000` | Web UI bind address. |
diff --git a/core/comic/identity.py b/core/comic/identity.py
index faf4881..6dfe9d6 100644
--- a/core/comic/identity.py
+++ b/core/comic/identity.py
@@ -12,6 +12,7 @@
Appearance,
CharacterAliasSuggestion,
CharacterAsset,
+ ComicPagePlan,
ProjectState,
Setting,
)
@@ -22,6 +23,72 @@
"substring match",
)
+# Chinese literary nicknames often embed animal/plant glyphs metaphorically
+# (虎妞, 凤姐). Image models literalize those glyphs unless prompts forbid it.
+_ANIMAL_METAPHOR_CHARS = frozenset("虎龙凤豹狼狮猴蛇鹤狐兔熊鹰")
+
+_HUMAN_LOCK = (
+ "human person only — the name is metaphorical; not an animal, no animal head, "
+ "no fur, no tail, no snout (not a tiger/dragon/phoenix creature)"
+)
+
+
+def name_suggests_animal_metaphor(name: str) -> bool:
+ """True when ``name`` contains a common animal-metaphor ideograph."""
+ return any(ch in _ANIMAL_METAPHOR_CHARS for ch in name or "")
+
+
+def metaphor_identity_lock_line(name: str) -> str:
+ """One-line human-only lock for finished-page / portrait prompts."""
+ return (
+ f"- {name}: human person with a normal human face; Chinese nickname only — "
+ f"NOT a literal animal; NO tiger/dragon head, NO fur, NO snout, NO anthropomorphic beast"
+ )
+
+
+def metaphor_names_on_page(
+ plan: ComicPagePlan,
+ characters_by_name: dict[str, CharacterAsset],
+) -> list[str]:
+ """Collect metaphorical animal-glyph names referenced on a finished page."""
+ found: set[str] = set()
+ for name in plan.reference_characters or []:
+ if name_suggests_animal_metaphor(name):
+ found.add(name)
+ for panel in plan.panels:
+ for name in panel.characters:
+ if name_suggests_animal_metaphor(name):
+ found.add(name)
+ action = panel.action or ""
+ for name in characters_by_name:
+ if name_suggests_animal_metaphor(name) and name in action:
+ found.add(name)
+ return sorted(found)
+
+
+def harden_human_identity_prompt(name: str, prompt: str) -> str:
+ """Prefix an anti-literalization lock for metaphorical animal names.
+
+ Non-metaphor names are returned unchanged. Idempotent if the lock is already
+ present.
+ """
+ text = (prompt or "").strip()
+ if not name_suggests_animal_metaphor(name):
+ return text
+ lower = text.lower()
+ if "metaphorical" in lower and "not an animal" in lower and "human character" in lower:
+ return text
+ core = text
+ if core.startswith(name):
+ core = core[len(name) :].lstrip(" ,;—-")
+ lead = (
+ f"human character — {name} is a metaphorical Chinese nickname only "
+ f"(NOT a literal tiger/dragon/animal); draw a normal human face and body"
+ )
+ if core:
+ return f"{lead}; {core}; {_HUMAN_LOCK}"
+ return f"{lead}; {_HUMAN_LOCK}"
+
def build_l1_from_appearance(
name: str,
@@ -72,6 +139,10 @@ def ensure_character_l1(char: CharacterAsset) -> CharacterAsset:
char.l1_prompt = derived
elif not (char.l1_prompt or "").strip() and derived:
char.l1_prompt = derived
+ if char.l1_prompt:
+ char.l1_prompt = harden_human_identity_prompt(char.name, char.l1_prompt)
+ if char.portrait_prompt:
+ char.portrait_prompt = harden_human_identity_prompt(char.name, char.portrait_prompt)
return char
diff --git a/core/comic/lettering_lang.py b/core/comic/lettering_lang.py
new file mode 100644
index 0000000..4a18fa2
--- /dev/null
+++ b/core/comic/lettering_lang.py
@@ -0,0 +1,142 @@
+"""Detect source lettering script and validate ComicPagePlan lettering fields."""
+
+from __future__ import annotations
+
+import re
+from typing import Literal
+
+from core.comic.fonts import text_requires_cjk
+from core.schemas import ComicPagePlan, PagePanelSpec
+
+Script = Literal["cjk", "latin", "mixed", "unknown"]
+
+_LETTER_RE = re.compile(r"[A-Za-z\u00C0-\u024F\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]")
+
+# Parenthetical glosses that are mostly latin / pinyin (incl. tone marks + curly quotes).
+_PINYIN_PAREN_RE = re.compile(
+ r"[((][^))]*[A-Za-zĀÁǍÀĒÉĚÈĪÍǏÌŌÓǑÒŪÚǓÙÜǖǘǚǜāáǎàēéěèīíǐìōóǒòūúǔùüǖǘǚǜ][^))]*[))]"
+)
+
+_MAX_CAPTION_CHARS = 48
+_MAX_DIALOGUE_CHARS = 36
+_MAX_SFX_CHARS = 16
+
+
+def source_lettering_script(text: str) -> Script:
+ letters = _LETTER_RE.findall(text or "")
+ if not letters:
+ return "unknown"
+ cjk = sum(1 for ch in letters if text_requires_cjk(ch))
+ latin = len(letters) - cjk
+ if cjk and not latin:
+ return "cjk"
+ if latin and not cjk:
+ return "latin"
+ ratio = cjk / len(letters)
+ if ratio >= 0.15:
+ return "cjk"
+ if ratio <= 0.05:
+ return "latin"
+ return "mixed"
+
+
+def strip_pinyin_glosses(text: str) -> str:
+ """Remove parenthetical pinyin / latin pronunciation glosses."""
+ cleaned = _PINYIN_PAREN_RE.sub("", text or "")
+ cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
+ return cleaned.strip(" \t,;;、")
+
+
+def truncate_lettering(text: str, *, kind: str) -> str:
+ """Keep lettering short enough for overlay chrome."""
+ limits = {
+ "caption": _MAX_CAPTION_CHARS,
+ "dialogue": _MAX_DIALOGUE_CHARS,
+ "sfx": _MAX_SFX_CHARS,
+ }
+ limit = limits.get(kind, _MAX_DIALOGUE_CHARS)
+ text = (text or "").strip()
+ if len(text) <= limit:
+ return text
+ return text[: max(1, limit - 1)].rstrip(",,、;; ") + "…"
+
+
+def sanitize_lettering_text(text: str | None, *, kind: str, script: Script) -> str | None:
+ """Clean one lettering field for overlay / storage."""
+ if not text or not str(text).strip():
+ return None
+ cleaned = str(text).strip()
+ if script == "cjk":
+ cleaned = strip_pinyin_glosses(cleaned)
+ # Drop leftover latin-only tails after CJK (e.g. broken gloss remnants).
+ if text_requires_cjk(cleaned):
+ cleaned = re.sub(r"[A-Za-zĀ-žā-ž][A-Za-zĀ-žā-ž\s,.\-']{2,}$", "", cleaned).strip()
+ cleaned = truncate_lettering(cleaned, kind=kind)
+ return cleaned or None
+
+
+def sanitize_plan_lettering(plan: ComicPagePlan, script: Script) -> ComicPagePlan:
+ """Sanitize all caption/dialogue/sfx on a page plan."""
+ panels: list[PagePanelSpec] = []
+ for panel in plan.panels:
+ data = panel.model_dump()
+ for kind in ("caption", "dialogue", "sfx"):
+ data[kind] = sanitize_lettering_text(getattr(panel, kind), kind=kind, script=script)
+ panels.append(PagePanelSpec.model_validate(data))
+ kept_kinds = {
+ (p.panel_id, kind)
+ for p in panels
+ for kind in ("caption", "dialogue", "sfx")
+ if getattr(p, kind)
+ }
+ boxes = [b for b in plan.lettering_boxes if (b.panel_id, b.kind) in kept_kinds]
+ return plan.model_copy(update={"panels": panels, "lettering_boxes": boxes})
+
+
+def _field_mismatch(text: str | None, script: Script) -> bool:
+ if not text or not _LETTER_RE.search(text):
+ return False
+ has_cjk = text_requires_cjk(text)
+ if script == "cjk":
+ # Pure latin, or mostly latin gloss without enough CJK, is a mismatch.
+ if not has_cjk:
+ return True
+ # Chinese + heavy pinyin still counts as polluted for mismatch retry.
+ latin = sum(
+ 1
+ for ch in text
+ if ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ("\u00c0" <= ch <= "\u024f")
+ )
+ cjk = sum(1 for ch in text if text_requires_cjk(ch))
+ return latin >= max(8, cjk // 2) and _PINYIN_PAREN_RE.search(text) is not None
+ if script == "latin":
+ return has_cjk and sum(1 for ch in text if text_requires_cjk(ch)) >= max(
+ 1, len(_LETTER_RE.findall(text)) // 2
+ )
+ return False
+
+
+def lettering_field_mismatches(plan: ComicPagePlan, script: Script) -> list[tuple[str, str, str]]:
+ out: list[tuple[str, str, str]] = []
+ if script not in ("cjk", "latin"):
+ return out
+ for panel in plan.panels:
+ for kind in ("caption", "dialogue", "sfx"):
+ val = getattr(panel, kind)
+ if _field_mismatch(val, script):
+ out.append((panel.panel_id, kind, val or ""))
+ return out
+
+
+def strip_mismatched_lettering(plan: ComicPagePlan, script: Script) -> ComicPagePlan:
+ bad = {(p, k) for p, k, _ in lettering_field_mismatches(plan, script)}
+ panels: list[PagePanelSpec] = []
+ for panel in plan.panels:
+ data = panel.model_dump()
+ for kind in ("caption", "dialogue", "sfx"):
+ if (panel.panel_id, kind) in bad:
+ data[kind] = None
+ panels.append(PagePanelSpec.model_validate(data))
+ boxes = [b for b in plan.lettering_boxes if (b.panel_id, b.kind) not in bad]
+ return plan.model_copy(update={"panels": panels, "lettering_boxes": boxes})
diff --git a/core/comic/page_lettering.py b/core/comic/page_lettering.py
new file mode 100644
index 0000000..2e516e8
--- /dev/null
+++ b/core/comic/page_lettering.py
@@ -0,0 +1,174 @@
+"""Deferred lettering: composite plan text onto blank finished-page art."""
+
+from __future__ import annotations
+
+from PIL import Image, ImageDraw
+
+from core.comic.fonts import resolve_font
+from core.comic.layout import LayoutEngine, _line_height
+from core.comic.lettering_lang import sanitize_lettering_text, source_lettering_script
+from core.schemas import ComicPagePlan
+
+# Bump when overlay geometry/chrome behavior changes so resume can re-letter blanks.
+LETTERING_VERSION = "deferred_v3"
+
+_MARGIN_FRAC = 0.04
+_MAX_W_FRAC = 0.36
+_MAX_H_FRAC = 0.16
+_PAD = 8
+
+
+def resolve_lettering_jobs(
+ plan: ComicPagePlan,
+ *,
+ source_text: str = "",
+) -> list[tuple[str, str, str, tuple[float, float, float, float]]]:
+ script = source_lettering_script(source_text) if source_text else "cjk"
+ box_map = {(b.panel_id, b.kind): (b.x, b.y, b.w, b.h) for b in plan.lettering_boxes}
+ n = max(1, len(plan.panels))
+ jobs: list[tuple[str, str, str, tuple[float, float, float, float]]] = []
+ for i, panel in enumerate(plan.panels):
+ band_y0 = i / n
+ band_h = 1.0 / n
+ slot = 0
+ for kind in ("caption", "dialogue", "sfx"):
+ raw = getattr(panel, kind)
+ text = sanitize_lettering_text(raw, kind=kind, script=script)
+ if not text:
+ continue
+ key = (panel.panel_id, kind)
+ if key in box_map:
+ box = _safe_anchor(*box_map[key], band_y0=band_y0, band_h=band_h, kind=kind)
+ else:
+ box = _heuristic_box(kind, band_y0, band_h, slot=slot)
+ slot += 1
+ jobs.append((panel.panel_id, kind, text, box))
+ return jobs
+
+
+def _clamp_box(x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
+ m = _MARGIN_FRAC
+ w = min(max(w, 0.08), 1.0 - 2 * m)
+ h = min(max(h, 0.05), 1.0 - 2 * m)
+ x = min(max(x, m), 1.0 - m - w)
+ y = min(max(y, m), 1.0 - m - h)
+ return (x, y, w, h)
+
+
+def _safe_anchor(
+ x: float,
+ y: float,
+ w: float,
+ h: float,
+ *,
+ band_y0: float,
+ band_h: float,
+ kind: str,
+) -> tuple[float, float, float, float]:
+ """Clamp LLM boxes and nudge vertically away from band center (faces)."""
+ x, y, w, h = _clamp_box(x, y, min(w, _MAX_W_FRAC + 0.05), min(h, _MAX_H_FRAC + 0.05))
+ cy = y + h / 2
+ band_mid = band_y0 + band_h * 0.5
+ # If the box sits in the middle third of its panel band, push to top or bottom edge.
+ if abs(cy - band_mid) < band_h * 0.18:
+ if kind == "caption" or cy <= band_mid:
+ y = band_y0 + band_h * 0.06
+ else:
+ y = band_y0 + band_h * 0.72
+ y = min(max(y, _MARGIN_FRAC), 1.0 - _MARGIN_FRAC - h)
+ return _clamp_box(x, y, w, h)
+
+
+def _heuristic_box(
+ kind: str, band_y0: float, band_h: float, *, slot: int = 0
+) -> tuple[float, float, float, float]:
+ # Prefer edges of the panel band — avoid vertical center where faces usually sit.
+ if kind == "caption":
+ return _clamp_box(0.06, band_y0 + 0.05 * band_h + slot * 0.04, 0.55, 0.16 * band_h)
+ if kind == "sfx":
+ return _clamp_box(0.58, band_y0 + 0.08 * band_h, 0.30, 0.14 * band_h)
+ # dialogue: top-right of band
+ return _clamp_box(0.52, band_y0 + 0.08 * band_h + slot * 0.05, 0.40, 0.18 * band_h)
+
+
+def fit_lettering_box(
+ engine: LayoutEngine,
+ kind: str,
+ text: str,
+ anchor: tuple[int, int, int, int],
+ page_size: tuple[int, int],
+) -> tuple[int, int, int, int]:
+ """Shrink chrome to text size; keep fully inside the page with a margin."""
+ ax, ay, aw, ah = anchor
+ page_w, page_h = page_size
+ margin_x = max(4, int(page_w * _MARGIN_FRAC))
+ margin_y = max(4, int(page_h * _MARGIN_FRAC))
+ max_w = max(24, min(aw, int(page_w * _MAX_W_FRAC), page_w - 2 * margin_x))
+ max_h = max(24, min(ah, int(page_h * _MAX_H_FRAC), page_h - 2 * margin_y))
+
+ font, _ = resolve_font(text, font_path=engine.font_path)
+ if kind == "sfx":
+ lines = engine._wrap_text(text, font, max(20, max_w - 8))
+ line_h = _line_height(font)
+ need_h = min(max_h, max(line_h, len(lines) * line_h))
+ longest = max((font.getlength(line) for line in lines), default=0)
+ need_w = min(max_w, int(longest) + 8)
+ else:
+ lines = engine._wrap_text(text, font, max(8, max_w - 2 * _PAD))
+ longest = max((font.getlength(line) for line in lines), default=0)
+ need_w = min(max_w, max(24, int(longest) + 2 * _PAD))
+ need_h = min(max_h, engine._bubble_height(text, need_w))
+
+ x = min(max(ax, margin_x), page_w - margin_x - need_w)
+ y = min(max(ay, margin_y), page_h - margin_y - need_h)
+ x = max(margin_x, min(x, page_w - margin_x - need_w))
+ y = max(margin_y, min(y, page_h - margin_y - need_h))
+ return (x, y, need_w, need_h)
+
+
+def letter_finished_page(
+ blank: Image.Image,
+ plan: ComicPagePlan,
+ *,
+ font_path: str | None = None,
+ source_text: str = "",
+) -> Image.Image:
+ img = blank.convert("RGB").copy()
+ draw = ImageDraw.Draw(img)
+ engine = LayoutEngine(font_path=font_path)
+ w, h = img.size
+ occupied: list[tuple[int, int, int, int]] = []
+ for _pid, kind, text, (nx, ny, nw, nh) in resolve_lettering_jobs(plan, source_text=source_text):
+ anchor = (int(nx * w), int(ny * h), max(8, int(nw * w)), max(8, int(nh * h)))
+ box = fit_lettering_box(engine, kind, text, anchor, (w, h))
+ box = _avoid_overlap(box, occupied, (w, h))
+ occupied.append(box)
+ if kind == "caption":
+ engine._draw_caption(draw, box, text)
+ elif kind == "sfx":
+ engine._draw_sfx(draw, box, text)
+ else:
+ engine._draw_bubble(draw, box, text)
+ return img
+
+
+def _avoid_overlap(
+ box: tuple[int, int, int, int],
+ occupied: list[tuple[int, int, int, int]],
+ page_size: tuple[int, int],
+) -> tuple[int, int, int, int]:
+ """Nudge a box downward slightly when it intersects an already-placed box."""
+ x, y, bw, bh = box
+ page_w, page_h = page_size
+ margin_y = max(4, int(page_h * _MARGIN_FRAC))
+ for _ in range(6):
+ hit = False
+ for ox, oy, ow, oh in occupied:
+ if x < ox + ow and x + bw > ox and y < oy + oh and y + bh > oy:
+ y = oy + oh + 4
+ hit = True
+ break
+ if not hit:
+ break
+ y = min(max(y, margin_y), page_h - margin_y - bh)
+ return (x, y, bw, bh)
diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py
index e9f3a77..b17ff29 100644
--- a/core/comic/page_prompt.py
+++ b/core/comic/page_prompt.py
@@ -2,8 +2,44 @@
from __future__ import annotations
-from core.comic.identity import ensure_character_l1
-from core.schemas import CharacterAsset, ComicPagePlan, Setting
+from typing import Literal
+
+from core.comic.identity import (
+ ensure_character_l1,
+ harden_human_identity_prompt,
+ metaphor_identity_lock_line,
+ metaphor_names_on_page,
+)
+from core.comic.visual_bible import (
+ ANTI_CHARACTER_SHEET_LINE,
+ ANTI_MULTI_AGE_COLLAGE_LINE,
+ COSTUME_CHANGE_LOCK_LINE,
+ PERIOD_WARDROBE_LINE,
+ format_color_bible_block,
+ l1_from_canon,
+ parse_stage_ref,
+ resolve_canonical_name,
+ resolve_character_asset,
+)
+from core.schemas import CharacterAsset, ComicPagePlan, Setting, VisualBible
+
+
+def _character_desc_for_prompt(
+ name: str,
+ asset: CharacterAsset,
+ visual_bible: VisualBible | None,
+) -> str:
+ if visual_bible is not None:
+ base, stage = parse_stage_ref(name)
+ canon = visual_bible.characters.get(base)
+ if canon is None:
+ base = resolve_canonical_name(base, visual_bible)
+ canon = visual_bible.characters.get(base)
+ if canon is not None:
+ canon_desc = l1_from_canon(canon, stage)
+ if canon_desc:
+ return harden_human_identity_prompt(name, canon_desc)
+ return harden_human_identity_prompt(name, asset.l1_prompt or "")
def render_finished_page_prompt(
@@ -13,23 +49,62 @@ def render_finished_page_prompt(
settings_by_name: dict[str, Setting],
style_guide: str = "",
strict: bool = False,
+ lettering: Literal["deferred", "in_image"] = "deferred",
+ visual_bible: VisualBible | None = None,
) -> str:
lines: list[str] = [
"Finished readable manga/comic page, A4 portrait single image,",
"dynamic panel layout with gutters (not a flat labeled grid collage),",
"clean black ink line art, soft cel shading, flat colors,",
- "speech bubbles, caption boxes, and SFX lettered legibly in-image,",
- "do not cover faces, hands, or key action with text.",
]
- if strict:
- lines.append(
- "STRICT: render every CAPTION, DIALOGUE, and SFX string exactly as "
- "specified; high-contrast legible lettering; do not omit any text."
+ if lettering == "deferred":
+ lines.extend(
+ [
+ "NO speech bubbles, caption bars, SFX glyphs, or lettering chrome in the image,",
+ "leave clean panel art only — text will be added in post-processing,",
+ "do not render any readable text, letters, or glyphs (no Latin, no CJK),",
+ "do not cover faces, hands, or key action with placeholders.",
+ ]
+ )
+ if strict:
+ lines.append(
+ "STRICT: zero readable characters and zero bubble/caption chrome anywhere."
+ )
+ else:
+ lines.extend(
+ [
+ "speech bubbles, caption boxes, and SFX lettered legibly in-image,",
+ "do not cover faces, hands, or key action with text.",
+ ]
)
- if style_guide:
- lines.append(f"Style: {style_guide}")
+ if strict:
+ lines.append(
+ "STRICT: render every CAPTION, DIALOGUE, and SFX string exactly as "
+ "specified; high-contrast legible lettering; do not omit any text."
+ )
+ effective_style = (
+ visual_bible.style_guide
+ if visual_bible is not None and visual_bible.style_guide
+ else style_guide
+ )
+ if effective_style:
+ lines.append(f"Style: {effective_style}")
+ if visual_bible is not None:
+ color_block = format_color_bible_block(visual_bible)
+ if color_block:
+ lines.append(color_block)
+ lines.append(COSTUME_CHANGE_LOCK_LINE)
+ lines.append(ANTI_CHARACTER_SHEET_LINE)
+ lines.append(PERIOD_WARDROBE_LINE)
+ lines.append(ANTI_MULTI_AGE_COLLAGE_LINE)
lines.append(f"Page purpose: {plan.purpose}")
lines.append(f"Layout intent: {plan.layout_intent}")
+ metaphor_names = metaphor_names_on_page(plan, characters_by_name)
+ if metaphor_names:
+ lines.append(
+ "CRITICAL character identity (Chinese nicknames are metaphorical — draw HUMANS only):"
+ )
+ lines.extend(metaphor_identity_lock_line(name) for name in metaphor_names)
for i, panel in enumerate(plan.panels, start=1):
lines.append(
f"Panel {i} ({panel.panel_id}): role={panel.role}, shape={panel.shape_hint}, "
@@ -40,17 +115,22 @@ def render_finished_page_prompt(
scene = getattr(setting, "scene_prompt", "") if setting else ""
lines.append(f" setting={panel.setting_ref}: {scene}".rstrip(": "))
for name in panel.characters:
- asset = characters_by_name.get(name)
+ asset = resolve_character_asset(name, characters_by_name, visual_bible)
if asset:
ensure_character_l1(asset)
- if asset.l1_prompt:
- lines.append(f" character {name}: {asset.l1_prompt}")
- if panel.caption:
- lines.append(f" CAPTION (exact): {panel.caption}")
- if panel.dialogue:
- lines.append(f" DIALOGUE (exact): {panel.dialogue}")
- if panel.sfx:
- lines.append(f" SFX (exact): {panel.sfx}")
- if panel.lettering_notes:
- lines.append(f" lettering: {panel.lettering_notes}")
+ desc = _character_desc_for_prompt(name, asset, visual_bible)
+ if desc:
+ lines.append(f" character {name}: {desc}")
+ if lettering == "in_image":
+ if panel.caption:
+ lines.append(f" CAPTION (exact): {panel.caption}")
+ if panel.dialogue:
+ lines.append(f" DIALOGUE (exact): {panel.dialogue}")
+ if panel.sfx:
+ lines.append(f" SFX (exact): {panel.sfx}")
+ if panel.lettering_notes:
+ lines.append(f" lettering: {panel.lettering_notes}")
+ else:
+ if panel.lettering_notes:
+ lines.append(f" leave clear space for lettering: {panel.lettering_notes}")
return "\n".join(lines)
diff --git a/core/comic/visual_bible.py b/core/comic/visual_bible.py
new file mode 100644
index 0000000..64b4b57
--- /dev/null
+++ b/core/comic/visual_bible.py
@@ -0,0 +1,858 @@
+"""core.comic.visual_bible — hash, reconcile apply, and ref helpers for Visual Bible."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import re
+from collections.abc import Iterable
+
+from core.comic.identity import merge_character_alias, suggestion_from_alias
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ColorSwatch,
+ ComicPagePlan,
+ ComicPagePlanSet,
+ ProjectState,
+ VisualBible,
+ VisualBibleReconcileResult,
+)
+
+logger = logging.getLogger(__name__)
+
+COSTUME_CHANGE_LOCK_LINE = (
+ "do not change hair color, outfit colors, or skin tone across panels "
+ "unless action says costume change"
+)
+
+ANTI_CHARACTER_SHEET_LINE = (
+ "NO character design sheets, turnarounds, model sheets, or multi-view "
+ "reference collages inside the page."
+)
+
+PERIOD_WARDROBE_LINE = (
+ "Period-accurate wardrobe only; no modern hoodies, sneakers, or athleisure "
+ "unless action explicitly requires costume change."
+)
+
+ANTI_MULTI_AGE_COLLAGE_LINE = (
+ "Do not depict multiple age versions of the same person on one page unless "
+ "layout_intent explicitly calls for a flashback split."
+)
+
+_ASCII_LETTER_RE = re.compile(r"[A-Za-z]")
+_PROSE_MARKER_RE = re.compile(
+ r"(?i)(,|\bwith\b|\bhair\b|\bexpression\b|\bwearing\b|\bbuild\b|\beyes\b|\bage\b|\bold\b)",
+)
+_OUTFIT_WORD_RE = re.compile(
+ r"(?i)\b("
+ r"wearing|hoodie|athletic|sneakers|jacket|suit|dress|skirt|pants|boots|coat|"
+ r"sweater|jeans|uniform|robe|vest|tie|blouse|shirt|trousers|athleisure"
+ r")\b",
+)
+
+_MOTHER_ROLE_MARKERS = ("母", "妈", "mother", "widow", "寡妇")
+_DAUGHTER_ROLE_MARKERS = ("女", "孩", "narrator", "少女", "女儿", "叙述者")
+_COUNT_LOVER_ROLE_MARKERS = ("伯爵", "count", "工厂主", "情人")
+_NOVELIST_ROLE_MARKERS = ("小说家", "作家", "novelist")
+_SERVANT_ROLE_MARKERS = ("仆", "butler", "约翰")
+_MASTER_ROLE_MARKERS = ("主人", "novelist", "作家")
+
+
+def is_illegal_character_name(name: str) -> bool:
+ """True when ``name`` looks like English prose description, not a character label."""
+ text = (name or "").strip()
+ if not text:
+ return False
+ ascii_count = len(_ASCII_LETTER_RE.findall(text))
+ if len(text) > 40 and ascii_count >= 10:
+ return True
+ if text.count(",") >= 2 and ascii_count >= max(len(text) // 3, 8):
+ return True
+ if ascii_count > 0 and _PROSE_MARKER_RE.search(text):
+ if ascii_count >= max(len(text) // 4, 6):
+ return True
+ return False
+
+
+def _role_contains_any(role: str, markers: tuple[str, ...]) -> bool:
+ lower = role.casefold()
+ for marker in markers:
+ if marker in role or marker.casefold() in lower:
+ return True
+ return False
+
+
+def roles_incompatible(role_a: str, role_b: str) -> bool:
+ """True when two role strings describe incompatible person identities."""
+ a = (role_a or "").strip()
+ b = (role_b or "").strip()
+ if not a or not b:
+ return False
+ if a == b:
+ return False
+
+ def _pair(
+ left: str,
+ right: str,
+ markers_a: tuple[str, ...],
+ markers_b: tuple[str, ...],
+ ) -> bool:
+ return _role_contains_any(left, markers_a) and _role_contains_any(right, markers_b)
+
+ incompatible_pairs = (
+ (_MOTHER_ROLE_MARKERS, _DAUGHTER_ROLE_MARKERS),
+ (_DAUGHTER_ROLE_MARKERS, _MOTHER_ROLE_MARKERS),
+ (_COUNT_LOVER_ROLE_MARKERS, _NOVELIST_ROLE_MARKERS),
+ (_NOVELIST_ROLE_MARKERS, _COUNT_LOVER_ROLE_MARKERS),
+ (_SERVANT_ROLE_MARKERS, _MASTER_ROLE_MARKERS),
+ (_MASTER_ROLE_MARKERS, _SERVANT_ROLE_MARKERS),
+ )
+ return any(_pair(a, b, ma, mb) for ma, mb in incompatible_pairs)
+
+
+def normalize_face_lock(text: str) -> str:
+ """Strip outfit-related words so ``face_lock`` stays facial-only."""
+ stripped = re.sub(r",?\s*wearing[^,;]*", "", (text or "").strip(), flags=re.IGNORECASE)
+ parts: list[str] = []
+ for part in re.split(r"[,;]", stripped):
+ chunk = part.strip()
+ if not chunk or _OUTFIT_WORD_RE.search(chunk):
+ continue
+ parts.append(chunk)
+ return ", ".join(parts).strip()
+
+
+_HAIR_MARKER_RE = re.compile(
+ r"(?i)\b(hair|bald|balding|curly|straight|braid|ponytail)\b|[发髻鬃]",
+)
+
+
+def _default_hair_lock() -> str:
+ return "dark hair"
+
+
+def _hair_lock_from_canon_face(canon_face: str) -> str | None:
+ """Derive a short hair lock from the first ``canon_face`` clause when it mentions hair."""
+ text = (canon_face or "").strip()
+ if not text:
+ return None
+ first_clause = re.split(r"[,;]", text, maxsplit=1)[0].strip()
+ if not first_clause or not _HAIR_MARKER_RE.search(first_clause):
+ return None
+ # Hair locks are brief identity tags, not full face prose.
+ return first_clause[:80].strip()
+
+
+DEFAULT_OUTFIT_LOCK = "early 20th century European period clothing"
+
+
+def _default_outfit_lock() -> str:
+ return DEFAULT_OUTFIT_LOCK
+
+
+def ensure_stage_locks(
+ stage: CharacterStage,
+ *,
+ canon_face: str,
+ canonical_name: str = "",
+) -> CharacterStage:
+ """Fill empty stage locks and repair illegal ``portrait_key`` values."""
+ hair_lock = (stage.hair_lock or "").strip()
+ if not hair_lock:
+ # Prefer a short hair hint from the canon face's first clause before generic default.
+ hair_lock = _hair_lock_from_canon_face(canon_face) or _default_hair_lock()
+ outfit_lock = (stage.outfit_lock or "").strip() or _default_outfit_lock()
+ portrait_key = (stage.portrait_key or "").strip()
+ if canonical_name and (not portrait_key or is_illegal_character_name(portrait_key)):
+ portrait_key = f"{canonical_name}@{stage.stage}"
+ return CharacterStage(
+ stage=stage.stage,
+ appearance=stage.appearance,
+ outfit_lock=outfit_lock,
+ hair_lock=hair_lock,
+ portrait_key=portrait_key,
+ )
+
+
+def ensure_canon_locks(canon: CharacterCanon) -> CharacterCanon:
+ """Normalize face lock and ensure every stage has hair/outfit/portrait locks."""
+ face_lock = normalize_face_lock(canon.face_lock) or (canon.face_lock or "").strip()
+ stages = [
+ ensure_stage_locks(
+ stage,
+ canon_face=face_lock,
+ canonical_name=canon.canonical_name,
+ )
+ for stage in canon.stages
+ ]
+ return canon.model_copy(update={"face_lock": face_lock, "stages": stages})
+
+
+def _canonical_for_character(state: ProjectState, name: str) -> str:
+ """Resolve a character or alias string to its bible canonical name."""
+ if not name:
+ return name
+ bible = state.visual_bible
+ if bible is None:
+ return name
+ canon = bible.characters.get(name)
+ if canon is not None:
+ return canon.canonical_name or name
+ return _build_alias_to_canonical_map(bible).get(name, name)
+
+
+def _alias_loses_conflict(owner_role: str, other_role: str) -> bool:
+ """True when ``owner_role`` should drop a contested alias to ``other_role``."""
+ if not roles_incompatible(other_role, owner_role):
+ return False
+ loser_winner_pairs = (
+ (_MOTHER_ROLE_MARKERS, _DAUGHTER_ROLE_MARKERS),
+ (_COUNT_LOVER_ROLE_MARKERS, _NOVELIST_ROLE_MARKERS),
+ (_SERVANT_ROLE_MARKERS, _MASTER_ROLE_MARKERS),
+ )
+ return any(
+ _role_contains_any(owner_role, loser_markers)
+ and _role_contains_any(other_role, winner_markers)
+ for loser_markers, winner_markers in loser_winner_pairs
+ )
+
+
+def _canons_claiming_alias(
+ bible: VisualBible,
+ alias: str,
+ alias_snapshot: dict[str, list[str]] | None = None,
+) -> list[str]:
+ """Return canonical names whose bible entry key, name, or alias list claims ``alias``."""
+ claimed: list[str] = []
+ for key, canon in bible.characters.items():
+ canonical = canon.canonical_name or key
+ aliases = alias_snapshot.get(key, canon.aliases) if alias_snapshot else canon.aliases
+ if alias == key or alias == canonical or alias in aliases:
+ if canonical not in claimed:
+ claimed.append(canonical)
+ return claimed
+
+
+def _drop_incompatible_aliases(
+ aliases: list[str],
+ owner_role: str,
+ state: ProjectState,
+ owner_canonical: str = "",
+ alias_snapshot: dict[str, list[str]] | None = None,
+) -> list[str]:
+ """Keep only aliases that are legal names and role-compatible with ``owner_role``."""
+ owner_key = (owner_canonical or "").strip()
+ owner_resolved = _canonical_for_character(state, owner_key) if owner_key else ""
+ bible = state.visual_bible
+ kept: list[str] = []
+ for alias in aliases:
+ if is_illegal_character_name(alias):
+ continue
+ other_asset = state.characters.get(alias)
+ if (
+ other_asset is not None
+ and alias != owner_key
+ and alias != owner_resolved
+ and roles_incompatible(other_asset.role or "", owner_role)
+ ):
+ continue
+ if bible is not None:
+ other_claimants = [
+ canon
+ for canon in _canons_claiming_alias(bible, alias, alias_snapshot)
+ if canon != owner_resolved
+ ]
+ incompatible_others = [
+ other
+ for other in other_claimants
+ if roles_incompatible(_role_for_character(state, other), owner_role)
+ ]
+ if any(
+ _alias_loses_conflict(owner_role, _role_for_character(state, other))
+ for other in incompatible_others
+ ):
+ continue
+ kept.append(alias)
+ return kept
+
+
+def sanitize_visual_bible_state(state: ProjectState) -> bool:
+ """Clean polluted bible/character state and bump to bible_v2. Returns True if mutated."""
+ bible = state.visual_bible
+ if bible is None:
+ return False
+
+ mutated = False
+
+ illegal_character_keys = [
+ name for name in list(state.characters) if is_illegal_character_name(name)
+ ]
+ for name in illegal_character_keys:
+ del state.characters[name]
+ mutated = True
+
+ for name, asset in state.characters.items():
+ cleaned = _drop_incompatible_aliases(
+ asset.aliases, asset.role or "", state, owner_canonical=name
+ )
+ if cleaned != asset.aliases:
+ asset.aliases = cleaned
+ mutated = True
+
+ illegal_canon_keys = [key for key in list(bible.characters) if is_illegal_character_name(key)]
+ for key in illegal_canon_keys:
+ del bible.characters[key]
+ mutated = True
+
+ canon_alias_snapshot = {key: list(canon.aliases) for key, canon in bible.characters.items()}
+
+ for key, canon in list(bible.characters.items()):
+ owner_role = canon.role or _role_for_character(state, key)
+ cleaned_aliases = _drop_incompatible_aliases(
+ canon.aliases,
+ owner_role,
+ state,
+ owner_canonical=key,
+ alias_snapshot=canon_alias_snapshot,
+ )
+ fixed = ensure_canon_locks(canon.model_copy(update={"aliases": cleaned_aliases}))
+ if cleaned_aliases != canon.aliases or fixed.model_dump() != canon.model_dump():
+ mutated = True
+ bible.characters[key] = fixed
+
+ if bible.version != "bible_v2":
+ bible.version = "bible_v2"
+ mutated = True
+
+ old_hash = bible.content_hash
+ state.visual_bible = refresh_bible_hash(bible)
+ if state.visual_bible.content_hash != old_hash:
+ mutated = True
+
+ return mutated
+
+
+def parse_stage_ref(name: str) -> tuple[str, str]:
+ """Split ``Name@stage`` into base name and stage (default ``default``)."""
+ text = (name or "").strip()
+ if "@" in text:
+ base, stage = text.split("@", 1)
+ base = base.strip()
+ stage = stage.strip() or "default"
+ return base, stage
+ return text, "default"
+
+
+def _bible_hash_payload(bible: VisualBible) -> dict:
+ characters: dict[str, dict] = {}
+ for name, canon in sorted(bible.characters.items()):
+ stages = [
+ {
+ "stage": stage.stage,
+ "outfit_lock": stage.outfit_lock,
+ "hair_lock": stage.hair_lock,
+ }
+ for stage in canon.stages
+ ]
+ characters[name] = {
+ "face_lock": canon.face_lock,
+ "palette_notes": canon.palette_notes,
+ "stages": stages,
+ }
+ return {
+ "style_guide": bible.style_guide,
+ "color": bible.color.model_dump(),
+ "characters": characters,
+ }
+
+
+def compute_bible_hash(bible: VisualBible) -> str:
+ """SHA-256 digest of style, color, and character locks (ignores content_hash)."""
+ payload = json.dumps(_bible_hash_payload(bible), sort_keys=True, ensure_ascii=False)
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def refresh_bible_hash(bible: VisualBible) -> VisualBible:
+ """Return a copy of ``bible`` with ``content_hash`` set from current locks."""
+ return bible.model_copy(update={"content_hash": compute_bible_hash(bible)})
+
+
+def _ensure_canon_alias(bible: VisualBible, canonical: str, alias: str) -> None:
+ if bible is None:
+ return
+ canon = bible.characters.get(canonical)
+ if canon is None:
+ return
+ if alias not in canon.aliases and alias != canonical:
+ canon.aliases.append(alias)
+
+
+def _apply_color_patches(color: ColorBible, patches: list[ColorSwatch]) -> None:
+ """Append or update palette swatches by name."""
+ if not patches:
+ return
+ by_name = {s.name: i for i, s in enumerate(color.palette) if s.name}
+ for patch in patches:
+ if patch.name and patch.name in by_name:
+ color.palette[by_name[patch.name]] = patch
+ else:
+ color.palette.append(patch)
+ if patch.name:
+ by_name[patch.name] = len(color.palette) - 1
+
+
+def _upsert_canon(existing: CharacterCanon, incoming: CharacterCanon) -> CharacterCanon:
+ """Merge incoming canon fields into an existing canonical character."""
+ updates: dict = {}
+ if incoming.face_lock:
+ updates["face_lock"] = incoming.face_lock
+ if incoming.palette_notes:
+ updates["palette_notes"] = incoming.palette_notes
+ if incoming.role:
+ updates["role"] = incoming.role
+ merged = existing.model_copy(update=updates) if updates else existing.model_copy(deep=True)
+
+ for alias in incoming.aliases:
+ if alias not in merged.aliases and alias != merged.canonical_name:
+ merged.aliases.append(alias)
+
+ stage_index = {s.stage: i for i, s in enumerate(merged.stages)}
+ for stage in incoming.stages:
+ if stage.stage in stage_index:
+ idx = stage_index[stage.stage]
+ old = merged.stages[idx]
+ merged.stages[idx] = CharacterStage(
+ stage=stage.stage,
+ outfit_lock=stage.outfit_lock or old.outfit_lock,
+ hair_lock=stage.hair_lock or old.hair_lock,
+ portrait_key=stage.portrait_key or old.portrait_key,
+ )
+ else:
+ merged.stages.append(stage)
+ return merged
+
+
+def _install_reconcile_bible(
+ out: ProjectState,
+ result: VisualBibleReconcileResult,
+) -> None:
+ """Create or update visual bible from reconcile style, color, and canons."""
+ if out.visual_bible is None:
+ out.visual_bible = VisualBible(
+ version="bible_v1",
+ style_guide=result.style_guide or "",
+ color=result.color or ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={c.canonical_name: c for c in result.canons},
+ sheet_ref_local=None,
+ content_hash="",
+ )
+ return
+
+ bible = out.visual_bible
+ for canon in result.canons:
+ existing = bible.characters.get(canon.canonical_name)
+ if existing is None:
+ bible.characters[canon.canonical_name] = canon
+ else:
+ bible.characters[canon.canonical_name] = _upsert_canon(existing, canon)
+
+ if not bible.style_guide and result.style_guide:
+ bible.style_guide = result.style_guide
+
+ if result.color_patches:
+ _apply_color_patches(bible.color, result.color_patches)
+
+
+def _ensure_canonical_character(
+ out: ProjectState,
+ canonical: str,
+ result: VisualBibleReconcileResult,
+) -> None:
+ """Ensure ``canonical`` exists in ``state.characters`` before alias merge."""
+ if canonical in out.characters:
+ return
+ canon = None
+ if out.visual_bible is not None:
+ canon = out.visual_bible.characters.get(canonical)
+ if canon is None:
+ for row in result.canons:
+ if row.canonical_name == canonical:
+ canon = row
+ break
+ if canon is not None:
+ l1 = l1_from_canon(canon)
+ out.characters[canonical] = CharacterAsset(
+ name=canonical,
+ role=canon.role or "",
+ l1_prompt=l1,
+ portrait_prompt=l1,
+ )
+ return
+ for merge in result.merges:
+ if merge.canonical == canonical and merge.alias in out.characters:
+ asset = out.characters[merge.alias]
+ out.characters[canonical] = asset.model_copy(update={"name": canonical})
+ return
+
+
+def _append_needs_review(out: ProjectState, suggestion) -> None:
+ if not any(
+ s.new_name == suggestion.new_name and s.candidate == suggestion.candidate
+ for s in out.needs_review
+ ):
+ out.needs_review.append(suggestion)
+
+
+def _role_for_character(out: ProjectState, name: str) -> str:
+ asset = out.characters.get(name)
+ if asset is not None and (asset.role or "").strip():
+ return asset.role.strip()
+ if out.visual_bible is not None:
+ canon = out.visual_bible.characters.get(name)
+ if canon is not None and (canon.role or "").strip():
+ return canon.role.strip()
+ canonical = _build_alias_to_canonical_map(out.visual_bible).get(name)
+ if canonical and canonical != name:
+ return _role_for_character(out, canonical)
+ return ""
+
+
+def apply_reconcile(
+ state: ProjectState,
+ result: VisualBibleReconcileResult,
+) -> ProjectState:
+ """Apply reconcile merges, stage links, and low-confidence review rows."""
+ out = state.model_copy(deep=True)
+
+ _install_reconcile_bible(out, result)
+
+ for merge in result.merges:
+ if merge.confidence == "high":
+ role_alias = _role_for_character(out, merge.alias)
+ role_canon = _role_for_character(out, merge.canonical)
+ if roles_incompatible(role_alias, role_canon):
+ suggestion = suggestion_from_alias(merge.alias, merge.canonical, merge.reason)
+ _append_needs_review(out, suggestion)
+ continue
+ _ensure_canonical_character(out, merge.canonical, result)
+ try:
+ merge_character_alias(out, merge.alias, merge.canonical)
+ except KeyError as exc:
+ logger.warning(
+ "visual bible merge skipped (%s → %s): %s",
+ merge.alias,
+ merge.canonical,
+ exc,
+ )
+ if out.visual_bible is not None:
+ _ensure_canon_alias(out.visual_bible, merge.canonical, merge.alias)
+ else:
+ suggestion = suggestion_from_alias(merge.alias, merge.canonical, merge.reason)
+ _append_needs_review(out, suggestion)
+
+ if out.visual_bible is not None:
+ for link in result.stages:
+ canon = out.visual_bible.characters.get(link.of_canonical)
+ if canon is None:
+ continue
+ _ensure_canon_alias(out.visual_bible, link.of_canonical, link.name)
+ existing = {s.stage for s in canon.stages}
+ if link.stage not in existing:
+ canon.stages.append(
+ CharacterStage(
+ stage=link.stage,
+ outfit_lock="",
+ hair_lock="",
+ portrait_key=f"{link.of_canonical}@{link.stage}",
+ )
+ )
+
+ return out
+
+
+def alias_to_canonical_map(bible: VisualBible) -> dict[str, str]:
+ """Public alias of ``_build_alias_to_canonical_map`` for pipeline callers."""
+ return _build_alias_to_canonical_map(bible)
+
+
+def _build_alias_to_canonical_map(bible: VisualBible) -> dict[str, str]:
+ mapping: dict[str, str] = {}
+ for key, canon in bible.characters.items():
+ canonical = canon.canonical_name or key
+ for alias in canon.aliases:
+ if alias and alias != canonical:
+ mapping[alias] = canonical
+ return mapping
+
+
+def rewrite_pageset_from_bible(pageset: ComicPagePlanSet, bible: VisualBible) -> ComicPagePlanSet:
+ """Rewrite panel/reference names in ``pageset`` using bible alias map."""
+ mapping = _build_alias_to_canonical_map(bible)
+ if not mapping:
+ return pageset
+ return pageset.model_copy(
+ update={
+ "pages": [rewrite_page_plan_names(plan, mapping) for plan in pageset.pages],
+ }
+ )
+
+
+def ensure_stage_portrait_assets(state: ProjectState) -> None:
+ """Ensure each stage ``portrait_key`` has a ``CharacterAsset`` for rendering."""
+ bible = state.visual_bible
+ if bible is None:
+ return
+ for key, canon in bible.characters.items():
+ canonical = canon.canonical_name or key
+ base_asset = state.characters.get(canonical)
+ for stage in canon.stages:
+ portrait_key = (stage.portrait_key or "").strip()
+ if not portrait_key or portrait_key == canonical:
+ continue
+ if portrait_key in state.characters:
+ continue
+ l1 = l1_from_canon(canon, stage.stage)
+ if base_asset is not None and not l1:
+ state.characters[portrait_key] = base_asset.model_copy(
+ update={"name": portrait_key}
+ )
+ else:
+ state.characters[portrait_key] = CharacterAsset(
+ name=portrait_key,
+ role=canon.role or (base_asset.role if base_asset else ""),
+ l1_prompt=l1,
+ portrait_prompt=l1,
+ )
+
+
+def resolve_canonical_name(name: str, bible: VisualBible | None) -> str:
+ """Resolve ``name`` to canonical bible character name when possible."""
+ if bible is None:
+ return name
+ base, _stage = parse_stage_ref(name)
+ if base in bible.characters:
+ return base
+ mapping = _build_alias_to_canonical_map(bible)
+ return mapping.get(base, base)
+
+
+def resolve_character_asset(
+ name: str,
+ characters_by_name: dict[str, CharacterAsset],
+ bible: VisualBible | None = None,
+) -> CharacterAsset | None:
+ """Look up a character asset, resolving bible aliases and stage portrait keys."""
+ if name in characters_by_name:
+ return characters_by_name[name]
+ if bible is None:
+ return None
+ base, stage = parse_stage_ref(name)
+ canon = bible.characters.get(base)
+ if canon is None:
+ canonical = resolve_canonical_name(base, bible)
+ canon = bible.characters.get(canonical)
+ base = canonical
+ if canon is not None:
+ stage_row = next((s for s in canon.stages if s.stage == stage), None)
+ if stage_row is not None and stage_row.portrait_key:
+ key = stage_row.portrait_key
+ if key in characters_by_name:
+ return characters_by_name[key]
+ canonical = resolve_canonical_name(base, bible)
+ return characters_by_name.get(canonical)
+
+
+def sync_characters_from_bible(state: ProjectState) -> None:
+ """Sync character L1 prompts from bible canons and rewrite cached page plans."""
+ bible = state.visual_bible
+ if bible is None:
+ return
+
+ mapping = _build_alias_to_canonical_map(bible)
+
+ for key, canon in bible.characters.items():
+ canonical = canon.canonical_name or key
+ asset = state.characters.get(canonical)
+ if asset is None:
+ continue
+ l1 = l1_from_canon(canon)
+ if l1:
+ asset.l1_prompt = l1
+ asset.portrait_prompt = l1
+ for alias in canon.aliases:
+ if alias and alias != canonical and alias not in asset.aliases:
+ asset.aliases.append(alias)
+
+ ensure_stage_portrait_assets(state)
+
+ if not mapping:
+ return
+
+ for cache_key, pageset in list(state.page_cache.items()):
+ state.page_cache[cache_key] = rewrite_pageset_from_bible(pageset, bible)
+
+
+def format_color_bible_block(bible: VisualBible) -> str:
+ """Format palette, lighting, and forbidden colors for image prompts."""
+ color = bible.color
+ lines: list[str] = []
+ for swatch in color.palette:
+ if not swatch.hex:
+ continue
+ label = swatch.name or swatch.usage or "color"
+ detail = f"{label} {swatch.hex}"
+ if swatch.usage and swatch.name and swatch.usage != swatch.name:
+ detail = f"{swatch.name} {swatch.hex} ({swatch.usage})"
+ lines.append(detail)
+ if color.lighting:
+ lines.append(f"lighting: {color.lighting}")
+ if color.forbidden:
+ lines.append(f"forbidden: {', '.join(color.forbidden)}")
+ if not lines:
+ return ""
+ return "Color bible:\n" + "\n".join(f" {line}" for line in lines)
+
+
+def l1_from_canon(canon: CharacterCanon, stage: str = "default") -> str:
+ """Build an L1 identity string from canon face lock and stage outfit/hair locks."""
+ parts: list[str] = []
+ if canon.face_lock:
+ parts.append(canon.face_lock)
+ if canon.palette_notes:
+ parts.append(canon.palette_notes)
+ stage_row = next((s for s in canon.stages if s.stage == stage), None)
+ if stage_row is None and canon.stages:
+ stage_row = canon.stages[0]
+ if stage_row is not None:
+ if stage_row.outfit_lock:
+ parts.append(stage_row.outfit_lock)
+ if stage_row.hair_lock:
+ parts.append(stage_row.hair_lock)
+ return ", ".join(parts)
+
+
+def _rewrite_name_list(names: list[str], mapping: dict[str, str]) -> list[str]:
+ out: list[str] = []
+ seen: set[str] = set()
+ for name in names:
+ mapped = mapping.get(name, name)
+ if mapped not in seen:
+ out.append(mapped)
+ seen.add(mapped)
+ return out
+
+
+def backfill_panel_characters(
+ plan: ComicPagePlan,
+ known_names: Iterable[str],
+) -> ComicPagePlan:
+ """Fill empty panel ``characters`` from page refs and action substring matches."""
+ known = [name for name in known_names if name]
+ updated = plan.model_copy(deep=True)
+ for panel in updated.panels:
+ if panel.characters:
+ continue
+ found: list[str] = []
+ seen: set[str] = set()
+ for name in updated.reference_characters:
+ if name not in seen:
+ found.append(name)
+ seen.add(name)
+ action = panel.action or ""
+ for name in known:
+ if name in action and name not in seen:
+ found.append(name)
+ seen.add(name)
+ panel.characters = found
+ return updated
+
+
+def rewrite_page_plan_names(
+ plan: ComicPagePlan,
+ mapping: dict[str, str],
+) -> ComicPagePlan:
+ """Rewrite panel and reference character names using ``mapping``."""
+ updated = plan.model_copy(deep=True)
+ updated.reference_characters = _rewrite_name_list(updated.reference_characters, mapping)
+ for panel in updated.panels:
+ panel.characters = _rewrite_name_list(panel.characters, mapping)
+ return updated
+
+
+def build_visual_sheet(bible: VisualBible) -> None:
+ """Phase B stub — visual sheet generation is deferred to phase C."""
+ return None
+
+
+def _page_character_names(plan: ComicPagePlan) -> list[str]:
+ names: list[str] = []
+ seen: set[str] = set()
+ for name in plan.reference_characters:
+ if name not in seen:
+ names.append(name)
+ seen.add(name)
+ for panel in plan.panels:
+ for name in panel.characters:
+ if name not in seen:
+ names.append(name)
+ seen.add(name)
+ return names
+
+
+def _portrait_path_for_name(
+ name: str,
+ characters_by_name: dict,
+ bible: VisualBible,
+) -> str | None:
+ base, stage = parse_stage_ref(name)
+ canonical = resolve_canonical_name(base, bible)
+ canon = bible.characters.get(canonical)
+ if canon is not None:
+ stage_row = next((s for s in canon.stages if s.stage == stage), None)
+ if stage_row is not None and stage_row.portrait_key:
+ key = stage_row.portrait_key
+ char = characters_by_name.get(key)
+ if char is not None and char.portrait_local:
+ return char.portrait_local
+ char = resolve_character_asset(name, characters_by_name, bible)
+ if char is not None and char.portrait_local:
+ return char.portrait_local
+ return None
+
+
+def collect_finished_page_refs(
+ plan: ComicPagePlan,
+ characters_by_name: dict,
+ bible: VisualBible,
+ *,
+ prev_blank: str | None = None,
+ max_refs: int = 9,
+) -> list[str]:
+ """Collect i2i reference paths: sheet, portraits, then optional previous blank."""
+ refs: list[str] = []
+ seen: set[str] = set()
+
+ def _add(path: str | None) -> bool:
+ if not path or path in seen:
+ return False
+ refs.append(path)
+ seen.add(path)
+ return len(refs) >= max_refs
+
+ if bible.sheet_ref_local:
+ _add(bible.sheet_ref_local)
+
+ for name in _page_character_names(plan):
+ if len(refs) >= max_refs:
+ break
+ _add(_portrait_path_for_name(name, characters_by_name, bible))
+
+ if len(refs) < max_refs and prev_blank:
+ _add(prev_blank)
+
+ return refs
diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py
index a32a94b..c04da8c 100644
--- a/core/pipelines/creative_comic.py
+++ b/core/pipelines/creative_comic.py
@@ -1,17 +1,19 @@
"""core.pipelines.creative_comic — text-to-comic orchestration.
-Drives the whole flow for one source text:
+Default (``finished_page``) flow for one source text:
- segment -> extract -> merge characters -> (portraits) -> storyboard
- -> per panel: build prompt (L1) + collect references (L2) -> generate
- -> face composite (L3) -> layout -> export PDF
+ segment -> extract -> merge characters -> (portraits)
+ -> plan_comic_pages -> one finished page image per plan -> bind PDF/webtoon
-State is persisted to ``state.json`` after every panel so a rerun resumes
-from where it stopped and never regenerates an already-finished panel. Each
-chunk's extracted ``StoryElements`` and planned ``Storyboard`` are cached in
-``ProjectState.chunk_cache``, so a resume reuses them instead of re-paying the
-(billable) chat API for already-planned chunks; only chunks with missing panels
-are re-entered, and only the missing panels are regenerated.
+Legacy (``panel_compose``) flow:
+
+ … -> storyboard -> per panel (L1/L2, optional L3) -> LayoutEngine -> export
+
+Billable chat products are cached so resume does not re-pay:
+``ProjectState.chunk_cache`` holds extract / storyboard / optional page_script;
+``ProjectState.page_cache`` holds finished-page plans (``ComicPagePlanSet``).
+Generated page/panel assets resume independently via ``pages_done`` /
+``panels_done``.
Providers are injected so the pipeline can be exercised without network.
"""
@@ -23,6 +25,7 @@
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass, field
+from functools import partial
from pathlib import Path
try:
@@ -44,10 +47,30 @@
_panel_reference_names,
)
from core.comic.export import ExportEngine
-from core.comic.identity import ensure_character_l1, merge_settings, suggestion_from_alias
+from core.comic.identity import (
+ ensure_character_l1,
+ harden_human_identity_prompt,
+ merge_settings,
+ suggestion_from_alias,
+)
from core.comic.layout import LayoutEngine, PanelImage
+from core.comic.page_lettering import LETTERING_VERSION, letter_finished_page
from core.comic.page_prompt import render_finished_page_prompt
from core.comic.segmentation import detect_character_aliases, merge_characters, segment_text
+from core.comic.visual_bible import (
+ apply_reconcile,
+ backfill_panel_characters,
+ collect_finished_page_refs,
+ format_color_bible_block,
+ l1_from_canon,
+ parse_stage_ref,
+ refresh_bible_hash,
+ resolve_canonical_name,
+ resolve_character_asset,
+ rewrite_pageset_from_bible,
+ sanitize_visual_bible_state,
+ sync_characters_from_bible,
+)
from core.config import ImageConfig, finished_page_size, l3_enabled, page_script_enabled
from core.config import render_mode as config_render_mode
from core.perf import PerfCollector
@@ -71,6 +94,7 @@
plan_comic_pages,
plan_page_script,
plan_storyboard,
+ reconcile_visual_bible,
)
logger = logging.getLogger(__name__)
@@ -120,6 +144,23 @@ def _structure_fingerprint(source_txt: str) -> str:
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+def _bible_fingerprint_kwargs(visual_bible) -> tuple[str | None, str | None]:
+ if visual_bible is None or not visual_bible.content_hash:
+ return None, None
+ return visual_bible.version, visual_bible.content_hash
+
+
+def _known_character_names(state: ProjectState) -> list[str]:
+ names: list[str] = list(state.characters.keys())
+ bible = state.visual_bible
+ if bible is None:
+ return names
+ for canon_name, canon in bible.characters.items():
+ names.append(canon_name)
+ names.extend(canon.aliases)
+ return names
+
+
def _render_fingerprint(
style_guide: str | None,
*,
@@ -128,16 +169,26 @@ def _render_fingerprint(
l3_enabled: bool,
render_mode: str = "finished_page",
page_size: str = "1024x1536",
+ bible_version: str | None = None,
+ bible_hash: str | None = None,
) -> str:
+ fp_payload: dict[str, object] = {
+ "style_guide": style_guide or "",
+ "model_snapshot": snapshot.model_dump(),
+ "panel_continuity": panel_continuity,
+ "l3_enabled": l3_enabled,
+ "render_mode": render_mode,
+ "page_size": page_size,
+ "identity": "metaphor_v2",
+ }
+ if render_mode == "finished_page":
+ fp_payload["lettering"] = "deferred_v3"
+ if bible_version is not None:
+ fp_payload["visual_bible"] = bible_version
+ if bible_hash is not None:
+ fp_payload["bible_hash"] = bible_hash
payload = json.dumps(
- {
- "style_guide": style_guide or "",
- "model_snapshot": snapshot.model_dump(),
- "panel_continuity": panel_continuity,
- "l3_enabled": l3_enabled,
- "render_mode": render_mode,
- "page_size": page_size,
- },
+ fp_payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
@@ -224,6 +275,19 @@ def _page_asset_path(pages_dir: Path, chunk_index: int, page_index: int) -> Path
return pages_dir / f"page_c{chunk_index:04d}_p{page_index:04d}.png"
+def _letter_page_from_blank(
+ blank_path: Path,
+ local_path: Path,
+ plan: ComicPagePlan,
+ *,
+ source_text: str = "",
+) -> None:
+ """Render deferred lettering from a persisted blank page."""
+ with Image.open(blank_path) as blank:
+ lettered = letter_finished_page(blank, plan, source_text=source_text)
+ lettered.save(local_path)
+
+
def _finished_page_files(pages_dir: Path) -> list[Path]:
"""Return finished-page assets only, excluding panel-compose ``page_NN.png`` leftovers."""
return sorted(p for p in pages_dir.glob("page_*.png") if p.match("page_c*_p*.png"))
@@ -408,10 +472,11 @@ def _mark_page_chunk_done_if_complete(
def _soft_invalidate_render(state: ProjectState) -> None:
- """Drop render-owned assets while keeping structural cache (extract/storyboard).
+ """Drop render-owned assets while keeping structural chat caches.
- Content-policy ``skipped`` entries are preserved: the source text did not
- change, so re-attempting those panels only burns quota.
+ Preserves ``chunk_cache`` (extract/storyboard) and ``page_cache`` (finished
+ page plans). Content-policy ``skipped`` / ``skipped_pages`` are kept: the
+ source text did not change, so re-attempting those assets only burns quota.
"""
state.panels_done = []
state.stale_panels = []
@@ -424,6 +489,31 @@ def _soft_invalidate_render(state: ProjectState) -> None:
asset.portrait_local = None
+_FALLBACK_PAGE_SIZE = "1024x1024"
+
+
+def is_unsupported_image_size_error(exc: Exception) -> bool:
+ """Return True when a provider likely rejected the requested output size.
+
+ Used to fall back from portrait sizes (e.g. ``1024x1536``) to ``1024x1024``
+ once. Content-policy rejects are excluded so they stay on the skip path.
+ """
+ if is_content_policy_rejection(exc):
+ return False
+ text = str(exc).lower()
+ size_tokens = ("size", "resolution", "dimension", "1024x1536", "aspect")
+ reject_tokens = (
+ "invalid",
+ "unsupported",
+ "not supported",
+ "not allow",
+ "not allowed",
+ "unknown",
+ "bad request",
+ )
+ return any(t in text for t in size_tokens) and any(t in text for t in reject_tokens)
+
+
def _reconcile_state(state: ProjectState, state_path: Path, output_dir: Path) -> None:
"""Remove every missing or escaped persisted asset before a resume trusts it."""
changed = False
@@ -442,16 +532,26 @@ def _reconcile_state(state: ProjectState, state_path: Path, output_dir: Path) ->
state.panels_done.remove(panel_id)
changed = True
- invalid_pages = [
- page_id
- for page_id, generated in state.generated.pages.items()
- if not _is_within(generated.local, output_dir) or not Path(generated.local).is_file()
- ]
+ invalid_pages = []
+ pages_missing_lettered = []
+ for page_id, generated in state.generated.pages.items():
+ local_valid = _is_within(generated.local, output_dir) and Path(generated.local).is_file()
+ blank_valid = bool(
+ generated.blank_local
+ and _is_within(generated.blank_local, output_dir)
+ and Path(generated.blank_local).is_file()
+ )
+ if not local_valid:
+ pages_missing_lettered.append(page_id)
+ if not local_valid and not blank_valid:
+ invalid_pages.append(page_id)
for page_id in invalid_pages:
state.generated.pages.pop(page_id, None)
changed = True
stale_pages_done = [
- page_id for page_id in state.pages_done if page_id not in state.generated.pages
+ page_id
+ for page_id in state.pages_done
+ if page_id not in state.generated.pages or page_id in pages_missing_lettered
]
for page_id in stale_pages_done:
state.pages_done.remove(page_id)
@@ -735,14 +835,19 @@ def _report(stage: str, percent: float | None = None) -> None:
l3_enabled=l3_on,
)
struct = _structure_fingerprint(source_txt)
- render = _render_fingerprint(
- style_guide,
- snapshot=snapshot,
- panel_continuity=image_config.panel_continuity,
- l3_enabled=l3_on,
- render_mode=mode,
- page_size=page_size,
- )
+
+ def _render_for_bible(visual_bible=None) -> str:
+ bible_version, bible_hash = _bible_fingerprint_kwargs(visual_bible)
+ return _render_fingerprint(
+ style_guide,
+ snapshot=snapshot,
+ panel_continuity=image_config.panel_continuity,
+ l3_enabled=l3_on,
+ render_mode=mode,
+ page_size=page_size,
+ bible_version=bible_version,
+ bible_hash=bible_hash,
+ )
def _fresh_state() -> ProjectState:
return ProjectState(
@@ -750,28 +855,29 @@ def _fresh_state() -> ProjectState:
source_file=str(output_dir),
source_fingerprint=struct,
structure_fingerprint=struct,
- render_fingerprint=render,
+ render_fingerprint=_render_for_bible(),
model_snapshot=snapshot,
)
soft_invalidated_this_run = False
if state_path.exists():
persisted = ProjectState.load(state_path)
+ expected_render = _render_for_bible(persisted.visual_bible)
if not persisted.structure_fingerprint and not persisted.render_fingerprint:
if persisted.source_fingerprint == fingerprint:
state = persisted
state.structure_fingerprint = struct
- state.render_fingerprint = render
+ state.render_fingerprint = expected_render
state.source_fingerprint = struct
else:
state = _fresh_state()
elif persisted.structure_fingerprint != struct:
state = _fresh_state()
- elif persisted.render_fingerprint != render:
+ elif persisted.render_fingerprint != expected_render:
state = persisted
_soft_invalidate_render(state)
soft_invalidated_this_run = True
- state.render_fingerprint = render
+ state.render_fingerprint = expected_render
state.model_snapshot = snapshot
else:
state = persisted
@@ -788,6 +894,11 @@ def _fresh_state() -> ProjectState:
_reconcile_state(state, state_path, output_dir)
+ if sanitize_visual_bible_state(state):
+ _soft_invalidate_render(state)
+ state.render_fingerprint = _render_for_bible(state.visual_bible)
+ state.save(state_path)
+
with perf.measure("segment"):
chunks = segment_text(source_txt)
total_chunks = len(chunks) or 1
@@ -815,12 +926,13 @@ def _pct() -> float:
# disk. Re-running reuses the cache so the billable chat API is never
# re-called.
if mode == "finished_page":
- if (
+ chunk_complete = (
pageset is not None
and key in set(state.chunks_done)
and _page_chunk_complete(state, pageset, output_dir, ci)
and panel_key_filter is None
- ):
+ )
+ if chunk_complete and state.visual_bible is not None:
_report("resume", _pct())
continue
elif (
@@ -828,6 +940,7 @@ def _pct() -> float:
and key in set(state.chunks_done)
and _chunk_complete(state, board, output_dir, ci)
and panel_key_filter is None
+ and state.visual_bible is not None
):
if image_config.panel_continuity and board.panels:
last_index = len(board.panels) - 1
@@ -837,6 +950,7 @@ def _pct() -> float:
continue
# ---- extraction (only when not cached) ----
+ fresh_extract = elements is None
if elements is None:
state.stage = "extract"
try:
@@ -865,17 +979,77 @@ def _pct() -> float:
state.settings = merge_settings(state.settings, elements.settings)
# Surface likely alias variants for human review (never auto-merged).
- for name, cand, reason in detect_character_aliases(state.characters, new_names):
+ hints = list(detect_character_aliases(state.characters, new_names))
+ for name, cand, reason in hints:
sugg = suggestion_from_alias(name, cand, reason)
if sugg not in state.needs_review:
state.needs_review.append(sugg)
- effective_style = style_guide or elements.style_guide
+ if state.visual_bible is None or fresh_extract or new_names:
+ try:
+ recon = await reconcile_visual_bible(
+ chunk,
+ state.characters,
+ state.visual_bible,
+ alias_hints=hints,
+ preferred_style=style_guide or elements.style_guide,
+ chat=chat,
+ )
+ prev_hash = state.visual_bible.content_hash if state.visual_bible else None
+ had_render_assets = bool(
+ state.generated.pages
+ or state.generated.portraits
+ or state.pages_done
+ or state.panels_done
+ )
+ state = apply_reconcile(state, recon)
+ sync_characters_from_bible(state)
+ sanitized = sanitize_visual_bible_state(state)
+ if sanitized:
+ _soft_invalidate_render(state)
+ elif state.visual_bible:
+ state.visual_bible = refresh_bible_hash(state.visual_bible)
+ if prev_hash and state.visual_bible.content_hash != prev_hash:
+ _soft_invalidate_render(state)
+ elif prev_hash is None and had_render_assets:
+ _soft_invalidate_render(state)
+ if state.visual_bible:
+ new_render = _render_for_bible(state.visual_bible)
+ if state.render_fingerprint != new_render:
+ state.render_fingerprint = new_render
+ except Exception as exc: # noqa: BLE001 — reconcile is best-effort
+ logger.warning("visual bible reconcile failed (%s); continuing", exc)
+ if state.visual_bible and state.visual_bible.style_guide:
+ effective_style = state.visual_bible.style_guide
+ else:
+ effective_style = style_guide or elements.style_guide
portrait_style = effective_style
- async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[str, str]:
- asset = state.characters[name]
+ async def _render_portrait(
+ name: str,
+ *,
+ style: str = portrait_style,
+ _state: ProjectState = state,
+ ) -> tuple[str, str]:
+ asset = resolve_character_asset(name, _state.characters, _state.visual_bible)
+ if asset is None:
+ asset = _state.characters[name]
ensure_character_l1(asset)
prompt = asset.portrait_prompt or asset.l1_prompt
+ if _state.visual_bible is not None:
+ base, stage = parse_stage_ref(name)
+ canon = _state.visual_bible.characters.get(base)
+ if canon is None:
+ base = resolve_canonical_name(base, _state.visual_bible)
+ canon = _state.visual_bible.characters.get(base)
+ if canon is not None:
+ canon_prompt = l1_from_canon(canon, stage)
+ if canon_prompt:
+ prompt = canon_prompt
+ prompt = harden_human_identity_prompt(name, prompt)
+ if _state.visual_bible is not None:
+ color_block = format_color_bible_block(_state.visual_bible)
+ if color_block:
+ prompt = f"{prompt}, {color_block}"
comic_style = f"{style}, {DEFAULT_PORTRAIT_STYLE}" if style else DEFAULT_PORTRAIT_STYLE
async with image_semaphore:
with perf.measure("portrait"):
@@ -927,6 +1101,8 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
_report("portrait", _pct())
if mode == "finished_page":
+ if pageset is not None and state.visual_bible is not None:
+ pageset = rewrite_pageset_from_bible(pageset, state.visual_bible)
if pageset is None:
state.stage = "page_plan"
try:
@@ -943,6 +1119,8 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
_report("skip", _pct())
continue
raise
+ if state.visual_bible is not None:
+ pageset = rewrite_pageset_from_bible(pageset, state.visual_bible)
state.page_cache[key] = pageset
state.save(state_path)
_report("page_plan", _pct())
@@ -969,30 +1147,99 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
state.stage = "pages"
check_cancel(cancel_check)
for page_index, plan in enumerate(pageset.pages):
+ if state.visual_bible is not None:
+ plan = backfill_panel_characters(plan, _known_character_names(state))
page_id = plan.page_id
state_key = _page_state_key(ci, page_id)
+ existing = state.generated.pages.get(state_key)
+ blank_ok = bool(
+ existing
+ and existing.blank_local
+ and _is_within(existing.blank_local, output_dir)
+ and Path(existing.blank_local).is_file()
+ )
+ if (
+ blank_ok
+ and existing is not None
+ and existing.lettering_version != LETTERING_VERSION
+ ):
+ pages_dir.mkdir(parents=True, exist_ok=True)
+ local = _page_asset_path(pages_dir, ci, page_index)
+ await asyncio.to_thread(
+ partial(
+ _letter_page_from_blank,
+ Path(existing.blank_local),
+ local,
+ plan,
+ source_text=chunk,
+ )
+ )
+ existing.local = str(local)
+ existing.mode = "finished_lettered"
+ existing.lettering_version = LETTERING_VERSION
+ _mark_page_done(state, state_key)
+ state.save(state_path)
+ _report("pages", _pct())
+ continue
if not _page_needs_generation(state, state_key):
continue
check_cancel(cancel_check)
+ if blank_ok and existing is not None:
+ pages_dir.mkdir(parents=True, exist_ok=True)
+ local = _page_asset_path(pages_dir, ci, page_index)
+ await asyncio.to_thread(
+ partial(
+ _letter_page_from_blank,
+ Path(existing.blank_local),
+ local,
+ plan,
+ source_text=chunk,
+ )
+ )
+ existing.local = str(local)
+ existing.mode = "finished_lettered"
+ existing.lettering_version = LETTERING_VERSION
+ _mark_page_done(state, state_key)
+ state.save(state_path)
+ _report("pages", _pct())
+ continue
+ prev_blank_path: str | None = None
+ if page_index > 0:
+ prev_plan = pageset.pages[page_index - 1]
+ prev_key = _page_state_key(ci, prev_plan.page_id)
+ prev_gen = state.generated.pages.get(prev_key)
+ if prev_gen and prev_gen.blank_local:
+ prev_blank_path = prev_gen.blank_local
prompt = render_finished_page_prompt(
plan,
characters_by_name=state.characters,
settings_by_name=state.settings,
style_guide=effective_style,
+ visual_bible=state.visual_bible,
)
- refs = [
- state.characters[name].portrait_local
- for name in _page_reference_names(plan)
- if name in state.characters and state.characters[name].portrait_local
- ]
+ if state.visual_bible is not None:
+ refs = collect_finished_page_refs(
+ plan,
+ state.characters,
+ state.visual_bible,
+ prev_blank=prev_blank_path,
+ )
+ else:
+ refs = [
+ state.characters[name].portrait_local
+ for name in _page_reference_names(plan)
+ if name in state.characters and state.characters[name].portrait_local
+ ]
refs = [ref for ref in refs if _is_within(ref, output_dir) and Path(ref).is_file()]
stricter_attempted = False
+ size_fallback_attempted = False
+ active_size = page_size
while True:
try:
async with image_semaphore:
with perf.measure("page"):
out = await image.generate_single_image(
- prompt, reference_image_paths=refs, size=page_size
+ prompt, reference_image_paths=refs, size=active_size
)
break
except Exception as exc: # noqa: BLE001 — preserve policy skip behavior
@@ -1007,6 +1254,21 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
state.save(state_path)
out = None
break
+ if (
+ not size_fallback_attempted
+ and active_size != _FALLBACK_PAGE_SIZE
+ and is_unsupported_image_size_error(exc)
+ ):
+ size_fallback_attempted = True
+ logger.warning(
+ "page %s size %s rejected (%s); falling back to %s",
+ page_id,
+ active_size,
+ exc,
+ _FALLBACK_PAGE_SIZE,
+ )
+ active_size = _FALLBACK_PAGE_SIZE
+ continue
if not stricter_attempted:
stricter_attempted = True
prompt = render_finished_page_prompt(
@@ -1015,6 +1277,7 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
settings_by_name=state.settings,
style_guide=effective_style,
strict=True,
+ visual_bible=state.visual_bible,
)
logger.warning(
"page %s image failed (%s); retrying once with stricter prompt",
@@ -1026,14 +1289,28 @@ async def _render_portrait(name: str, *, style: str = portrait_style) -> tuple[s
if out is None:
continue
pages_dir.mkdir(parents=True, exist_ok=True)
+ blank_dir = pages_dir / "blank"
+ blank_dir.mkdir(parents=True, exist_ok=True)
+ blank_path = _page_asset_path(blank_dir, ci, page_index)
+ await asyncio.to_thread(out.save, str(blank_path))
local = _page_asset_path(pages_dir, ci, page_index)
- await asyncio.to_thread(out.save, str(local))
+ await asyncio.to_thread(
+ partial(
+ _letter_page_from_blank,
+ blank_path,
+ local,
+ plan,
+ source_text=chunk,
+ )
+ )
state.generated.pages[state_key] = GeneratedPage(
local=str(local),
+ blank_local=str(blank_path),
+ lettering_version=LETTERING_VERSION,
page_id=page_id,
unit_index=ci,
page_index=page_index,
- mode="finished",
+ mode="finished_lettered",
)
_mark_page_done(state, state_key)
state.save(state_path)
@@ -1127,21 +1404,22 @@ async def _render_panel(
elements_for_panel: StoryElements = panel_elements,
style_for_panel: str = panel_style,
chunk_index: int = panel_chunk_index,
+ _state: ProjectState = state,
) -> GeneratedPanel:
# Same name set for L1 prompt subjects and L2/L3 refs so a model that
# fills only one of characters_present / reference_characters cannot
# silently desync text conditioning from portrait conditioning.
panel_names = _panel_reference_names(panel)
- chars = [state.characters[n] for n in panel_names if n in state.characters]
+ chars = [_state.characters[n] for n in panel_names if n in _state.characters]
prompt = engine.build_panel_prompt(
characters=chars,
- setting=_resolve_setting(state, elements_for_panel, panel.setting_ref),
+ setting=_resolve_setting(_state, elements_for_panel, panel.setting_ref),
action=panel.action,
style_guide=style_for_panel,
)
refs = engine.collect_reference_images(
panel=panel,
- characters_by_name=state.characters,
+ characters_by_name=_state.characters,
prev_panel_local=previous,
)
refs = [ref for ref in refs if _is_within(ref, output_dir) and Path(ref).is_file()]
@@ -1155,9 +1433,9 @@ async def _render_panel(
portrait_ref = next(
(
- state.characters[n].portrait_local
+ _state.characters[n].portrait_local
for n in _panel_reference_names(panel)
- if n in state.characters and state.characters[n].portrait_local
+ if n in _state.characters and _state.characters[n].portrait_local
),
None,
)
diff --git a/core/schemas.py b/core/schemas.py
index 56ffbea..37f8800 100644
--- a/core/schemas.py
+++ b/core/schemas.py
@@ -749,6 +749,32 @@ def _coerce_lettering(cls, value: Any) -> Any:
return text
+class LetteringBox(BaseModel):
+ """Normalized page rectangle for deferred lettering overlay."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ kind: Literal["caption", "dialogue", "sfx"]
+ panel_id: str
+ x: float = 0.0
+ y: float = 0.0
+ w: float = 0.4
+ h: float = 0.12
+
+ @field_validator("panel_id", mode="before")
+ @classmethod
+ def _coerce_panel_id(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("x", "y", "w", "h", mode="before")
+ @classmethod
+ def _coerce_float(cls, value: Any) -> Any:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return 0.0
+
+
class ComicPagePlan(BaseModel):
"""A single finished-page layout plan."""
@@ -758,6 +784,7 @@ class ComicPagePlan(BaseModel):
purpose: str = ""
layout_intent: str = ""
panels: list[PagePanelSpec] = Field(default_factory=list)
+ lettering_boxes: list[LetteringBox] = Field(default_factory=list)
reference_characters: list[str] = Field(default_factory=list)
setting_refs: list[str] = Field(default_factory=list)
@@ -790,6 +817,11 @@ def _coerce_text(cls, value: Any) -> Any:
def _coerce_panels(cls, value: Any) -> Any:
return coerce_model_list(value, PagePanelSpec)
+ @field_validator("lettering_boxes", mode="before")
+ @classmethod
+ def _coerce_lettering_boxes(cls, value: Any) -> Any:
+ return coerce_model_list(value, LetteringBox)
+
@field_validator("reference_characters", "setting_refs", mode="before")
@classmethod
def _coerce_name_lists(cls, value: Any) -> Any:
@@ -886,13 +918,13 @@ class PageScript(BaseModel):
class ChunkCache(BaseModel):
- """Per-chunk cache of the billable chat-API results.
+ """Per-chunk cache of panel-era billable chat-API results.
- ``extract_story_elements`` and ``plan_storyboard`` are the only network/cost
- calls in the pipeline; caching their products per chunk lets a resume reuse
- them instead of re-paying for already-planned chunks. Either field may be
- ``None`` while a chunk is mid-flight (e.g. extraction cached but storyboard
- still pending or rejected), in which case only the missing step is re-run.
+ Holds ``extract_story_elements`` / ``plan_storyboard`` (and optional legacy
+ ``page_script``). Finished-page plans live in ``ProjectState.page_cache`` —
+ a sibling map — so the default ``finished_page`` mode can skip storyboard
+ without stuffing page plans into this panel-era object. Either field here
+ may be ``None`` while a chunk is mid-flight; only the missing step is re-run.
"""
model_config = ConfigDict(extra="ignore")
@@ -939,10 +971,12 @@ class GeneratedPage(BaseModel):
model_config = ConfigDict(extra="ignore")
local: str
+ blank_local: str | None = None
+ lettering_version: str = ""
page_id: str = ""
unit_index: int = 0
page_index: int = 0
- mode: Literal["finished", "composed_fallback"] = "finished"
+ mode: Literal["finished", "finished_lettered", "composed_fallback"] = "finished"
dialogue: str | None = None
caption: str | None = None
sfx: str | None = None
@@ -995,6 +1029,242 @@ class GeneratedAssets(BaseModel):
pages: dict[str, GeneratedPage] = Field(default_factory=dict)
+CharacterStageLiteral = Literal["child", "teen", "adult", "elder", "default"]
+
+
+class ColorSwatch(BaseModel):
+ """A named color entry in the project visual bible palette."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ name: str = ""
+ hex: str = ""
+ usage: str = ""
+
+ @field_validator("name", "hex", "usage", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+
+class ColorBible(BaseModel):
+ """Project-locked palette, lighting, and forbidden color directions."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ palette: list[ColorSwatch] = Field(default_factory=list)
+ lighting: str = ""
+ forbidden: list[str] = Field(default_factory=list)
+
+ @field_validator("palette", mode="before")
+ @classmethod
+ def _coerce_palette(cls, value: Any) -> Any:
+ return coerce_model_list(value, ColorSwatch)
+
+ @field_validator("lighting", mode="before")
+ @classmethod
+ def _coerce_lighting(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("forbidden", mode="before")
+ @classmethod
+ def _coerce_forbidden(cls, value: Any) -> Any:
+ return coerce_str_list(value)
+
+
+class CharacterStage(BaseModel):
+ """Age- or context-specific appearance lock for a canonical character."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ stage: CharacterStageLiteral = "default"
+ appearance: Appearance = Field(default_factory=Appearance)
+ outfit_lock: str = ""
+ hair_lock: str = ""
+ portrait_key: str = ""
+
+ @field_validator("appearance", mode="before")
+ @classmethod
+ def _coerce_appearance(cls, value: Any) -> Any:
+ value = coerce_jsonish(value)
+ if value is None or value == "" or value == []:
+ return {}
+ if isinstance(value, str):
+ return {"distinguishing": value}
+ return value
+
+ @field_validator("outfit_lock", "hair_lock", "portrait_key", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+
+class CharacterCanon(BaseModel):
+ """Canonical character identity with shared face lock and optional stages."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ canonical_name: str
+ aliases: list[str] = Field(default_factory=list)
+ face_lock: str = ""
+ palette_notes: str = ""
+ stages: list[CharacterStage] = Field(default_factory=list)
+ role: str = ""
+
+ @field_validator(
+ "canonical_name",
+ "face_lock",
+ "palette_notes",
+ "role",
+ mode="before",
+ )
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("aliases", mode="before")
+ @classmethod
+ def _coerce_aliases(cls, value: Any) -> Any:
+ return coerce_str_list(value)
+
+ @field_validator("stages", mode="before")
+ @classmethod
+ def _coerce_stages(cls, value: Any) -> Any:
+ return coerce_model_list(value, CharacterStage)
+
+
+class VisualBible(BaseModel):
+ """Project-level style, color, and canonical character locks."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ version: str = "bible_v1"
+ style_guide: str = ""
+ color: ColorBible = Field(default_factory=ColorBible)
+ characters: dict[str, CharacterCanon] = Field(default_factory=dict)
+ sheet_ref_local: str | None = None
+ content_hash: str = ""
+
+ @field_validator("version", "style_guide", "content_hash", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("color", mode="before")
+ @classmethod
+ def _coerce_color(cls, value: Any) -> Any:
+ value = coerce_jsonish(value)
+ if value is None or value == "":
+ return {}
+ return value
+
+ @field_validator("sheet_ref_local", mode="before")
+ @classmethod
+ def _coerce_sheet_ref_local(cls, value: Any) -> Any:
+ if value is None or value == "":
+ return None
+ text = coerce_str(value).strip()
+ return text or None
+
+
+class VisualBibleMerge(BaseModel):
+ """High- or low-confidence alias merge suggestion from bible reconcile."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ alias: str
+ canonical: str
+ confidence: Literal["high", "low"]
+ reason: str = ""
+
+ @field_validator("alias", "canonical", "reason", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+
+class VisualBibleStageLink(BaseModel):
+ """Attach an age-stage identity under an existing canonical character."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ name: str
+ stage: CharacterStageLiteral
+ of_canonical: str
+ reason: str = ""
+
+ @field_validator("name", "of_canonical", "reason", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+
+class VisualBibleKeep(BaseModel):
+ """A new name that should remain an independent canonical character."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ name: str
+ reason: str = ""
+
+ @field_validator("name", "reason", mode="before")
+ @classmethod
+ def _coerce_text_fields(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+
+class VisualBibleReconcileResult(BaseModel):
+ """LLM tool payload for bible create/update."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ merges: list[VisualBibleMerge] = Field(default_factory=list)
+ stages: list[VisualBibleStageLink] = Field(default_factory=list)
+ keeps: list[VisualBibleKeep] = Field(default_factory=list)
+ color_patches: list[ColorSwatch] = Field(default_factory=list)
+ style_guide: str = ""
+ color: ColorBible | None = None
+ canons: list[CharacterCanon] = Field(default_factory=list)
+
+ @field_validator("merges", mode="before")
+ @classmethod
+ def _coerce_merges(cls, value: Any) -> Any:
+ return coerce_model_list(value, VisualBibleMerge)
+
+ @field_validator("stages", mode="before")
+ @classmethod
+ def _coerce_stages(cls, value: Any) -> Any:
+ return coerce_model_list(value, VisualBibleStageLink)
+
+ @field_validator("keeps", mode="before")
+ @classmethod
+ def _coerce_keeps(cls, value: Any) -> Any:
+ return coerce_model_list(value, VisualBibleKeep)
+
+ @field_validator("color_patches", mode="before")
+ @classmethod
+ def _coerce_color_patches(cls, value: Any) -> Any:
+ return coerce_model_list(value, ColorSwatch)
+
+ @field_validator("style_guide", mode="before")
+ @classmethod
+ def _coerce_style_guide(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("color", mode="before")
+ @classmethod
+ def _coerce_color(cls, value: Any) -> Any:
+ value = coerce_jsonish(value)
+ if value is None or value == "":
+ return None
+ return value
+
+ @field_validator("canons", mode="before")
+ @classmethod
+ def _coerce_canons(cls, value: Any) -> Any:
+ return coerce_model_list(value, CharacterCanon)
+
+
def _now_iso() -> str:
return datetime.now().astimezone().isoformat()
@@ -1039,6 +1309,7 @@ class ProjectState(BaseModel):
stale_pages: list[str] = Field(default_factory=list)
skipped_pages: list[str] = Field(default_factory=list)
generated: GeneratedAssets = Field(default_factory=GeneratedAssets)
+ visual_bible: VisualBible | None = None
errors: str = "logs/errors.jsonl"
active_elapsed_seconds: float = 0.0
diff --git a/core/screenwriter.py b/core/screenwriter.py
index 9af5085..bd9b46a 100644
--- a/core/screenwriter.py
+++ b/core/screenwriter.py
@@ -8,16 +8,25 @@
reject the call.
"""
+import json
import logging
import requests
from core.api import get_chat_provider
+from core.comic.lettering_lang import (
+ lettering_field_mismatches,
+ sanitize_plan_lettering,
+ source_lettering_script,
+ strip_mismatched_lettering,
+)
from core.schemas import (
ComicPagePlanSet,
PageScript,
Storyboard,
StoryElements,
+ VisualBible,
+ VisualBibleReconcileResult,
to_tool_schema,
)
@@ -32,10 +41,14 @@
"Language rules: keep character names as they appear in the source text; "
"write every caption / dialogue / sfx line in the same language as "
"the source excerpt (do not translate Chinese source into English). "
+ "CRITICAL: Never mix lettering languages or translate source-language lettering. "
"Lettering: put narration/time-place in caption, spoken lines in dialogue, "
"and onomatopoeia in sfx — leave a field null when unused. "
"Art-direction fields (style_guide, scene_prompt, action, l1_prompt) may stay "
"in English when that helps image models. "
+ "Identity: Chinese nicknames with animal glyphs (虎妞, 凤姐, 豹子头) are usually "
+ "human metaphors — describe a human person in l1_prompt/portrait_prompt; never "
+ "an animal head or anthropomorphic beast unless the source explicitly says so. "
"When planning finished pages, describe manga geometry (splash/inset/diagonal), "
"never only 2x2 or 3x2 grids."
)
@@ -54,7 +67,14 @@
ComicPagePlanSet,
"plan_comic_pages",
"Plan finished comic pages for one text unit: per-page purpose, "
- "dynamic layout_intent, and panel specs with source-language lettering.",
+ "dynamic layout_intent, panel specs with source-language lettering, and "
+ "lettering_boxes as normalized 0-1 page rectangles.",
+)
+RECONCILE_BIBLE_TOOL = to_tool_schema(
+ VisualBibleReconcileResult,
+ "reconcile_visual_bible",
+ "Build or update the project visual bible: merge aliases, attach age stages, "
+ "lock style_guide and color palette, emit CharacterCanon entries.",
)
# Optional local scrub list. Empty by default: content policy is enforced by the
@@ -142,25 +162,142 @@ async def plan_storyboard(text: str, elements: StoryElements, *, chat=None) -> S
async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> ComicPagePlanSet:
"""Plan finished readable pages for ``text`` given ``elements``."""
chat = chat or get_chat_provider()
+ script = source_lettering_script(text)
+ lang_reminder = (
+ "Reminder: caption / dialogue / sfx must match the source language "
+ "(if the excerpt is Chinese, lettering must be Chinese — never English translation). "
+ "Do NOT add pinyin, romanization, or latin glosses in parentheses. "
+ "Keep each caption/dialogue short (one breath). "
+ "Place lettering_boxes near panel edges — never covering faces; keep boxes fully "
+ "inside the page (0.05–0.95). "
+ "Also emit lettering_boxes: normalized 0-1 page rectangles "
+ "(kind, panel_id, x, y, w, h) for every non-null lettering field."
+ )
+ user = (
+ f"{sanitize_text(text)}\n\n"
+ f"Known elements:\n{elements.model_dump_json()}\n\n"
+ "Plan finished readable pages (not a flat 2x2 collage). "
+ "Each page needs purpose, layout_intent, panels, and lettering_boxes. "
+ f"{lang_reminder}"
+ )
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
- {
- "role": "user",
- "content": (
- f"{sanitize_text(text)}\n\n"
- f"Known elements:\n{elements.model_dump_json()}\n\n"
- "Plan finished readable pages (not a flat 2x2 collage). "
- "Each page needs purpose, layout_intent, and panels with "
- "caption/dialogue/sfx in the source language."
- ),
- },
+ {"role": "user", "content": user},
]
args = await chat.chat_function_call(
messages,
[PAGE_PLAN_TOOL],
_tool_choice("plan_comic_pages"),
)
- return ComicPagePlanSet.model_validate(args)
+ pageset = ComicPagePlanSet.model_validate(args)
+
+ def _any_mismatch(plan_set: ComicPagePlanSet) -> bool:
+ return any(lettering_field_mismatches(page, script) for page in plan_set.pages)
+
+ if script in ("cjk", "latin") and _any_mismatch(pageset):
+ logger.warning(
+ "plan_comic_pages language mismatch; retrying once (script=%s)",
+ script,
+ )
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": user + "\n\nCRITICAL: previous plan mixed languages. Fix lettering now.",
+ },
+ ]
+ args = await chat.chat_function_call(
+ messages,
+ [PAGE_PLAN_TOOL],
+ _tool_choice("plan_comic_pages"),
+ )
+ pageset = ComicPagePlanSet.model_validate(args)
+
+ if script in ("cjk", "latin"):
+ pageset = pageset.model_copy(
+ update={
+ "pages": [
+ sanitize_plan_lettering(strip_mismatched_lettering(page, script), script)
+ for page in pageset.pages
+ ],
+ }
+ )
+ else:
+ pageset = pageset.model_copy(
+ update={
+ "pages": [sanitize_plan_lettering(page, "cjk") for page in pageset.pages],
+ }
+ )
+ return pageset
+
+
+async def reconcile_visual_bible(
+ text: str,
+ state_characters: dict,
+ bible: VisualBible | None,
+ *,
+ alias_hints: list[tuple[str, str, str]] = (),
+ preferred_style: str = "",
+ chat=None,
+) -> VisualBibleReconcileResult:
+ """Build or update the project visual bible from chunk text and known characters."""
+ chat = chat or get_chat_provider()
+ char_blob = json.dumps(
+ {k: v.model_dump() for k, v in state_characters.items()},
+ ensure_ascii=False,
+ )
+ hints_blob = (
+ "\n".join(f"- {alias} → {canonical} ({reason})" for alias, canonical, reason in alias_hints)
+ or "(none)"
+ )
+ if bible is None:
+ style_hint = (
+ f" Use preferred_style={preferred_style!r} for style_guide when non-empty."
+ if preferred_style
+ else ""
+ )
+ bible_note = (
+ "No visual bible yet. Fill style_guide, color (4–6 palette swatches), "
+ f"and full canons for every canonical character.{style_hint}"
+ )
+ bible_blob = ""
+ else:
+ bible_note = (
+ "Existing visual bible below. Keep the existing color palette unless "
+ "color_patches are clearly justified. Still return merges/stages/keeps "
+ "for any new names."
+ )
+ bible_blob = bible.model_dump_json()
+ instructions = (
+ "Reconcile character identities for the visual bible.\n"
+ "- Prefer merging pronouns/descriptive labels for the same person "
+ "(男人(被叙述者), 他(被爱者), 李先生, R·) into one canonical.\n"
+ "- Age variants → stages under one canonical, not new root characters.\n"
+ "- Use confidence=high only when clearly the same person; otherwise low.\n"
+ "- Never invent English prose character names "
+ "(no 'man with dark hair, wearing a jacket').\n"
+ "- Never high-merge incompatible roles "
+ "(mother≠daughter, count≠novelist, servant≠master).\n"
+ "- Always fill face_lock, hair_lock, and outfit_lock for every stage.\n"
+ "- portrait_key must be short form {canonical_name}@{stage} only "
+ "(e.g. R@adult), never prose.\n"
+ f"- {bible_note}\n"
+ f"String alias hints:\n{hints_blob}"
+ )
+ user = f"{sanitize_text(text)}\n\nKnown character assets:\n{char_blob}\n\n"
+ if bible_blob:
+ user += f"Current visual bible:\n{bible_blob}\n\n"
+ user += instructions
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user},
+ ]
+ args = await chat.chat_function_call(
+ messages,
+ [RECONCILE_BIBLE_TOOL],
+ _tool_choice("reconcile_visual_bible"),
+ )
+ return VisualBibleReconcileResult.model_validate(args)
PAGE_SCRIPT_TOOL = to_tool_schema(
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 2e421f7..9bf9b17 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -32,7 +32,7 @@ When completing a change:
| Area | Status | Notes |
|---|---|---|
-| Core TXT → comic pipeline | Released | Segmentation, extraction, portraits, storyboard, panels, layout, PDF / Webtoon export, `state.json` resume. **Default render mode is `finished_page`** (one image per comic page); `panel_compose` is the explicit legacy fallback (`INKSTONE_RENDER_MODE=panel_compose`). |
+| Core TXT → comic pipeline | Released | Segmentation, extraction, portraits, storyboard, panels, layout, PDF / Webtoon export, `state.json` resume. **Default render mode is `finished_page`** (one image per comic page); `panel_compose` is the explicit legacy fallback (`INKSTONE_RENDER_MODE=panel_compose`). **Finished-page text:** deferred lettering — blank art + font overlay ([spec](superpowers/specs/2026-07-31-deferred-lettering-design.md)). |
| Providers and reliability | Released | Agnes + OpenAI-compatible routing, rate limit, retry and JSONL error collection. **Local temp:** default Agnes `BASE_URL` is `apihub.agnes-ai.cn` (domestic reachability); revert to `.com` or make env-configurable when access stabilizes (`TODO(temp)` in `core/api/chat_provider.py` / `agnes_image.py`). |
| Cross-chapter identity | Released | L1/L2 consistency, alias review, stale-only redraw; L3 is experimental and off by default |
| Web UI and unattended supervisor | Released | Local browser UI, cancel, retry, review, deadline pause / resume; job/project JSON exposes `render_mode`, `pages_done`, `skipped_pages` |
@@ -58,6 +58,7 @@ Keep these prototypes only as migration material until they conform to the targe
### P0 — Make the current state honest and deterministic
+- [x] Deferred lettering for finished pages: model paints empty chrome; Inkstone overlays caption/dialogue/sfx with CJK-capable fonts. Unlettered art under `pages/blank/` enables resume re-lettering without re-calling the image API. Spec: [`docs/superpowers/specs/2026-07-31-deferred-lettering-design.md`](superpowers/specs/2026-07-31-deferred-lettering-design.md) (local on `feat/deferred-lettering` until merged).
- [ ] Make density a real contract: persist it in `ProjectState`, include it in
the structure fingerprint (`render_fingerprint` covers style/model/L3), pass a
budget to planning, and invalidate affected caches.
diff --git a/docs/superpowers/plans/2026-07-31-deferred-lettering.md b/docs/superpowers/plans/2026-07-31-deferred-lettering.md
new file mode 100644
index 0000000..7be0634
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-31-deferred-lettering.md
@@ -0,0 +1,965 @@
+# Deferred Lettering Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make finished-page comics letter with real CJK/Latin fonts (deferred overlay) and lock lettering language to the source before any image call — eliminating model-painted glyph distortion and CN/EN mix on the default path.
+
+**Architecture:** Language-validate `ComicPagePlan` → blank-lettering image prompt → save blank art → `letter_finished_page` composites bubbles/text via shared LayoutEngine drawers → export lettered `pages/`. Planner emits normalized `lettering_boxes`; heuristics fill gaps.
+
+**Tech Stack:** Python ≥3.10, Pydantic v2, Pillow, existing ChatProvider/ImageProvider, pytest, ruff.
+
+**Spec:** `docs/superpowers/specs/2026-07-31-deferred-lettering-design.md`
+**Issue note (local):** `.issue/2026-07-31-10_14-deferred-lettering-cjk.md`
+
+## Global Constraints
+
+- English code, comments, commits (CONTRIBUTING).
+- No new hard dependencies.
+- Do not break `panel_compose` lettering behavior.
+- Do not nest `page_cache` into `ChunkCache`.
+- No CV bubble detection / OCR in this plan.
+- TDD: failing test → implement → pass → commit per task.
+- Old `state.json` without `blank_local` / `lettering_boxes` must still load.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `core/schemas.py` | `LetteringBox`; extend `ComicPagePlan`, `GeneratedPage` |
+| `core/comic/lettering_lang.py` | **New.** Source script detection + plan language validation / repair helpers |
+| `core/comic/page_prompt.py` | Blank-lettering prompt mode (default) |
+| `core/comic/page_lettering.py` | **New.** Overlay blank page + plan → lettered image |
+| `core/comic/layout.py` | Share draw helpers with page_lettering (extract or call) |
+| `core/screenwriter.py` | Stronger page-plan reminder; validate + one re-plan |
+| `core/pipelines/creative_comic.py` | Blank save, overlay, fingerprint token, re-letter resume |
+| `README.md`, `docs/ROADMAP.md` | Honesty: deferred lettering |
+| `tests/test_lettering_lang.py` | Language lock unit tests |
+| `tests/test_page_prompt.py` | Blank prompt contracts |
+| `tests/test_page_lettering.py` | Overlay + heuristic boxes |
+| `tests/test_schemas_finished_page.py` | Schema round-trip for boxes / blank_local |
+| `tests/test_screenwriter_pages.py` | Re-plan on language mismatch |
+| `tests/test_finished_page_pipeline.py` | End-to-end blank + lettered files |
+
+---
+
+### Task 1: Schema — LetteringBox + GeneratedPage.blank_local
+
+**Files:**
+- Modify: `core/schemas.py`
+- Modify: `tests/test_schemas_finished_page.py`
+
+**Interfaces:**
+- Produces: `LetteringBox`, `ComicPagePlan.lettering_boxes`, `GeneratedPage.blank_local`, `GeneratedPage.mode` includes `"finished_lettered"`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_schemas_finished_page.py (additions)
+from core.schemas import ComicPagePlan, GeneratedPage, LetteringBox, ProjectState
+
+
+def test_lettering_box_and_plan_round_trip():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "establish",
+ "layout_intent": "wide top",
+ "panels": [
+ {
+ "panel_id": "1",
+ "dialogue": "你好",
+ "action": "waves",
+ }
+ ],
+ "lettering_boxes": [
+ {
+ "kind": "dialogue",
+ "panel_id": "1",
+ "x": 0.1,
+ "y": 0.2,
+ "w": 0.4,
+ "h": 0.15,
+ }
+ ],
+ }
+ )
+ assert plan.lettering_boxes[0].kind == "dialogue"
+ assert LetteringBox.model_validate(plan.lettering_boxes[0].model_dump()).w == 0.4
+
+
+def test_generated_page_blank_local_and_lettered_mode():
+ page = GeneratedPage(
+ local="/tmp/pages/page_c0000_p0000.png",
+ blank_local="/tmp/pages/blank/page_c0000_p0000.png",
+ page_id="p0001",
+ mode="finished_lettered",
+ )
+ state = ProjectState(project_id="t", generated={"pages": {"c0000:p0001": page}})
+ loaded = ProjectState.model_validate_json(state.model_dump_json())
+ assert loaded.generated.pages["c0000:p0001"].blank_local.endswith("blank/page_c0000_p0000.png")
+ assert loaded.generated.pages["c0000:p0001"].mode == "finished_lettered"
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_schemas_finished_page.py::test_lettering_box_and_plan_round_trip tests/test_schemas_finished_page.py::test_generated_page_blank_local_and_lettered_mode -v`
+Expected: FAIL (`LetteringBox` missing / mode reject / `blank_local` missing)
+
+- [ ] **Step 3: Minimal schema implementation**
+
+In `core/schemas.py`, near `PagePanelSpec`:
+
+```python
+class LetteringBox(BaseModel):
+ """Normalized page rectangle for deferred lettering overlay."""
+
+ model_config = ConfigDict(extra="ignore")
+
+ kind: Literal["caption", "dialogue", "sfx"]
+ panel_id: str
+ x: float = 0.0
+ y: float = 0.0
+ w: float = 0.4
+ h: float = 0.12
+
+ @field_validator("panel_id", mode="before")
+ @classmethod
+ def _coerce_panel_id(cls, value: Any) -> Any:
+ return coerce_str(value)
+
+ @field_validator("x", "y", "w", "h", mode="before")
+ @classmethod
+ def _coerce_float(cls, value: Any) -> Any:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return 0.0
+```
+
+On `ComicPagePlan` add `lettering_boxes: list[LetteringBox] = Field(default_factory=list)`.
+Include `"lettering_boxes"` in any fused-key repair allowlists if present.
+On `GeneratedPage`:
+
+```python
+mode: Literal["finished", "finished_lettered", "composed_fallback"] = "finished"
+blank_local: str | None = None
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_schemas_finished_page.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/schemas.py tests/test_schemas_finished_page.py
+git commit -m "$(cat <<'EOF'
+feat: add LetteringBox and finished_lettered page fields
+
+EOF
+)"
+```
+
+---
+
+### Task 2: Language lock helpers
+
+**Files:**
+- Create: `core/comic/lettering_lang.py`
+- Create: `tests/test_lettering_lang.py`
+
+**Interfaces:**
+- Produces:
+ - `source_lettering_script(text: str) -> Literal["cjk", "latin", "mixed", "unknown"]`
+ - `lettering_field_mismatches(plan: ComicPagePlan, script: str) -> list[tuple[str, str, str]]`
+ (panel_id, kind, sample)
+ - `strip_mismatched_lettering(plan: ComicPagePlan, script: str) -> ComicPagePlan`
+ (returns copy with bad fields set to None; boxes for those kinds dropped)
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_lettering_lang.py
+from core.comic.lettering_lang import (
+ lettering_field_mismatches,
+ source_lettering_script,
+ strip_mismatched_lettering,
+)
+from core.schemas import ComicPagePlan
+
+
+def test_source_lettering_script_chinese_novel():
+ assert source_lettering_script("第一章\n福贵在村口看着夕阳。") == "cjk"
+
+
+def test_source_lettering_script_english():
+ assert source_lettering_script("Chapter one. Fugui stood at the gate.") == "latin"
+
+
+def test_mismatches_english_dialogue_on_chinese_source():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {"panel_id": "1", "dialogue": "Hello there", "caption": "傍晚,村口。"}
+ ],
+ }
+ )
+ bad = lettering_field_mismatches(plan, "cjk")
+ assert ("1", "dialogue", "Hello there") in bad
+ assert not any(k == "caption" for _, k, _ in bad)
+
+
+def test_strip_mismatched_lettering_drops_english_on_cjk():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [{"panel_id": "1", "dialogue": "Hello", "caption": "傍晚"}],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.1, "w": 0.3, "h": 0.1},
+ {"kind": "caption", "panel_id": "1", "x": 0.1, "y": 0.0, "w": 0.5, "h": 0.1},
+ ],
+ }
+ )
+ fixed = strip_mismatched_lettering(plan, "cjk")
+ assert fixed.panels[0].dialogue is None
+ assert fixed.panels[0].caption == "傍晚"
+ assert all(b.kind != "dialogue" for b in fixed.lettering_boxes)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_lettering_lang.py -v`
+Expected: FAIL (import error)
+
+- [ ] **Step 3: Implement `core/comic/lettering_lang.py`**
+
+```python
+"""Detect source lettering script and validate ComicPagePlan lettering fields."""
+
+from __future__ import annotations
+
+import re
+from typing import Literal
+
+from core.comic.fonts import text_requires_cjk
+from core.schemas import ComicPagePlan, LetteringBox, PagePanelSpec
+
+Script = Literal["cjk", "latin", "mixed", "unknown"]
+
+_LETTER_RE = re.compile(r"[A-Za-z\u00C0-\u024F\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]")
+
+
+def source_lettering_script(text: str) -> Script:
+ letters = _LETTER_RE.findall(text or "")
+ if not letters:
+ return "unknown"
+ cjk = sum(1 for ch in letters if text_requires_cjk(ch))
+ latin = len(letters) - cjk
+ if cjk and not latin:
+ return "cjk"
+ if latin and not cjk:
+ return "latin"
+ ratio = cjk / len(letters)
+ if ratio >= 0.15:
+ return "cjk"
+ if ratio <= 0.05:
+ return "latin"
+ return "mixed"
+
+
+def _field_mismatch(text: str | None, script: Script) -> bool:
+ if not text or not _LETTER_RE.search(text):
+ return False
+ has_cjk = text_requires_cjk(text)
+ if script == "cjk":
+ return not has_cjk
+ if script == "latin":
+ return has_cjk and sum(1 for ch in text if text_requires_cjk(ch)) >= max(
+ 1, len(_LETTER_RE.findall(text)) // 2
+ )
+ return False
+
+
+def lettering_field_mismatches(
+ plan: ComicPagePlan, script: Script
+) -> list[tuple[str, str, str]]:
+ out: list[tuple[str, str, str]] = []
+ if script not in ("cjk", "latin"):
+ return out
+ for panel in plan.panels:
+ for kind in ("caption", "dialogue", "sfx"):
+ val = getattr(panel, kind)
+ if _field_mismatch(val, script):
+ out.append((panel.panel_id, kind, val or ""))
+ return out
+
+
+def strip_mismatched_lettering(plan: ComicPagePlan, script: Script) -> ComicPagePlan:
+ bad = {(p, k) for p, k, _ in lettering_field_mismatches(plan, script)}
+ panels: list[PagePanelSpec] = []
+ for panel in plan.panels:
+ data = panel.model_dump()
+ for kind in ("caption", "dialogue", "sfx"):
+ if (panel.panel_id, kind) in bad:
+ data[kind] = None
+ panels.append(PagePanelSpec.model_validate(data))
+ boxes = [
+ b
+ for b in plan.lettering_boxes
+ if (b.panel_id, b.kind) not in bad
+ ]
+ return plan.model_copy(update={"panels": panels, "lettering_boxes": boxes})
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_lettering_lang.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/comic/lettering_lang.py tests/test_lettering_lang.py
+git commit -m "$(cat <<'EOF'
+feat: add finished-page lettering language lock helpers
+
+EOF
+)"
+```
+
+---
+
+### Task 3: Blank finished-page prompt
+
+**Files:**
+- Modify: `core/comic/page_prompt.py`
+- Modify: `tests/test_page_prompt.py`
+
+**Interfaces:**
+- Change: `render_finished_page_prompt(..., lettering: Literal["deferred", "in_image"] = "deferred")`
+- Deferred mode: no exact CAPTION/DIALOGUE/SFX glyph lines; forbid readable text; may mention empty-bubble geometry from boxes/notes
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_page_prompt.py — replace/extend existing expectations
+from core.comic.page_prompt import render_finished_page_prompt
+from core.schemas import CharacterAsset, ComicPagePlan, Setting
+
+
+def test_deferred_prompt_omits_glyph_strings_and_forbids_readable_text():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "establish",
+ "layout_intent": "wide top",
+ "panels": [
+ {
+ "panel_id": "1",
+ "action": "福贵 walks",
+ "characters": ["福贵"],
+ "dialogue": "你好",
+ "caption": "傍晚",
+ "lettering_notes": "bubble near face — leave empty",
+ }
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.2, "y": 0.3, "w": 0.3, "h": 0.1}
+ ],
+ }
+ )
+ chars = {"福贵": CharacterAsset(name="福贵", l1_prompt="middle-aged farmer")}
+ text = render_finished_page_prompt(plan, characters_by_name=chars, settings_by_name={})
+ assert "CAPTION (exact):" not in text
+ assert "DIALOGUE (exact):" not in text
+ assert "你好" not in text
+ assert "傍晚" not in text
+ assert "no readable" in text.lower() or "empty speech" in text.lower()
+ assert "empty" in text.lower()
+
+
+def test_in_image_lettering_mode_still_includes_exact_strings():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "dialogue": "你好"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan, characters_by_name={}, settings_by_name={}, lettering="in_image"
+ )
+ assert "DIALOGUE (exact): 你好" in text
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_page_prompt.py -v`
+Expected: FAIL (deferred still embeds exact strings / missing kwargs)
+
+- [ ] **Step 3: Implement blank prompt**
+
+Update `render_finished_page_prompt` signature and body:
+
+```python
+def render_finished_page_prompt(
+ plan: ComicPagePlan,
+ *,
+ characters_by_name: dict[str, CharacterAsset],
+ settings_by_name: dict[str, Setting],
+ style_guide: str = "",
+ strict: bool = False,
+ lettering: str = "deferred",
+) -> str:
+ lines: list[str] = [
+ "Finished readable manga/comic page, A4 portrait single image,",
+ "dynamic panel layout with gutters (not a flat labeled grid collage),",
+ "clean black ink line art, soft cel shading, flat colors,",
+ ]
+ if lettering == "deferred":
+ lines.extend(
+ [
+ "empty speech bubbles and caption bars as chrome only — leave interiors blank,",
+ "do not render any readable text, letters, or glyphs (no Latin, no CJK),",
+ "do not cover faces, hands, or key action with chrome.",
+ ]
+ )
+ if strict:
+ lines.append(
+ "STRICT: zero readable characters anywhere; high-contrast empty bubbles only."
+ )
+ else:
+ lines.extend(
+ [
+ "speech bubbles, caption boxes, and SFX lettered legibly in-image,",
+ "do not cover faces, hands, or key action with text.",
+ ]
+ )
+ if strict:
+ lines.append(
+ "STRICT: render every CAPTION, DIALOGUE, and SFX string exactly as "
+ "specified; high-contrast legible lettering; do not omit any text."
+ )
+ # ... style / purpose / panels ...
+ # For deferred: skip exact CAPTION/DIALOGUE/SFX lines; emit geometry hints from boxes/notes.
+ # For in_image: keep existing exact lines.
+```
+
+Update any existing `test_prompt_includes_layout_lettering_and_identity` to pass `lettering="in_image"` **or** assert deferred contracts instead — prefer updating the main test to deferred defaults and keep one in_image regression test.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_page_prompt.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/comic/page_prompt.py tests/test_page_prompt.py
+git commit -m "$(cat <<'EOF'
+feat: default finished-page prompts to blank lettering
+
+EOF
+)"
+```
+
+---
+
+### Task 4: Overlay module `page_lettering`
+
+**Files:**
+- Create: `core/comic/page_lettering.py`
+- Create: `tests/test_page_lettering.py`
+- Modify: `core/comic/layout.py` (only if needed to reuse drawers without copy-paste — prefer constructing a tiny `LayoutEngine` and calling existing `_draw_*`)
+
+**Interfaces:**
+- Produces: `letter_finished_page(blank: Image.Image, plan: ComicPagePlan, *, font_path: str | None = None) -> Image.Image`
+- Produces: `resolve_lettering_jobs(plan: ComicPagePlan) -> list[tuple[str, str, str, tuple[float,float,float,float]]]`
+ `(panel_id, kind, text, (x,y,w,h))`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_page_lettering.py
+from PIL import Image
+
+from core.comic.page_lettering import letter_finished_page, resolve_lettering_jobs
+from core.schemas import ComicPagePlan
+
+
+def test_resolve_uses_boxes_then_heuristics():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {"panel_id": "1", "dialogue": "你好", "caption": "旁白"},
+ {"panel_id": "2", "sfx": "砰"},
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.1},
+ ],
+ }
+ )
+ jobs = resolve_lettering_jobs(plan)
+ kinds = {(p, k) for p, k, _, _ in jobs}
+ assert ("1", "dialogue") in kinds
+ assert ("1", "caption") in kinds # heuristic
+ assert ("2", "sfx") in kinds
+ dialogue = next(b for p, k, t, b in jobs if k == "dialogue")
+ assert dialogue == (0.1, 0.2, 0.3, 0.1)
+
+
+def test_letter_finished_page_draws_nonzero_ink():
+ blank = Image.new("RGB", (200, 300), (240, 240, 240))
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [{"panel_id": "1", "dialogue": "你好世界"}],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.1, "w": 0.6, "h": 0.2},
+ ],
+ }
+ )
+ out = letter_finished_page(blank, plan)
+ assert out.size == blank.size
+ # Bubble fill / text should change some pixels vs flat blank.
+ assert list(out.getdata()) != list(blank.getdata())
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_page_lettering.py -v`
+Expected: FAIL (import error)
+
+- [ ] **Step 3: Implement overlay**
+
+```python
+# core/comic/page_lettering.py
+"""Deferred lettering: composite plan text onto blank finished-page art."""
+
+from __future__ import annotations
+
+from PIL import Image, ImageDraw
+
+from core.comic.layout import LayoutEngine
+from core.schemas import ComicPagePlan
+
+Kind = str # "caption" | "dialogue" | "sfx"
+
+
+def resolve_lettering_jobs(
+ plan: ComicPagePlan,
+) -> list[tuple[str, str, str, tuple[float, float, float, float]]]:
+ box_map = {(b.panel_id, b.kind): (b.x, b.y, b.w, b.h) for b in plan.lettering_boxes}
+ n = max(1, len(plan.panels))
+ jobs: list[tuple[str, str, str, tuple[float, float, float, float]]] = []
+ for i, panel in enumerate(plan.panels):
+ band_y0 = i / n
+ band_h = 1.0 / n
+ for kind in ("caption", "dialogue", "sfx"):
+ text = getattr(panel, kind)
+ if not text:
+ continue
+ key = (panel.panel_id, kind)
+ if key in box_map:
+ box = _clamp_box(*box_map[key])
+ else:
+ box = _heuristic_box(kind, band_y0, band_h)
+ jobs.append((panel.panel_id, kind, text, box))
+ return jobs
+
+
+def _clamp_box(x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
+ x = min(max(x, 0.0), 0.95)
+ y = min(max(y, 0.0), 0.95)
+ w = min(max(w, 0.08), 1.0 - x)
+ h = min(max(h, 0.05), 1.0 - y)
+ return (x, y, w, h)
+
+
+def _heuristic_box(kind: str, band_y0: float, band_h: float) -> tuple[float, float, float, float]:
+ if kind == "caption":
+ return _clamp_box(0.1, band_y0 + 0.02 * band_h, 0.8, 0.22 * band_h)
+ if kind == "sfx":
+ return _clamp_box(0.55, band_y0 + 0.05 * band_h, 0.35, 0.2 * band_h)
+ return _clamp_box(0.15, band_y0 + 0.55 * band_h, 0.7, 0.28 * band_h)
+
+
+def letter_finished_page(
+ blank: Image.Image,
+ plan: ComicPagePlan,
+ *,
+ font_path: str | None = None,
+) -> Image.Image:
+ img = blank.convert("RGB").copy()
+ draw = ImageDraw.Draw(img)
+ engine = LayoutEngine(font_path=font_path)
+ w, h = img.size
+ for _pid, kind, text, (nx, ny, nw, nh) in resolve_lettering_jobs(plan):
+ box = (int(nx * w), int(ny * h), max(8, int(nw * w)), max(8, int(nh * h)))
+ if kind == "caption":
+ engine._draw_caption(draw, box, text)
+ elif kind == "sfx":
+ engine._draw_sfx(draw, box, text)
+ else:
+ engine._draw_bubble(draw, box, text)
+ return img
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_page_lettering.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/comic/page_lettering.py tests/test_page_lettering.py
+git commit -m "$(cat <<'EOF'
+feat: overlay deferred lettering onto blank finished pages
+
+EOF
+)"
+```
+
+---
+
+### Task 5: Screenwriter — language reminder + one re-plan
+
+**Files:**
+- Modify: `core/screenwriter.py`
+- Modify: `tests/test_screenwriter_pages.py` (create if missing)
+
+**Interfaces:**
+- Change: `plan_comic_pages` applies `source_lettering_script`, mismatch → one retry with hard reminder → `strip_mismatched_lettering`
+- Tool description mentions `lettering_boxes` normalized rects
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_screenwriter_pages.py
+import asyncio
+
+from core.api import ChatProvider
+from core.schemas import StoryElements
+from core.screenwriter import plan_comic_pages
+
+
+class FlipLangChat(ChatProvider):
+ def __init__(self):
+ self.calls = 0
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kw):
+ self.calls += 1
+ if self.calls == 1:
+ return {
+ "unit_id": "1",
+ "pages": [
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "wide",
+ "panels": [{"panel_id": "1", "dialogue": "Hello friend", "action": "stands"}],
+ }
+ ],
+ }
+ return {
+ "unit_id": "1",
+ "pages": [
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "wide",
+ "panels": [{"panel_id": "1", "dialogue": "你好啊", "action": "stands"}],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.2, "y": 0.3, "w": 0.4, "h": 0.15}
+ ],
+ }
+ ],
+ }
+
+
+def test_plan_comic_pages_retries_once_on_language_mismatch():
+ elements = StoryElements.model_validate(
+ {"characters": [{"name": "福贵", "l1_prompt": "farmer"}], "settings": [], "style_guide": "manhua"}
+ )
+ chat = FlipLangChat()
+ pageset = asyncio.run(plan_comic_pages("第一章\n福贵在村口。", elements, chat=chat))
+ assert chat.calls == 2
+ assert pageset.pages[0].panels[0].dialogue == "你好啊"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_screenwriter_pages.py::test_plan_comic_pages_retries_once_on_language_mismatch -v`
+Expected: FAIL (`calls == 1`)
+
+- [ ] **Step 3: Wire validation into `plan_comic_pages`**
+
+```python
+async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> ComicPagePlanSet:
+ chat = chat or get_chat_provider()
+ script = source_lettering_script(text)
+ lang_reminder = (
+ "Reminder: caption / dialogue / sfx must match the source language "
+ "(if the excerpt is Chinese, lettering must be Chinese — never English translation). "
+ "Also emit lettering_boxes: normalized 0-1 page rectangles (kind, panel_id, x, y, w, h) "
+ "for every non-null lettering field."
+ )
+ user = (
+ f"{sanitize_text(text)}\n\n"
+ f"Known elements:\n{elements.model_dump_json()}\n\n"
+ "Plan finished readable pages (not a flat 2x2 collage). "
+ "Each page needs purpose, layout_intent, panels, and lettering_boxes. "
+ f"{lang_reminder}"
+ )
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user},
+ ]
+ args = await chat.chat_function_call(messages, [PAGE_PLAN_TOOL], _tool_choice("plan_comic_pages"))
+ pageset = ComicPagePlanSet.model_validate(args)
+
+ def _any_mismatch(ps: ComicPagePlanSet) -> bool:
+ return any(lettering_field_mismatches(p, script) for p in ps.pages)
+
+ if script in ("cjk", "latin") and _any_mismatch(pageset):
+ logger.warning("plan_comic_pages language mismatch; retrying once (script=%s)", script)
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {
+ "role": "user",
+ "content": user + "\n\nCRITICAL: previous plan mixed languages. Fix lettering now.",
+ },
+ ]
+ args = await chat.chat_function_call(
+ messages, [PAGE_PLAN_TOOL], _tool_choice("plan_comic_pages")
+ )
+ pageset = ComicPagePlanSet.model_validate(args)
+
+ if script in ("cjk", "latin"):
+ pageset = pageset.model_copy(
+ update={
+ "pages": [strip_mismatched_lettering(p, script) for p in pageset.pages],
+ }
+ )
+ return pageset
+```
+
+Also bump `SYSTEM_PROMPT` with one CRITICAL sentence on never mixing lettering languages (keep art-direction English allowance).
+
+Update `PAGE_PLAN_TOOL` description to mention `lettering_boxes`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_screenwriter_pages.py tests/test_lettering_lang.py -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/screenwriter.py tests/test_screenwriter_pages.py
+git commit -m "$(cat <<'EOF'
+feat: lock finished-page plan lettering language with one retry
+
+EOF
+)"
+```
+
+---
+
+### Task 6: Pipeline — blank save, overlay, fingerprint, resume re-letter
+
+**Files:**
+- Modify: `core/pipelines/creative_comic.py`
+- Modify: `tests/test_finished_page_pipeline.py`
+
+**Interfaces:**
+- After successful blank image save: run `letter_finished_page`, write lettered `local`, set `blank_local`, `mode="finished_lettered"`
+- `render_fingerprint` includes `lettering=deferred_v1`
+- If page needs generation but `blank_local` exists and file present → skip image API, re-letter only
+- Prompt calls use default deferred lettering (no glyph strings)
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_finished_page_pipeline.py additions
+class RecordingImage(FakeImage):
+ def __init__(self):
+ super().__init__()
+ self.prompts: list[str] = []
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ self.prompts.append(prompt)
+ return await super().generate_single_image(prompt, reference_image_paths, size, **kw)
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_writes_blank_and_lettered(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ img = RecordingImage()
+ proj = asyncio.run(
+ creative_comic("第一章\n福贵在村口。", output_dir=str(tmp_path), chat=FakeChat(), image=img)
+ )
+ assert any("no readable" in p.lower() or "empty speech" in p.lower() for p in img.prompts)
+ assert all("DIALOGUE (exact):" not in p for p in img.prompts)
+ page_key = next(iter(proj.state.generated.pages))
+ gp = proj.state.generated.pages[page_key]
+ assert gp.mode == "finished_lettered"
+ assert gp.blank_local and Path(gp.blank_local).exists()
+ assert Path(gp.local).exists()
+ assert Path(gp.blank_local).read_bytes() != Path(gp.local).read_bytes()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_reletters_from_blank_without_new_image(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ img = RecordingImage()
+ out = str(tmp_path)
+ asyncio.run(creative_comic("第一章\n福贵在村口。", output_dir=out, chat=FakeChat(), image=img))
+ first_prompts = len(img.prompts)
+ # Drop lettered file + pages_done entry but keep blank → resume should re-letter only.
+ state = ProjectState.load(tmp_path / "state.json")
+ key = next(iter(state.generated.pages))
+ Path(state.generated.pages[key].local).unlink()
+ state.pages_done = [k for k in state.pages_done if k != key]
+ # keep blank_local file and generated entry blank path
+ state.generated.pages[key].local = str(tmp_path / "pages" / "missing.png")
+ state.save(tmp_path / "state.json")
+ img2 = RecordingImage()
+ proj2 = asyncio.run(creative_comic("第一章\n福贵在村口。", output_dir=out, chat=FakeChat(), image=img2))
+ assert img2.prompts == [] # no new page image calls (portrait may still 0 if cached)
+ assert Path(proj2.state.generated.pages[key].local).exists()
+```
+
+Adjust FakeChat so plans include Chinese dialogue (already does via captions). Ensure page plan from FakeChat includes at least one Chinese lettering field so overlay changes pixels.
+
+Update `FakeChat.plan_comic_pages` return to include `dialogue`/`caption` Chinese strings if not already.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_finished_page_pipeline.py::test_finished_page_writes_blank_and_lettered -v`
+Expected: FAIL (`mode != finished_lettered` / no blank_local)
+
+- [ ] **Step 3: Pipeline wiring**
+
+In finished-page loop after image success:
+
+```python
+blank_dir = pages_dir / "blank"
+blank_dir.mkdir(parents=True, exist_ok=True)
+blank_path = _page_asset_path(blank_dir, ci, page_index)
+await asyncio.to_thread(out.save, str(blank_path))
+from PIL import Image as PILImage
+from core.comic.page_lettering import letter_finished_page
+
+blank_img = await asyncio.to_thread(PILImage.open, str(blank_path))
+lettered = await asyncio.to_thread(letter_finished_page, blank_img, plan)
+local = _page_asset_path(pages_dir, ci, page_index)
+await asyncio.to_thread(lettered.save, str(local))
+state.generated.pages[state_key] = GeneratedPage(
+ local=str(local),
+ blank_local=str(blank_path),
+ page_id=page_id,
+ unit_index=ci,
+ page_index=page_index,
+ mode="finished_lettered",
+ # snapshot lettering fields as today if applicable
+)
+```
+
+Before image call, if `_page_needs_generation` and existing `blank_local` is valid:
+
+```python
+existing = state.generated.pages.get(state_key)
+if existing and existing.blank_local and _is_within(existing.blank_local, output_dir) and Path(existing.blank_local).is_file():
+ blank_img = await asyncio.to_thread(PILImage.open, existing.blank_local)
+ lettered = await asyncio.to_thread(letter_finished_page, blank_img, plan)
+ local = _page_asset_path(pages_dir, ci, page_index)
+ await asyncio.to_thread(lettered.save, str(local))
+ # update GeneratedPage local/mode; mark done; continue
+```
+
+Add `lettering=deferred_v1` into whatever string builds `render_fingerprint` (find existing helper in `creative_comic.py` / config fingerprint and append).
+
+Stop passing in-image exact strings (default prompt already deferred).
+
+- [ ] **Step 4: Run finished-page tests**
+
+Run: `.venv/bin/python -m pytest tests/test_finished_page_pipeline.py tests/test_page_prompt.py tests/test_page_lettering.py -v`
+Expected: PASS (update any assertions that required `DIALOGUE (exact)` in prompts)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/pipelines/creative_comic.py tests/test_finished_page_pipeline.py
+git commit -m "$(cat <<'EOF'
+feat: wire deferred lettering into finished-page pipeline
+
+EOF
+)"
+```
+
+---
+
+### Task 7: Docs honesty + full verification
+
+**Files:**
+- Modify: `README.md`
+- Modify: `docs/ROADMAP.md`
+- Modify: `docs/README.md` only if specs index needs a link
+
+- [ ] **Step 1: Update README finished-page blurb**
+
+Replace in-image lettering claims with:
+
+> Finished-page mode generates whole-page art with empty lettering chrome; Inkstone overlays caption/dialogue/sfx using real fonts (CJK-capable). This avoids model-painted glyph distortion. Set `INKSTONE_RENDER_MODE=panel_compose` for the legacy per-panel path.
+
+Document that `pages/blank/` holds unlettered art for resume re-lettering.
+
+- [ ] **Step 2: ROADMAP checkbox**
+
+Mark deferred lettering for finished pages as done / in progress note with link to the spec.
+
+- [ ] **Step 3: Full test + lint**
+
+Run:
+
+```bash
+.venv/bin/python -m pytest tests/test_schemas_finished_page.py tests/test_lettering_lang.py tests/test_page_prompt.py tests/test_page_lettering.py tests/test_screenwriter_pages.py tests/test_finished_page_pipeline.py -q
+.venv/bin/ruff check core/schemas.py core/comic/lettering_lang.py core/comic/page_prompt.py core/comic/page_lettering.py core/screenwriter.py core/pipelines/creative_comic.py
+.venv/bin/ruff format --check core/comic/lettering_lang.py core/comic/page_lettering.py
+```
+
+Expected: all PASS / clean
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add README.md docs/ROADMAP.md docs/superpowers/specs/2026-07-31-deferred-lettering-design.md docs/superpowers/plans/2026-07-31-deferred-lettering.md
+git commit -m "$(cat <<'EOF'
+docs: document deferred lettering for finished pages
+
+EOF
+)"
+```
+
+---
+
+## Plan self-review
+
+1. **Spec coverage:** Language lock → Task 2+5; blank prompt → Task 3; boxes schema → Task 1; overlay + heuristics → Task 4; pipeline/resume/fingerprint → Task 6; honesty docs → Task 7. CV/OCR explicitly absent.
+2. **Placeholders:** None intentional; concrete tests and code in each task.
+3. **Type consistency:** `LetteringBox`, `finished_lettered`, `blank_local`, `letter_finished_page`, `source_lettering_script` names align across tasks.
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-07-31-deferred-lettering.md`. Two execution options:
+
+1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks
+2. **Inline Execution** — execute tasks in this session with checkpoints
+
+Which approach?
diff --git a/docs/superpowers/plans/2026-07-31-lettering-fit-and-metaphor-names.md b/docs/superpowers/plans/2026-07-31-lettering-fit-and-metaphor-names.md
new file mode 100644
index 0000000..e35230d
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-31-lettering-fit-and-metaphor-names.md
@@ -0,0 +1,158 @@
+# Lettering Fit + Metaphorical Name Identity — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or executing-plans. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Stop deferred-lettering chrome from filling oversized plan boxes (covering art), and stop image models from literalizing metaphorical Chinese names like 虎妞 into animals.
+
+**Architecture:** Shrink-wrap dialogue/caption chrome to measured text within the plan box as a max/anchor; harden extract + portrait/L1 prompts so animal-character names stay human; bump render identity token so bad portraits soft-invalidate on re-run.
+
+**Tech Stack:** Python ≥3.10, Pillow, existing LayoutEngine drawers, pytest.
+
+**Issue note:** `.issue/2026-07-31-14_56-lettering-bubble-and-huniu.md`
+**Repro:** `comic_out/efb7cc24b763` page `page_c0009_p0014.png`, character 虎妞.
+
+## Global Constraints
+
+- English code, comments, commits.
+- No new hard dependencies.
+- Do not break `panel_compose` bubble sizing for its own cell layout (only change deferred overlay path / shared helpers carefully).
+- TDD per task.
+- Prefer re-letter from `blank_local` when only lettering version changes.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `core/comic/page_lettering.py` | Shrink-wrap fitted boxes; `LETTERING_VERSION` |
+| `core/comic/layout.py` | Optional: expose measure helpers if needed (prefer using `_bubble_height`) |
+| `core/comic/identity.py` | `harden_human_identity_prompt` / name metaphor detection |
+| `core/screenwriter.py` | Extract/system reminder for metaphorical names |
+| `core/pipelines/creative_comic.py` | Portrait prompt harden; fingerprint `identity=metaphor_v1`; re-letter on version mismatch |
+| `core/schemas.py` | `GeneratedPage.lettering_version` optional |
+| `tests/test_page_lettering.py` | Shrink-wrap vs huge plan box |
+| `tests/test_identity_metaphor.py` | 虎妞 / 凤姐 human harden |
+| `tests/test_finished_page_pipeline.py` | Fingerprint / re-letter version if wired |
+
+---
+
+### Task 1: Shrink-wrap deferred lettering chrome
+
+**Files:**
+- Modify: `core/comic/page_lettering.py`
+- Modify: `tests/test_page_lettering.py`
+
+**Interfaces:**
+- `LETTERING_VERSION = "deferred_v2"`
+- `fit_lettering_box(engine, kind, text, anchor_xywh_px, page_wh) -> (x,y,w,h)` — chrome sized to text, clamped to anchor max and page caps (`max_w_frac=0.45`, `max_h_frac=0.28`)
+
+- [ ] **Step 1: Failing test**
+
+```python
+def test_letter_finished_page_shrink_wraps_huge_plan_box():
+ blank = Image.new("RGB", (200, 400), (200, 200, 200))
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {
+ "panel_id": "2",
+ "dialogue": "假如老头子消了气呢?",
+ }
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "2", "x": 0.1, "y": 0.5, "w": 0.8, "h": 0.4},
+ ],
+ }
+ )
+ out = letter_finished_page(blank, plan)
+ # Count near-white pixels in bottom half — must be far less than filling the 0.8x0.4 box.
+ bottom = out.crop((0, 200, 200, 400))
+ whiteish = sum(1 for px in bottom.getdata() if px[0] > 240 and px[1] > 240 and px[2] > 240)
+ # Full 0.8*200 x 0.4*400 = 160*160 = 25600 white if filled; shrink-wrap should be << that.
+ assert whiteish < 8000
+```
+
+- [ ] **Step 2: Implement fit + use in `letter_finished_page`**
+
+Algorithm:
+
+1. Convert normalized anchor → pixel rect (existing).
+2. Cap `max_w = min(anchor_w, int(page_w * 0.45))`, `max_h = min(anchor_h, int(page_h * 0.28))`.
+3. `need_h = engine._bubble_height(text, max_w)` (caption uses same); `need_h = min(need_h, max_h)`.
+4. Optionally tighten width: binary search / measure longest line after wrap at `max_w`, set `w = min(max_w, longest + 2*pad)`.
+5. Place fitted box at top-left of anchor (keep `x,y` of anchor); clamp so it stays on page.
+6. Draw with fitted rect, not full anchor.
+
+- [ ] **Step 3: Tests pass + commit**
+
+```bash
+git commit -m "fix: shrink-wrap deferred lettering chrome to text size"
+```
+
+---
+
+### Task 2: Metaphorical Chinese names stay human
+
+**Files:**
+- Modify: `core/comic/identity.py`
+- Create: `tests/test_identity_metaphor.py`
+- Modify: `core/screenwriter.py` (SYSTEM_PROMPT one sentence)
+- Modify: `core/pipelines/creative_comic.py` (portrait + fingerprint)
+- Modify: `core/comic/page_prompt.py` (optional: append human lock on character lines when metaphor name)
+
+**Interfaces:**
+- `name_suggests_animal_metaphor(name: str) -> bool` — true if name contains common metaphor animals (虎/龙/凤/豹/狼/狮/猴/蛇/…)AND is not already marked non-human in role
+- `harden_human_identity_prompt(name: str, prompt: str) -> str` — append/ensure `human character (name is metaphorical; not an animal, no animal head)`
+
+- [ ] **Step 1: Failing tests**
+
+```python
+def test_harden_huniu_prompt_forbids_tiger():
+ out = harden_human_identity_prompt("虎妞", "虎妞, sturdy woman in traditional clothes")
+ assert "not an animal" in out.lower() or "human" in out.lower()
+ assert "tiger" in out.lower() # explicit negation: "not a tiger" preferred
+
+def test_ordinary_name_unchanged_enough():
+ base = "middle-aged farmer in patched jacket"
+ assert harden_human_identity_prompt("祥子", base) == base or "human" in harden_human_identity_prompt("祥子", base).lower()
+```
+
+Prefer: only harden when `name_suggests_animal_metaphor`; 祥子 unchanged.
+
+- [ ] **Step 2: Wire**
+
+- `SYSTEM_PROMPT`: metaphorical names (虎妞, 凤姐) are human unless source says otherwise; l1/portrait must say human, not animal.
+- Portrait render: `prompt = harden_human_identity_prompt(name, asset.portrait_prompt or asset.l1_prompt)`
+- `ensure_character_l1` or call site after ensure: harden `l1_prompt` when metaphor
+- `_render_fingerprint`: add `"identity": "metaphor_v1"` always (or when finished_page / always — always is fine so panel_compose also refreshes bad portraits)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git commit -m "fix: keep metaphorical Chinese names human in identity prompts"
+```
+
+---
+
+### Task 3: Re-letter on version bump; verify
+
+**Files:**
+- Modify: `core/schemas.py` — `GeneratedPage.lettering_version: str = ""`
+- Modify: `core/pipelines/creative_comic.py` — after lettering, set version; if blank valid and version != LETTERING_VERSION, re-letter even when in pages_done? Prefer: treat outdated version as needs generation for lettering-only path without image.
+- Modify tests
+- Fingerprint: bump finished-page `lettering` to `deferred_v2` (with identity bump this soft-invalidates once — acceptable for repro project)
+
+- [ ] Wire + test that shrink-wrapped lettering_version is stored
+- [ ] Run focused pytest + ruff
+- [ ] Commit docs pointer in ROADMAP one-liner optional
+
+```bash
+git commit -m "fix: bump deferred lettering version and persist on GeneratedPage"
+```
+
+---
+
+## Self-review
+
+- Spec coverage: huge box → Task 1; 虎妞 → Task 2; re-run path → Task 3.
+- No CV bubble detection (still out of scope).
diff --git a/docs/superpowers/plans/2026-08-02-visual-bible-v2-hardening.md b/docs/superpowers/plans/2026-08-02-visual-bible-v2-hardening.md
new file mode 100644
index 0000000..ed86ca5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-visual-bible-v2-hardening.md
@@ -0,0 +1,329 @@
+# Visual Bible v2 Hardening Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Harden Visual Bible so projects like 9910 stop wrong merges, English-prose identities, empty locks, character-sheet page insets, and missing panel character refs.
+
+**Architecture:** Add detectors/guards/sanitize/lock-fill/backfill in `visual_bible.py`; strengthen reconcile prompts; harden finished-page prompt; bump fingerprint to `bible_v2` and sanitize on resume.
+
+**Tech Stack:** Python ≥3.10, Pydantic v2, existing chat tools, pytest, ruff.
+
+**Spec:** `docs/superpowers/specs/2026-08-02-visual-bible-v2-hardening-design.md`
+
+## Global Constraints
+
+- English code, comments, commits.
+- No new hard dependencies.
+- Phase C sheet still unused.
+- Do not enable L3 by default.
+- Old state without bible still loads; sanitize is no-op when bible is None except illegal CharacterAsset cleanup may still run.
+- TDD per task; commit per task.
+- Prefer extending `core/comic/visual_bible.py` over new modules unless file exceeds ~600 lines meaningfully.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `core/comic/visual_bible.py` | Illegal names, role guard, lock fill, sanitize, backfill_panel_characters |
+| `core/screenwriter.py` | Reconcile prompt rules for v2 |
+| `core/comic/page_prompt.py` | Anti-sheet + period wardrobe lines |
+| `core/pipelines/creative_comic.py` | Sanitize on resume; backfill before render; `bible_v2` fingerprint |
+| `tests/test_visual_bible_v2.py` | New unit tests |
+| `tests/test_visual_bible_prompt.py` / `test_finished_page_pipeline.py` | Prompt + fingerprint updates |
+
+---
+
+### Task 1: Illegal names + role-incompatible merge guard + lock fill
+
+**Files:**
+- Modify: `core/comic/visual_bible.py`
+- Create: `tests/test_visual_bible_v2.py`
+
+**Interfaces:**
+- `is_illegal_character_name(name: str) -> bool`
+- `roles_incompatible(role_a: str, role_b: str) -> bool`
+- `normalize_face_lock(text: str) -> str` — strip outfit words
+- `ensure_stage_locks(stage: CharacterStage, *, canon_face: str, style_hint: str = "") -> CharacterStage`
+- `ensure_canon_locks(canon: CharacterCanon, style_hint: str = "") -> CharacterCanon`
+- Gate high merges in `apply_reconcile` via `roles_incompatible`; demote to needs_review
+- Reject illegal portrait_key → `f"{canonical}@{stage}"`
+
+- [ ] **Step 1: Failing tests**
+
+```python
+# tests/test_visual_bible_v2.py
+from core.comic.visual_bible import (
+ apply_reconcile,
+ is_illegal_character_name,
+ roles_incompatible,
+ ensure_canon_locks,
+)
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ProjectState,
+ VisualBible,
+ VisualBibleMerge,
+ VisualBibleReconcileResult,
+)
+
+
+def test_illegal_english_prose_name():
+ assert is_illegal_character_name(
+ "41-year-old Viennese novelist, athletic elegant build, glossy dark hair"
+ )
+ assert not is_illegal_character_name("R(小说家)")
+ assert not is_illegal_character_name("老约翰")
+
+
+def test_roles_incompatible_mother_daughter_and_count_novelist():
+ assert roles_incompatible("女主角母亲,寡妇", "女主角,信件叙述者")
+ assert roles_incompatible("帝国伯爵,情人", "著名小说家")
+ assert not roles_incompatible("著名小说家", "男主角作家")
+
+
+def test_apply_reconcile_demotes_incompatible_high_merge():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R(小说家)": CharacterAsset(name="R(小说家)", role="著名小说家"),
+ "帝国伯爵": CharacterAsset(name="帝国伯爵", role="帝国伯爵,年长情人"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="period vienna",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R(小说家)": CharacterCanon(
+ canonical_name="R(小说家)",
+ role="著名小说家",
+ face_lock="handsome face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="suit",
+ hair_lock="dark hair",
+ portrait_key="R(小说家)",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ out = apply_reconcile(
+ state,
+ VisualBibleReconcileResult(
+ merges=[
+ VisualBibleMerge(
+ alias="帝国伯爵",
+ canonical="R(小说家)",
+ confidence="high",
+ reason="wrong",
+ )
+ ]
+ ),
+ )
+ assert "帝国伯爵" in out.characters
+ assert any(s.new_name == "帝国伯爵" for s in out.needs_review)
+
+
+def test_ensure_canon_locks_fills_empty_and_strips_outfit_from_face():
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="handsome face, wearing athletic hoodie",
+ stages=[
+ CharacterStage(stage="default", outfit_lock="", hair_lock="", portrait_key="R")
+ ],
+ )
+ fixed = ensure_canon_locks(canon, style_hint="early 20th century Vienna")
+ assert "hoodie" not in fixed.face_lock.lower()
+ assert fixed.stages[0].hair_lock
+ assert fixed.stages[0].outfit_lock
+```
+
+- [ ] **Step 2: Run fail → implement → pass → commit**
+
+```bash
+.venv/bin/python -m pytest tests/test_visual_bible_v2.py -q --tb=short
+git add core/comic/visual_bible.py tests/test_visual_bible_v2.py
+git commit -m "feat: guard visual bible merges and require identity locks"
+```
+
+---
+
+### Task 2: sanitize_visual_bible_state + illegal asset cleanup
+
+**Files:**
+- Modify: `core/comic/visual_bible.py`
+- Modify: `tests/test_visual_bible_v2.py`
+
+**Interfaces:**
+- `sanitize_visual_bible_state(state: ProjectState) -> bool`
+ - Fold/drop illegal-named characters
+ - Drop incompatible aliases from canons and CharacterAsset.aliases
+ - `ensure_canon_locks` on all canons
+ - Set `version="bible_v2"`, `refresh_bible_hash`
+ - Return True if any mutation
+
+- [ ] **Step 1: Test**
+
+```python
+def test_sanitize_removes_prose_character_and_bad_alias():
+ prose = "41-year-old Viennese novelist, athletic elegant build, glossy dark hair"
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R(小说家)": CharacterAsset(
+ name="R(小说家)",
+ role="小说家",
+ aliases=["帝国伯爵", prose],
+ ),
+ prose: CharacterAsset(name=prose, role="小说家"),
+ "帝国伯爵": CharacterAsset(name="帝国伯爵", role="伯爵情人"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="vienna",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R(小说家)": CharacterCanon(
+ canonical_name="R(小说家)",
+ role="小说家",
+ aliases=["帝国伯爵", prose],
+ face_lock="face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ hair_lock="",
+ outfit_lock="",
+ portrait_key=prose,
+ )
+ ],
+ )
+ },
+ ),
+ )
+ from core.comic.visual_bible import sanitize_visual_bible_state
+
+ assert sanitize_visual_bible_state(state) is True
+ assert prose not in state.characters
+ assert state.visual_bible.version == "bible_v2"
+ assert "帝国伯爵" not in state.visual_bible.characters["R(小说家)"].aliases
+ assert state.visual_bible.characters["R(小说家)"].stages[0].portrait_key.startswith("R")
+```
+
+- [ ] **Step 2: Implement → pass → commit**
+
+```bash
+git commit -m "feat: sanitize polluted visual bible state to bible_v2"
+```
+
+---
+
+### Task 3: Panel character backfill
+
+**Files:**
+- Modify: `core/comic/visual_bible.py`
+- Modify: `tests/test_visual_bible_v2.py`
+
+**Interfaces:**
+- `backfill_panel_characters(plan: ComicPagePlan, known_names: Iterable[str]) -> ComicPagePlan`
+- Known names = all character keys + bible canonicals + aliases
+
+- [ ] **Step 1: Test**
+
+```python
+def test_backfill_panel_characters_from_action_and_refs():
+ from core.comic.visual_bible import backfill_panel_characters
+ from core.schemas import ComicPagePlan
+
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "海滩",
+ "layout_intent": "三格",
+ "panels": [
+ {
+ "panel_id": "1",
+ "characters": [],
+ "action": "女人牵着金发男孩在海滩散步",
+ }
+ ],
+ "reference_characters": ["陌生女人(信中叙述者)", "死去的儿子"],
+ }
+ )
+ fixed = backfill_panel_characters(
+ plan, ["陌生女人(信中叙述者)", "死去的儿子", "R(小说家)"]
+ )
+ assert "陌生女人(信中叙述者)" in fixed.panels[0].characters
+ assert "死去的儿子" in fixed.panels[0].characters
+```
+
+Matching: exact substring of known name in action, plus always include page refs when panel empty.
+
+- [ ] **Step 2: Implement → pass → commit**
+
+```bash
+git commit -m "feat: backfill empty panel characters from refs and action"
+```
+
+---
+
+### Task 4: Prompt + screenwriter + pipeline wire + fingerprint bible_v2
+
+**Files:**
+- Modify: `core/comic/page_prompt.py`
+- Modify: `core/screenwriter.py`
+- Modify: `core/pipelines/creative_comic.py`
+- Modify: `tests/test_visual_bible_prompt.py`, `tests/test_finished_page_pipeline.py`, `tests/test_visual_bible_v2.py`
+
+**Behavior:**
+- Page prompt when bible: anti-sheet line + period wardrobe line + anti multi-age collage line
+- Reconcile system/user: never invent English prose names; never high-merge incompatible roles; always fill locks; portrait_key short form only
+- Pipeline: after load / before pages loop, `if sanitize_visual_bible_state(state): soft-invalidate + save`
+- Before render each plan: `backfill_panel_characters` then existing rewrite
+- Fingerprint token `bible_v2` when bible.version startswith bible_v2 OR always emit `bible_v2` once code ships (prefer: use `state.visual_bible.version if bible else omit`, and sanitize sets bible_v2)
+
+- [ ] **Step 1: Tests**
+
+```python
+def test_prompt_forbids_character_sheets_and_modern_athleisure():
+ # bible present → assert "turnaround" or "design sheet" forbidden language
+ # and "hoodie" / period wardrobe line present
+ ...
+
+
+def test_render_fingerprint_uses_bible_v2_token():
+ # _render_fingerprint(..., bible_version="bible_v2", bible_hash="abc")
+ ...
+```
+
+- [ ] **Step 2: Implement all wiring → run full related suite → commit**
+
+```bash
+.venv/bin/python -m pytest tests/test_visual_bible_v2.py tests/test_visual_bible.py tests/test_visual_bible_prompt.py tests/test_visual_bible_reconcile.py tests/test_finished_page_pipeline.py tests/test_page_prompt.py -q --tb=short
+.venv/bin/ruff check core/comic/visual_bible.py core/comic/page_prompt.py core/screenwriter.py core/pipelines/creative_comic.py
+git commit -m "feat: wire bible_v2 sanitize, backfill, and page prompt locks"
+```
+
+---
+
+### Task 5: Final verification
+
+- [ ] Run full related pytest + ruff; fix regressions
+- [ ] Commit only if needed
+- [ ] Note in report: re-run `9910f82a3873` to regenerate
+
+## Spec coverage
+
+| Spec | Task |
+|---|---|
+| Illegal names | T1–T2 |
+| Merge guard | T1 |
+| Lock requirements | T1–T2 |
+| Sanitize resume | T2 + T4 |
+| Panel backfill | T3–T4 |
+| Prompt anti-sheet/period | T4 |
+| Fingerprint bible_v2 | T4 |
diff --git a/docs/superpowers/plans/2026-08-02-visual-bible.md b/docs/superpowers/plans/2026-08-02-visual-bible.md
new file mode 100644
index 0000000..24f21c6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-visual-bible.md
@@ -0,0 +1,905 @@
+# Visual Bible Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Lock project-level style, color palette, and canonical character identity so finished pages stop drifting in color and fragmenting the same person into many faces (phase B; C sheet hooks only).
+
+**Architecture:** Persist a `VisualBible` on `ProjectState`. After each extract, LLM reconcile merges semantic aliases / age stages into canons. Finished-page and portrait prompts always inject bible style + color + face/outfit locks; page refs always include on-page portraits. Fingerprint carries `bible_v1` + content hash.
+
+**Tech Stack:** Python ≥3.10, Pydantic v2, existing ChatProvider tool-calling, Pillow (unchanged), pytest, ruff.
+
+**Spec:** `docs/superpowers/specs/2026-08-02-visual-bible-design.md`
+**Repro:** `comic_out/93520df389e3`
+
+## Global Constraints
+
+- English code, comments, commits (CONTRIBUTING).
+- No new hard dependencies.
+- Phase B only: `sheet_ref_local` stays `None`; `build_visual_sheet` is a no-op returning `None`.
+- Do not enable L3 by default.
+- Do not block generation on `needs_review` (low-confidence → review only).
+- Old `state.json` without `visual_bible` must still load (`None`).
+- TDD: failing test → implement → pass → commit per task.
+- Keep `panel_compose` working; share helpers where cheap, finished_page is primary consumer.
+
+## File map
+
+| File | Responsibility |
+|---|---|
+| `core/schemas.py` | `ColorSwatch`, `ColorBible`, `CharacterStage`, `CharacterCanon`, `VisualBible`, `VisualBibleReconcileResult`; `ProjectState.visual_bible` |
+| `core/comic/visual_bible.py` | **New.** parse `Name@stage`, hash, apply reconcile, build L1 from canon, sheet stub, page-name rewrite |
+| `core/comic/identity.py` | Optional thin wrappers; prefer calling `visual_bible` from ensure paths |
+| `core/comic/page_prompt.py` | Inject style/color/locks from bible |
+| `core/comic/consistency.py` | Finished-page / panel ref collection: portraits ∪ sheet hook |
+| `core/screenwriter.py` | `reconcile_visual_bible` chat tool call |
+| `core/pipelines/creative_comic.py` | Wire reconcile → portraits → pages; fingerprint; force refs |
+| `tests/test_visual_bible_schema.py` | Schema round-trip |
+| `tests/test_visual_bible.py` | Hash, stage parse, apply merges/stages, sheet stub |
+| `tests/test_visual_bible_prompt.py` | Prompt injection |
+| `tests/test_visual_bible_refs.py` | Ref list + sheet-first hook |
+| `tests/test_finished_page_pipeline.py` | Fingerprint tokens |
+
+---
+
+### Task 1: Schema — Visual Bible models on ProjectState
+
+**Files:**
+- Modify: `core/schemas.py`
+- Create: `tests/test_visual_bible_schema.py`
+
+**Interfaces:**
+- Produces: `ColorSwatch`, `ColorBible`, `CharacterStage`, `CharacterCanon`, `VisualBible`, `VisualBibleReconcileResult` (+ nested merge/stage/keep rows), `ProjectState.visual_bible: VisualBible | None`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_visual_bible_schema.py
+from core.schemas import ProjectState, VisualBible, VisualBibleReconcileResult
+
+
+def test_visual_bible_round_trip_on_project_state():
+ raw = {
+ "project_id": "p1",
+ "visual_bible": {
+ "version": "bible_v1",
+ "style_guide": "manhua, muted European period tones",
+ "color": {
+ "palette": [
+ {"name": "ink_black", "hex": "#1A1A1A", "usage": "line art"},
+ {"name": "skin_warm", "hex": "#E8C4A8", "usage": "skin"},
+ ],
+ "lighting": "soft even cel lighting",
+ "forbidden": ["neon", "hyper-saturated"],
+ },
+ "characters": {
+ "R": {
+ "canonical_name": "R",
+ "aliases": ["R·", "李先生"],
+ "face_lock": "handsome European man, dark short hair, calm eyes",
+ "palette_notes": "dark suit, white shirt",
+ "role": "writer",
+ "stages": [
+ {
+ "stage": "adult",
+ "appearance": {"hair": "dark short", "outfit_top": "suit"},
+ "outfit_lock": "dark suit jacket, white shirt",
+ "hair_lock": "dark short neat hair",
+ "portrait_key": "R",
+ }
+ ],
+ }
+ },
+ "sheet_ref_local": None,
+ "content_hash": "abc",
+ },
+ }
+ state = ProjectState.model_validate(raw)
+ assert state.visual_bible is not None
+ assert state.visual_bible.characters["R"].aliases == ["R·", "李先生"]
+ assert state.visual_bible.color.palette[0].hex == "#1A1A1A"
+ dumped = state.model_dump()
+ assert dumped["visual_bible"]["version"] == "bible_v1"
+
+
+def test_project_state_loads_without_visual_bible():
+ state = ProjectState.model_validate({"project_id": "old"})
+ assert state.visual_bible is None
+
+
+def test_reconcile_result_schema():
+ result = VisualBibleReconcileResult.model_validate(
+ {
+ "merges": [
+ {
+ "alias": "李先生",
+ "canonical": "R",
+ "confidence": "high",
+ "reason": "same man",
+ }
+ ],
+ "stages": [
+ {
+ "name": "女孩(叙述者)",
+ "stage": "teen",
+ "of_canonical": "陌生女人",
+ "reason": "younger self",
+ }
+ ],
+ "keeps": [{"name": "老约翰", "reason": "servant"}],
+ "color_patches": [],
+ "style_guide": "manhua muted tones",
+ "color": {
+ "palette": [{"name": "ink", "hex": "#111111", "usage": "lines"}],
+ "lighting": "soft",
+ "forbidden": ["neon"],
+ },
+ "canons": [],
+ }
+ )
+ assert result.merges[0].confidence == "high"
+```
+
+Place new models near other comic schemas in `core/schemas.py` (before `ProjectState`). `VisualBibleReconcileResult` fields:
+
+```python
+class VisualBibleMerge(BaseModel):
+ alias: str
+ canonical: str
+ confidence: Literal["high", "low"]
+ reason: str = ""
+
+class VisualBibleStageLink(BaseModel):
+ name: str
+ stage: Literal["child", "teen", "adult", "elder", "default"]
+ of_canonical: str
+ reason: str = ""
+
+class VisualBibleKeep(BaseModel):
+ name: str
+ reason: str = ""
+
+class VisualBibleReconcileResult(BaseModel):
+ """LLM tool payload for bible create/update."""
+ merges: list[VisualBibleMerge] = Field(default_factory=list)
+ stages: list[VisualBibleStageLink] = Field(default_factory=list)
+ keeps: list[VisualBibleKeep] = Field(default_factory=list)
+ color_patches: list[ColorSwatch] = Field(default_factory=list)
+ style_guide: str = ""
+ color: ColorBible | None = None
+ canons: list[CharacterCanon] = Field(default_factory=list)
+```
+
+`CharacterStage.stage` uses the same Literal. `VisualBible.characters` is `dict[str, CharacterCanon]`. Coerce empty bible fields with existing `coerce_str` / list helpers.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_schema.py -q --tb=line`
+Expected: FAIL (import / missing types)
+
+- [ ] **Step 3: Implement schema**
+
+Add the models from the spec §4 plus reconcile result types above. On `ProjectState` add:
+
+```python
+visual_bible: VisualBible | None = None
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_schema.py -q --tb=short`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/schemas.py tests/test_visual_bible_schema.py
+git commit -m "feat: add VisualBible schema models on ProjectState"
+```
+
+---
+
+### Task 2: visual_bible helpers — parse, hash, apply, sheet stub
+
+**Files:**
+- Create: `core/comic/visual_bible.py`
+- Create: `tests/test_visual_bible.py`
+
+**Interfaces:**
+- Consumes: schema types from Task 1; `merge_character_alias` from `core.comic.identity`
+- Produces:
+ - `parse_stage_ref(name: str) -> tuple[str, str]` → `(canonical_or_base, stage)` with stage default `"default"`
+ - `compute_bible_hash(bible: VisualBible) -> str`
+ - `refresh_bible_hash(bible: VisualBible) -> VisualBible`
+ - `apply_reconcile(state: ProjectState, result: VisualBibleReconcileResult) -> ProjectState`
+ - `l1_from_canon(canon: CharacterCanon, stage: str = "default") -> str`
+ - `rewrite_page_plan_names(plan: ComicPagePlan, mapping: dict[str, str]) -> ComicPagePlan`
+ - `build_visual_sheet(bible: VisualBible) -> None` # always returns None in B
+ - `collect_finished_page_refs(plan, characters_by_name, bible, *, prev_blank: str | None = None, max_refs: int = 9) -> list[str]`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/test_visual_bible.py
+from core.comic.visual_bible import (
+ apply_reconcile,
+ build_visual_sheet,
+ collect_finished_page_refs,
+ compute_bible_hash,
+ l1_from_canon,
+ parse_stage_ref,
+ refresh_bible_hash,
+ rewrite_page_plan_names,
+)
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ColorSwatch,
+ ComicPagePlan,
+ ProjectState,
+ VisualBible,
+ VisualBibleMerge,
+ VisualBibleReconcileResult,
+ VisualBibleStageLink,
+)
+
+
+def test_parse_stage_ref():
+ assert parse_stage_ref("陌生女人@teen") == ("陌生女人", "teen")
+ assert parse_stage_ref("R") == ("R", "default")
+
+
+def test_bible_hash_stable_and_sensitive():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(
+ palette=[ColorSwatch(name="ink", hex="#111111", usage="lines")],
+ lighting="soft",
+ forbidden=["neon"],
+ ),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="face A",
+ palette_notes="suit",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit",
+ hair_lock="dark hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ )
+ h1 = compute_bible_hash(bible)
+ bible2 = bible.model_copy(update={"style_guide": "watercolor"})
+ assert compute_bible_hash(bible2) != h1
+ assert compute_bible_hash(refresh_bible_hash(bible)) == h1 or True # hash field ignored in input
+
+
+def test_apply_high_confidence_merge_and_low_to_review():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R": CharacterAsset(name="R", role="writer"),
+ "李先生": CharacterAsset(name="李先生", role="man"),
+ "路人": CharacterAsset(name="路人", role="extra"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="soft", forbidden=[]),
+ characters={
+ "R": CharacterCanon(canonical_name="R", face_lock="f", stages=[]),
+ },
+ ),
+ )
+ result = VisualBibleReconcileResult(
+ merges=[
+ VisualBibleMerge(alias="李先生", canonical="R", confidence="high", reason="same"),
+ VisualBibleMerge(alias="路人", canonical="R", confidence="low", reason="unsure"),
+ ],
+ stages=[],
+ keeps=[],
+ )
+ out = apply_reconcile(state, result)
+ assert "李先生" not in out.characters
+ assert "李先生" in out.characters["R"].aliases or "李先生" in out.visual_bible.characters["R"].aliases
+ assert "路人" in out.characters
+ assert any(s.new_name == "路人" for s in out.needs_review)
+
+
+def test_apply_stage_link():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "陌生女人": CharacterAsset(name="陌生女人"),
+ "女孩(叙述者)": CharacterAsset(name="女孩(叙述者)"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="x",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "陌生女人": CharacterCanon(
+ canonical_name="陌生女人",
+ face_lock="soft face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dress",
+ hair_lock="long dark",
+ portrait_key="陌生女人",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ result = VisualBibleReconcileResult(
+ stages=[
+ VisualBibleStageLink(
+ name="女孩(叙述者)",
+ stage="teen",
+ of_canonical="陌生女人",
+ reason="younger",
+ )
+ ]
+ )
+ out = apply_reconcile(state, result)
+ stages = {s.stage for s in out.visual_bible.characters["陌生女人"].stages}
+ assert "teen" in stages
+
+
+def test_l1_from_canon_includes_locks():
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="calm eyes",
+ palette_notes="dark suit colors",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit",
+ hair_lock="dark short hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ text = l1_from_canon(canon, "adult")
+ assert "calm eyes" in text
+ assert "dark suit" in text
+ assert "dark short hair" in text
+
+
+def test_rewrite_page_plan_names():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["李先生"], "action": "stands"}],
+ "reference_characters": ["李先生"],
+ }
+ )
+ fixed = rewrite_page_plan_names(plan, {"李先生": "R"})
+ assert fixed.panels[0].characters == ["R"]
+ assert fixed.reference_characters == ["R"]
+
+
+def test_build_visual_sheet_noop():
+ assert build_visual_sheet(VisualBible(version="bible_v1", style_guide="", color=ColorBible(palette=[], lighting="", forbidden=[]), characters={})) is None
+
+
+def test_collect_refs_uses_panel_characters_and_sheet_first():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["R"], "action": "sits"}],
+ "reference_characters": [],
+ }
+ )
+ chars = {"R": CharacterAsset(name="R", portrait_local="/tmp/r.png")}
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={},
+ sheet_ref_local="/tmp/sheet.png",
+ )
+ refs = collect_finished_page_refs(plan, chars, bible, prev_blank="/tmp/prev.png")
+ assert refs[0] == "/tmp/sheet.png"
+ assert "/tmp/r.png" in refs
+```
+
+Hash rules: hash JSON of `{style_guide, color, characters: {name: {face_lock, palette_notes, stages: [{stage, outfit_lock, hair_lock}]}}}` with `sort_keys=True`; ignore `content_hash` and `sheet_ref_local` for the digest. `refresh_bible_hash` sets `content_hash`.
+
+`apply_reconcile` high merge: call `merge_character_alias(state, alias, canonical)` then ensure alias on canon; low merge: `suggestion_from_alias` into `needs_review`. Stage link: append `CharacterStage` if missing; map alias name into canon aliases; do not delete stage source character until high merge says so (stage link alone keeps asset if still referenced — prefer also adding alias and leaving portrait_key `f"{canonical}@{stage}"`).
+
+`collect_finished_page_refs`: resolve names via `parse_stage_ref`; look up `portrait_local` on `characters_by_name` by base name or stage `portrait_key` if present on bible; sheet first; then portraits; then `prev_blank`; cap `max_refs`.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible.py -q --tb=line`
+Expected: FAIL (module missing)
+
+- [ ] **Step 3: Implement `core/comic/visual_bible.py`**
+
+Implement the functions above. Keep file focused; no chat I/O here.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible.py tests/test_visual_bible_schema.py -q --tb=short`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/comic/visual_bible.py tests/test_visual_bible.py
+git commit -m "feat: add visual bible hash, reconcile apply, and ref helpers"
+```
+
+---
+
+### Task 3: Screenwriter — reconcile_visual_bible chat tool
+
+**Files:**
+- Modify: `core/screenwriter.py`
+- Create: `tests/test_visual_bible_reconcile.py`
+
+**Interfaces:**
+- Consumes: `VisualBibleReconcileResult`, `to_tool_schema`, chat provider
+- Produces: `async def reconcile_visual_bible(text: str, state_characters: dict, bible: VisualBible | None, *, alias_hints: list[tuple[str,str,str]] = (), chat=None) -> VisualBibleReconcileResult`
+
+- [ ] **Step 1: Write the failing test (mock chat)**
+
+```python
+# tests/test_visual_bible_reconcile.py
+import pytest
+
+from core.schemas import CharacterAsset, VisualBible, ColorBible
+from core.screenwriter import reconcile_visual_bible
+
+
+class _FakeChat:
+ async def chat_with_tools(self, *, messages, tools, tool_choice):
+ return {
+ "merges": [
+ {
+ "alias": "李先生",
+ "canonical": "R",
+ "confidence": "high",
+ "reason": "same protagonist",
+ }
+ ],
+ "stages": [],
+ "keeps": [{"name": "老约翰", "reason": "servant"}],
+ "color_patches": [],
+ "style_guide": "manhua, muted European period",
+ "color": {
+ "palette": [
+ {"name": "ink", "hex": "#1A1A1A", "usage": "lines"},
+ {"name": "skin", "hex": "#E8C4A8", "usage": "skin"},
+ ],
+ "lighting": "soft even cel",
+ "forbidden": ["neon"],
+ },
+ "canons": [
+ {
+ "canonical_name": "R",
+ "aliases": ["李先生"],
+ "face_lock": "handsome man calm eyes",
+ "palette_notes": "dark suit",
+ "role": "writer",
+ "stages": [
+ {
+ "stage": "adult",
+ "outfit_lock": "dark suit",
+ "hair_lock": "dark short hair",
+ "portrait_key": "R",
+ }
+ ],
+ }
+ ],
+ }
+
+
+@pytest.mark.asyncio
+async def test_reconcile_visual_bible_parses_tool_payload():
+ chars = {
+ "R": CharacterAsset(name="R"),
+ "李先生": CharacterAsset(name="李先生"),
+ "老约翰": CharacterAsset(name="老约翰"),
+ }
+ result = await reconcile_visual_bible(
+ "excerpt about R and 李先生",
+ chars,
+ None,
+ alias_hints=[("李先生", "R", "similar")],
+ chat=_FakeChat(),
+ )
+ assert result.merges[0].alias == "李先生"
+ assert result.style_guide.startswith("manhua")
+ assert result.canons[0].canonical_name == "R"
+```
+
+Match whatever chat helper pattern `plan_comic_pages` / `extract_story_elements` already use (`chat_with_tools` vs `complete_tool`). Mirror that exact call style — do not invent a new provider API.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_reconcile.py -q --tb=line`
+Expected: FAIL (`reconcile_visual_bible` missing)
+
+- [ ] **Step 3: Implement**
+
+In `core/screenwriter.py`:
+
+```python
+RECONCILE_BIBLE_TOOL = to_tool_schema(
+ VisualBibleReconcileResult,
+ "reconcile_visual_bible",
+ "Build or update the project visual bible: merge aliases, attach age stages, "
+ "lock style_guide and color palette, emit CharacterCanon entries.",
+)
+```
+
+System/user instructions must say:
+
+- Prefer merging pronouns/descriptive labels for the same person (`男人(被叙述者)`, `他(被爱者)`, `李先生`, `R·`) into one canonical.
+- Age variants → stages, not new roots.
+- High confidence only when clearly same person; else `low`.
+- On first bible (`bible is None`), fill `style_guide`, `color` (4–6 swatches), and full `canons`.
+- On update, keep existing palette unless `color_patches` justified; still return merges/stages/keeps for new names.
+
+On failure, re-raise only if you cannot parse; pipeline will catch — prefer returning empty merges with existing style if partial. For this task, let exceptions propagate; pipeline handles.
+
+- [ ] **Step 4: Run tests**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_reconcile.py -q --tb=short`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/screenwriter.py tests/test_visual_bible_reconcile.py
+git commit -m "feat: add reconcile_visual_bible screenwriter tool"
+```
+
+---
+
+### Task 4: Prompt injection — style, color, locks
+
+**Files:**
+- Modify: `core/comic/page_prompt.py`
+- Modify: `core/comic/visual_bible.py` (add `format_color_bible_block`, `format_character_lock_lines` if not already)
+- Create: `tests/test_visual_bible_prompt.py`
+
+**Interfaces:**
+- Consumes: `VisualBible`, `l1_from_canon`, `parse_stage_ref`
+- Produces: `render_finished_page_prompt(..., visual_bible: VisualBible | None = None)` includes color + locks when bible present
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/test_visual_bible_prompt.py
+from core.comic.page_prompt import render_finished_page_prompt
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ColorSwatch,
+ ComicPagePlan,
+ VisualBible,
+)
+
+
+def test_finished_page_prompt_injects_color_and_face_lock():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="manhua muted European period",
+ color=ColorBible(
+ palette=[ColorSwatch(name="skin", hex="#E8C4A8", usage="skin")],
+ lighting="soft even cel",
+ forbidden=["neon"],
+ ),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="calm dark eyes",
+ palette_notes="dark suit",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit jacket",
+ hair_lock="dark short hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ content_hash="x",
+ )
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "meet",
+ "layout_intent": "two shot",
+ "panels": [{"panel_id": "1", "characters": ["R"], "action": "R sits"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name={"R": CharacterAsset(name="R", l1_prompt="old loose")},
+ settings_by_name={},
+ style_guide="IGNORE_ME_IF_BIBLE",
+ visual_bible=bible,
+ )
+ assert "manhua muted European period" in text
+ assert "#E8C4A8" in text
+ assert "soft even cel" in text
+ assert "neon" in text
+ assert "calm dark eyes" in text
+ assert "dark suit jacket" in text
+ assert "do not change hair color" in text.lower() or "unless action says costume change" in text.lower()
+```
+
+When `visual_bible` is set, prefer `bible.style_guide` over the `style_guide` argument for the Style line. Still pass character lines through canon locks when the name resolves in `bible.characters`.
+
+- [ ] **Step 2: Run failing test**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_prompt.py -q --tb=line`
+Expected: FAIL (unexpected kw / missing strings)
+
+- [ ] **Step 3: Implement prompt wiring**
+
+Update `render_finished_page_prompt` signature and body. Portrait path in pipeline (Task 5) will append the same color block — add `format_color_bible_block(bible) -> str` in `visual_bible.py` for reuse.
+
+- [ ] **Step 4: Run tests**
+
+Run: `.venv/bin/python -m pytest tests/test_visual_bible_prompt.py tests/test_page_prompt.py -q --tb=short`
+Expected: PASS (existing page_prompt tests still work with default `visual_bible=None`)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/comic/page_prompt.py core/comic/visual_bible.py tests/test_visual_bible_prompt.py
+git commit -m "feat: inject visual bible style color and locks into page prompts"
+```
+
+---
+
+### Task 5: Pipeline wire — reconcile, portraits, refs, fingerprint
+
+**Files:**
+- Modify: `core/pipelines/creative_comic.py`
+- Modify: `tests/test_finished_page_pipeline.py`
+- Modify: `tests/test_visual_bible_refs.py` (create if Task 2 did not already cover collect; add pipeline-level name resolution test here only if needed)
+
+**Interfaces:**
+- Consumes: `reconcile_visual_bible`, `apply_reconcile`, `refresh_bible_hash`, `collect_finished_page_refs`, `format_color_bible_block`, `l1_from_canon`
+- Produces: pipeline order per spec §6; fingerprint keys `visual_bible=bible_v1`, `bible_hash=`
+
+- [ ] **Step 1: Write failing fingerprint test**
+
+```python
+# tests/test_finished_page_pipeline.py (addition)
+def test_render_fingerprint_includes_visual_bible_hash():
+ from core.pipelines.creative_comic import _render_fingerprint
+ from core.schemas import ModelSnapshot
+
+ fp = _render_fingerprint(
+ "style",
+ snapshot=ModelSnapshot(),
+ panel_continuity=False,
+ l3_enabled=False,
+ render_mode="finished_page",
+ page_size="1024x1536",
+ bible_version="bible_v1",
+ bible_hash="deadbeef",
+ )
+ # Reconstruct expected payload the same way _render_fingerprint does, assert hash equality
+ import hashlib, json
+ payload = {
+ "style_guide": "style",
+ "model_snapshot": ModelSnapshot().model_dump(),
+ "panel_continuity": False,
+ "l3_enabled": False,
+ "render_mode": "finished_page",
+ "page_size": "1024x1536",
+ "identity": "metaphor_v2",
+ "lettering": "deferred_v3",
+ "visual_bible": "bible_v1",
+ "bible_hash": "deadbeef",
+ }
+ expected = hashlib.sha256(
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ assert fp == expected
+```
+
+Adjust `identity` / `lettering` tokens to whatever the file currently uses when editing.
+
+- [ ] **Step 2: Run failing test**
+
+Run: `.venv/bin/python -m pytest tests/test_finished_page_pipeline.py::test_render_fingerprint_includes_visual_bible_hash -q --tb=short`
+Expected: FAIL (unexpected kwargs or hash mismatch)
+
+- [ ] **Step 3: Wire pipeline**
+
+In `_creative_comic` finished_page loop, after `merge_characters` / string alias detect:
+
+```python
+hints = detect_character_aliases(state.characters, new_names)
+# existing needs_review append for string hints stays
+try:
+ recon = await reconcile_visual_bible(
+ chunk, state.characters, state.visual_bible, alias_hints=hints, chat=chat
+ )
+ prev_hash = state.visual_bible.content_hash if state.visual_bible else None
+ state = apply_reconcile(state, recon)
+ if state.visual_bible:
+ state.visual_bible = refresh_bible_hash(state.visual_bible)
+ if prev_hash and state.visual_bible.content_hash != prev_hash:
+ _soft_invalidate_render(state)
+except Exception as exc:
+ logger.warning("visual bible reconcile failed (%s); continuing", exc)
+```
+
+Style lock:
+
+```python
+if state.visual_bible and state.visual_bible.style_guide:
+ effective_style = state.visual_bible.style_guide
+else:
+ effective_style = style_guide or elements.style_guide
+```
+
+Portrait prompt: append `format_color_bible_block(state.visual_bible)` when present; prefer `l1_from_canon` when name in bible.
+
+Page render:
+
+```python
+prompt = render_finished_page_prompt(..., style_guide=effective_style, visual_bible=state.visual_bible)
+refs = collect_finished_page_refs(
+ plan,
+ state.characters,
+ state.visual_bible,
+ prev_blank=prev_blank_path, # optional from previous GeneratedPage in chunk
+)
+# filter existing files within output_dir as today
+```
+
+Replace `_page_reference_names`-only path for finished pages with `collect_finished_page_refs`.
+
+Update `_render_fingerprint` signature to accept `bible_version: str | None = None`, `bible_hash: str | None = None` and include them when provided; call site passes from `state.visual_bible` (use `"none"` / `""` when missing so first bible creation changes fingerprint once state gains a hash — or recompute fingerprint after reconcile in the same run before pages).
+
+After first bible is created mid-run, soft-invalidate only if pages/portraits already existed with old hash; on brand-new project `prev_hash is None` → no wipe needed.
+
+- [ ] **Step 4: Run tests**
+
+Run: `.venv/bin/python -m pytest tests/test_finished_page_pipeline.py tests/test_visual_bible.py tests/test_visual_bible_prompt.py tests/test_visual_bible_reconcile.py -q --tb=short`
+Expected: PASS
+
+Also run: `.venv/bin/ruff check core/schemas.py core/comic/visual_bible.py core/comic/page_prompt.py core/screenwriter.py core/pipelines/creative_comic.py`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add core/pipelines/creative_comic.py tests/test_finished_page_pipeline.py
+git commit -m "feat: wire visual bible reconcile into finished_page pipeline"
+```
+
+---
+
+### Task 6: Sync CharacterAsset L1 from canon + plan name rewrite after reconcile
+
+**Files:**
+- Modify: `core/comic/visual_bible.py` (`sync_characters_from_bible`)
+- Modify: `core/pipelines/creative_comic.py` (call after apply)
+- Modify: `tests/test_visual_bible.py`
+
+**Interfaces:**
+- Produces: `sync_characters_from_bible(state: ProjectState) -> None` — for each canon, set `CharacterAsset.l1_prompt` / `portrait_prompt` from `l1_from_canon`; ensure aliases listed; rewrite all plans in `page_cache` via alias→canonical map (and `Name` → `Name@stage` only when stage link introduced this turn — keep simple: map aliases to canonical_name; stage refs are planner's job going forward)
+
+- [ ] **Step 1: Failing test**
+
+```python
+def test_sync_characters_from_bible_updates_l1():
+ from core.comic.visual_bible import sync_characters_from_bible
+ state = ProjectState(
+ project_id="p",
+ characters={"R": CharacterAsset(name="R", l1_prompt="stale")},
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="locked face",
+ stages=[
+ CharacterStage(
+ stage="default",
+ outfit_lock="locked outfit",
+ hair_lock="locked hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ sync_characters_from_bible(state)
+ assert "locked face" in state.characters["R"].l1_prompt
+ assert "locked outfit" in state.characters["R"].l1_prompt
+```
+
+- [ ] **Step 2: Run fail → implement → pass → commit**
+
+```bash
+git add core/comic/visual_bible.py core/pipelines/creative_comic.py tests/test_visual_bible.py
+git commit -m "feat: sync character L1 prompts from visual bible canons"
+```
+
+---
+
+### Task 7: Docs honesty + final verification
+
+**Files:**
+- Modify: `README.md` and/or `docs/ROADMAP.md` only if they claim character consistency behavior — one short note that finished_page uses a project Visual Bible (style/color/canon). Skip if no existing claim needs updating.
+- Run full related suite.
+
+- [ ] **Step 1: Grep docs for over-claims**
+
+Run: `rg -n "character consistency|style_guide|alias" README.md docs/ROADMAP.md docs/superpowers -g'*.md' | head`
+
+Update only if a sentence would become false.
+
+- [ ] **Step 2: Full test + ruff**
+
+```bash
+.venv/bin/python -m pytest tests/test_visual_bible_schema.py tests/test_visual_bible.py tests/test_visual_bible_reconcile.py tests/test_visual_bible_prompt.py tests/test_finished_page_pipeline.py tests/test_page_prompt.py tests/test_identity_metaphor.py -q --tb=short
+.venv/bin/ruff check core/schemas.py core/comic/visual_bible.py core/comic/page_prompt.py core/screenwriter.py core/pipelines/creative_comic.py
+.venv/bin/ruff format core/schemas.py core/comic/visual_bible.py core/comic/page_prompt.py core/screenwriter.py core/pipelines/creative_comic.py tests/test_visual_bible*.py
+```
+
+Expected: all PASS, ruff clean.
+
+- [ ] **Step 3: Commit doc touch if any**
+
+```bash
+git add README.md docs/ROADMAP.md # only if changed
+git commit -m "docs: note project visual bible for finished_page consistency"
+```
+
+---
+
+## Spec coverage self-check
+
+| Spec item | Task |
+|---|---|
+| ColorBible / CharacterCanon / VisualBible / ProjectState field | T1 |
+| Name@stage parse, hash, apply merge/stage, sheet noop, refs+sheet-first | T2 |
+| LLM reconcile tool | T3 |
+| Prompt style+color+locks | T4 |
+| Pipeline order, fingerprint, soft-invalidate on hash change | T5 |
+| Sync L1 from canon; rewrite cached names via merges | T2 apply + T6 |
+| C hooks only | T2 `build_visual_sheet` / sheet-first refs |
+| Migration = re-run | T5 fingerprint (no manual migrator) |
+| Tests listed in spec §9 | T1–T6 |
+
+## Placeholder scan
+
+No TBD / “implement later” steps. Exact commands and code included.
+
+## Type consistency
+
+- `confidence: Literal["high","low"]` shared by schema and apply.
+- Fingerprint keys: `visual_bible`, `bible_hash` (not `visual_sheet` in B).
+- `collect_finished_page_refs` is the finished-page ref API; panel_compose may keep `ConsistencyEngine.collect_reference_images` unchanged.
diff --git a/docs/superpowers/specs/2026-07-31-deferred-lettering-design.md b/docs/superpowers/specs/2026-07-31-deferred-lettering-design.md
new file mode 100644
index 0000000..02331ad
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-31-deferred-lettering-design.md
@@ -0,0 +1,177 @@
+# Design: Deferred lettering for finished pages
+
+**Date:** 2026-07-31
+**Status:** Approved in chat (placement: planner boxes + heuristic fallback)
+**Source note:** `.issue/2026-07-31-10_14-deferred-lettering-cjk.md` (local, untracked)
+**Product ask:** Thoroughly fix (1) CJK glyph distortion on finished pages and (2) mixed Chinese/English lettering — without abandoning whole-page generation.
+
+## §1 Goals and non-goals
+
+### Goals
+
+- Reader-visible caption / dialogue / sfx on finished pages are **rasterized with real fonts** (reuse `fonts.py` + LayoutEngine bubble/caption/sfx drawers).
+- Image model output for finished pages is **art + empty lettering chrome only** (no readable glyphs).
+- Lettering language matches the **source excerpt language** before any image or overlay work.
+- Keep dynamic whole-page composition as the default product path.
+- Resume can **re-letter** without re-paying the image API when a blank page already exists.
+
+### Non-goals (this change)
+
+- Computer-vision bubble detection.
+- OCR-based QC of painted glyphs.
+- Auto-switching entire projects to `panel_compose` based on CJK ratio.
+- Commercial typesetting (tails, speaker attribution, kerning wars).
+- Nesting `page_cache` into `ChunkCache`.
+
+## §2 Problem statement (verified)
+
+| Symptom | Mechanism today |
+|---|---|
+| Distorted Chinese on finished pages | `render_finished_page_prompt` asks Agnes to paint CAPTION/DIALOGUE/SFX in-image |
+| Mixed CN/EN | Soft language reminders; art-direction English in the same image prompt; no post-plan language gate |
+
+`panel_compose` already letters correctly via PIL. Finished-page mode must adopt the same **text authority** (schema strings + font rasterization), not the image model.
+
+## §3 Architecture
+
+```text
+segment → extract / portraits (unchanged)
+ → plan_comic_pages (+ language lock + lettering boxes)
+ → render_finished_page_prompt (BLANK LETTERING)
+ → generate page image → save blank under pages/blank/
+ → letter_finished_page(blank, plan) → pages/page_*.png
+ → bind PDF / webtoon
+```
+
+Hard rules:
+
+1. **Never** pass lettering strings into the image model as text to paint.
+2. **Always** overlay from plan fields after generation (or on resume).
+3. Language lock runs on the plan **before** caching as final / before image.
+
+## §4 Data model
+
+### `LetteringBox`
+
+Normalized page coordinates (origin top-left, unit square):
+
+- `kind: Literal["caption", "dialogue", "sfx"]`
+- `panel_id: str` — joins to `PagePanelSpec` for the text payload
+- `x, y, w, h: float` — each in `[0, 1]`; `w,h > 0`; clamp on ingest so `x+w ≤ 1.05` etc. with soft clamp to page
+
+Text is **not** duplicated on the box: overlay reads `panel.caption` / `.dialogue` / `.sfx` by `(panel_id, kind)`.
+
+### `ComicPagePlan`
+
+Add:
+
+- `lettering_boxes: list[LetteringBox] = []`
+
+Planner should emit one box per non-null lettering field. Missing boxes → heuristic fallback at overlay time (do not fail the plan).
+
+### `GeneratedPage`
+
+Extend:
+
+- `mode: Literal["finished", "finished_lettered", "composed_fallback"]`
+- `blank_local: str | None = None` — path to unlettered art
+- `local` — reader-facing lettered (or composed) page
+
+Default new finished-page outputs use `mode="finished_lettered"`. Legacy `mode="finished"` pages without `blank_local` remain loadable; re-run soft-invalidates render to regenerate under the new path.
+
+### Fingerprints
+
+Include a lettering-pipeline version token in `render_fingerprint` (e.g. `lettering=deferred_v1`) so upgrading this feature soft-invalidates old in-image-lettered pages without wiping `page_cache` / `chunk_cache`.
+
+## §5 Language lock
+
+### Source language
+
+`source_lettering_script(text) -> Literal["cjk", "latin", "mixed", "unknown"]`
+
+Reuse / extend `text_requires_cjk`:
+
+- Prefer **cjk** when CJK ideograph ratio among letters is ≥ ~0.15 (or any CJK present and latin letter ratio low).
+- Prefer **latin** when letters are overwhelmingly ASCII/Latin.
+- `mixed` / `unknown` → do not auto-strip; still prefer not translating.
+
+### Plan validation
+
+For each non-null `caption` / `dialogue` / `sfx`:
+
+- If source is `cjk` and field has letters but **no** CJK → **mismatch**.
+- If source is `latin` and field is CJK-heavy → **mismatch**.
+- Pure SFX punctuation / digits alone → allow.
+
+On mismatch in `plan_comic_pages`:
+
+1. Log warning with field samples.
+2. **One** forced re-plan with a hard language reminder (mirror storyboard reminder strength).
+3. If still mismatched: drop mismatched lettering fields to `None` (honest omission beats English on a Chinese book) and continue; do not infinite-loop.
+
+Art-direction English (`action`, `scene_prompt`, `l1_prompt`) remains allowed and stays **out of** overlay text.
+
+## §6 Blank image prompt
+
+`render_finished_page_prompt(..., lettering="deferred")` default for finished_page:
+
+- Require empty speech bubbles / caption bars / SFX space as chrome only.
+- **Forbid** any readable characters (Latin or CJK) in the image.
+- Still describe panel geometry, identity, style.
+- Do **not** include `CAPTION (exact): …` / `DIALOGUE (exact): …` / `SFX (exact): …` glyph strings.
+- May include placement hints from `lettering_boxes` / `lettering_notes` as geometry only (“empty bubble at upper-left of panel 2”).
+
+`strict=True` retries keep blank rules (stricter about no glyphs), not “paint exact strings”.
+
+## §7 Overlay (`letter_finished_page`)
+
+New pure module `core/comic/page_lettering.py`:
+
+```text
+letter_finished_page(blank: Image, plan: ComicPagePlan, *, font_path=None) -> Image
+```
+
+Algorithm:
+
+1. Build work list: for each panel lettering field that is non-null, resolve box from `plan.lettering_boxes` match `(panel_id, kind)` else **heuristic slot**.
+2. Heuristic (deterministic): divide page into `N=len(panels)` vertical bands; place caption near top of band, dialogue mid-lower, sfx upper-side; clamp width ~0.55–0.8 of page width.
+3. Convert normalized box → pixel rect; draw using extracted helpers shared with `LayoutEngine` (`_draw_caption` / `_draw_bubble` / `_draw_sfx`) to avoid drift.
+4. Return composited RGB image.
+
+Pipeline writes:
+
+- `pages/blank/page_cXXXX_pYYYY.png` → `blank_local`
+- `pages/page_cXXXX_pYYYY.png` → `local` (lettered)
+- `mode="finished_lettered"`
+
+Re-letter path: if `blank_local` exists and is within output dir, skip image API and only re-run overlay (e.g. after plan lettering fix on soft resume when blank retained).
+
+## §8 Failure policy
+
+| Failure | Behavior |
+|---|---|
+| Content-policy on image | `skipped_pages` (unchanged) |
+| Unsupported size | existing 1024×1024 fallback |
+| Language mismatch after retry | strip bad fields; overlay whatever remains |
+| Overlay exception | fail the page visibly (raise); do not export half-blank as success without logging |
+| `panel_compose` mode | unchanged; LayoutEngine letters as today |
+
+## §9 Web / docs honesty
+
+- README: finished-page default uses **deferred lettering** (model paints art; Inkstone paints text). Remove implication that in-image CJK is the quality path.
+- ROADMAP: mark deferred lettering as the finished-page text strategy.
+- Progress JSON can expose `mode` per page; no new required UI controls for v1.
+
+## §10 Migration
+
+- Old projects with in-image lettered `mode=finished` pages: soft-invalidate on fingerprint bump → regenerate blank + overlay.
+- Do not force-migrate pixel files in place.
+- `page_cache` plans without `lettering_boxes` remain valid; overlay uses heuristics.
+
+## Spec self-review
+
+- [x] No TBD in approved scope; CV detection explicitly out.
+- [x] No contradiction with finished-page default; panel_compose intact.
+- [x] Language lock + blank prompt + overlay + boxes/heuristics all assigned.
+- [x] Resume blank reuse specified.
+- [x] Honesty docs called out.
diff --git a/docs/superpowers/specs/2026-08-02-visual-bible-design.md b/docs/superpowers/specs/2026-08-02-visual-bible-design.md
new file mode 100644
index 0000000..3dec5aa
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-visual-bible-design.md
@@ -0,0 +1,192 @@
+# Design: Project Visual Bible (character + color consistency)
+
+**Date:** 2026-08-02
+**Status:** Approved in chat (approach B; C reserved as extension)
+**Repro:** `comic_out/93520df389e3`(《一个陌生女人的来信》)— fragmented identities (`R·` / `男人(被叙述者)` / `李先生` / …) and per-page color drift
+**Product ask:** Thoroughly fix (1) character consistency and (2) color unity; large change OK. Ship B first; leave hooks for C (visual sheet image).
+
+## §1 Goals and non-goals
+
+### Goals
+
+- One **project-level Visual Bible** locks style, color palette, and canonical character identity for the whole run.
+- Semantic aliases for the same person are **reconciled into a canonical character** (with optional age stages), not left as independent portraits.
+- Finished-page and portrait prompts always carry **style + color bible + face/outfit locks**.
+- Finished pages **always** attach portrait references for on-page characters (derive from `panel.characters` when `reference_characters` is empty).
+- Bible changes bump render fingerprint so resume soft-invalidates portraits/pages honestly.
+
+### Non-goals (phase B)
+
+- Generating a composite color/character sheet image as an extra i2i reference (**phase C**).
+- Turning on L3 face-swap by default.
+- Blocking generation behind a mandatory human review wall (low-confidence aliases still go to `needs_review`).
+- CV-based recoloring or post-hoc color grading of finished pages.
+- Rewriting the panel_compose path beyond sharing bible helpers (finished_page is the primary consumer).
+
+## §2 Problem statement (verified on 93520df389e3)
+
+| Symptom | Mechanism today |
+|---|---|
+| Same person looks different across pages | Extract creates new names each chunk; `detect_character_aliases` is string-only and never auto-merges; `needs_review` was empty while 14 near-duplicate identities accumulated |
+| Outfit / hair / age drift within a page | `l1_prompt` rebuilt from loose appearance text; no outfit/face lock; no stage model |
+| Color jumps page to page | `style_guide` comes from per-chunk extract; no project palette; each finished page is an independent generation |
+| Missing portrait conditioning | Many page plans have `reference_characters=[]` even when panels list characters |
+
+## §3 Approach
+
+- **B (this spec):** Visual Bible in state + LLM reconcile + prompt/ref hardening + fingerprint.
+- **C (extension only):** optional `sheet_ref_local` visual sheet image prepended to i2i refs. B leaves the field and collection hook; does not generate the sheet.
+
+## §4 Data model
+
+### `ColorSwatch`
+
+- `name: str` — e.g. `ink_black`, `skin_warm`, `accent_rose`
+- `hex: str` — `#RRGGBB`
+- `usage: str` — short English purpose line
+
+### `ColorBible`
+
+- `palette: list[ColorSwatch]` — 4–6 swatches
+- `lighting: str` — e.g. `soft even cel lighting, muted European period tones`
+- `forbidden: list[str]` — e.g. `neon`, `hyper-saturated`, `photoreal skin`
+
+### `CharacterStage`
+
+- `stage: Literal["child", "teen", "adult", "elder", "default"]`
+- `appearance: Appearance` — stage-specific body/outfit
+- `outfit_lock: str` — short English lock for clothes/colors
+- `hair_lock: str`
+- `portrait_key: str` — key into portraits map (usually `canonical_name` or `canonical_name@stage`)
+
+### `CharacterCanon`
+
+- `canonical_name: str`
+- `aliases: list[str]`
+- `face_lock: str` — shared across stages (bone structure / eyes / distinguishing face traits)
+- `palette_notes: str` — role-relative color constraints
+- `stages: list[CharacterStage]` — at least one (`default` if age is irrelevant)
+- `role: str`
+
+### `VisualBible`
+
+- `version: str` — pipeline token, start at `bible_v1`
+- `style_guide: str` — **project-locked** English art direction (not replaced per chunk)
+- `color: ColorBible`
+- `characters: dict[str, CharacterCanon]` — keyed by `canonical_name`
+- `sheet_ref_local: str | None = None` — **C extension**; always `null` in B
+- `content_hash: str` — sha256 over style + color + face/outfit locks (for fingerprint)
+
+### `ProjectState`
+
+Add:
+
+- `visual_bible: VisualBible | None = None`
+
+Page / panel character strings may use `Name@stage` (e.g. `陌生女人@teen`). Resolvers strip the suffix for canon lookup and select the stage.
+
+## §5 Identity reconcile flow
+
+### When
+
+1. **First bible:** after the first successful extract when `state.visual_bible is None`, before portraits.
+2. **Per chunk:** after `merge_characters` yields `new_names`, run a light reconcile against the existing bible.
+
+### Tool output (structured)
+
+- `merges: [{alias, canonical, confidence, reason}]`
+- `stages: [{name, stage, of_canonical, reason}]`
+- `keeps: [{name, reason}]` — new independent canons
+- `color_patches: [...]` — optional; default empty (palette stays stable)
+
+### Auto vs human
+
+- `confidence == "high"` (same person) → **auto** `merge_character_alias` + update canon aliases; drop alias portrait; rewrite names in `page_cache` / current plans.
+- `confidence == "low"` → append `needs_review`; treat as keep for this run (no silent merge).
+- Stage links do not create a second root character; they attach under `of_canonical`.
+
+### Relation to string alias detection
+
+- Keep `detect_character_aliases` as a cheap pre-hint list passed into the reconcile prompt.
+- LLM reconcile is authoritative for semantic aliases (`R·` vs `男人(被叙述者)`).
+
+### Failure
+
+- On timeout / parse failure: log warning, skip merges for this chunk, keep existing bible; do not block image generation.
+
+## §6 Prompt, references, fingerprint
+
+### Prompts
+
+`render_finished_page_prompt` and portrait generation inject:
+
+1. `Style: {bible.style_guide}`
+2. `Color bible:` palette hexes + lighting + forbidden
+3. Per character: `face_lock` + stage `hair_lock` / `outfit_lock` + `palette_notes` (via canon-aware `ensure_character_l1`)
+4. Hard line: `do not change hair color, outfit colors, or skin tone across panels unless action says costume change`
+
+Chunk-local `elements.style_guide` may **initialize** an empty bible once; it must not override a locked bible.
+
+### References (B)
+
+- Finished page refs = portraits for every resolved on-page character (from `reference_characters` ∪ all `panel.characters`).
+- Missing portrait → generate that canon/stage portrait before the page.
+- Optional: previous page `blank_local` as continuity ref; **portrait slots win** if at the provider cap.
+- C hook: if `sheet_ref_local` is set, prepend it as the first ref (unimplemented generator in B).
+
+### Pipeline order (finished_page)
+
+```text
+extract → merge_characters → reconcile(visual_bible)
+ → portraits(canon / stages)
+ → plan_comic_pages (emit canonical names / Name@stage)
+ → render prompt (style + color + locks) + refs
+ → blank → letter
+```
+
+### Fingerprint
+
+`_render_fingerprint` includes:
+
+- `visual_bible: "bible_v1"`
+- `bible_hash: visual_bible.content_hash`
+
+Do **not** write `visual_sheet` in B (avoid useless invalidation). C may add `visual_sheet=v1` later.
+
+Bible first create or `content_hash` change → `_soft_invalidate_render` (clear portraits/pages; keep structural caches; page plans may already have rewritten names).
+
+## §7 Phase C extension points (no implementation in B)
+
+| Hook | B behavior |
+|---|---|
+| `VisualBible.sheet_ref_local` | Always `None` |
+| `build_visual_sheet(bible) -> Path` | Stub / no-op returning `None` |
+| Ref collection | `if sheet_ref_local: refs = [sheet] + portraits` branch present but dead |
+| Fingerprint `visual_sheet` | Omitted until C ships |
+
+## §8 Migration (93520df389e3 and peers)
+
+1. Re-run project with B code.
+2. Missing bible → first-chunk reconcile builds bible and merges obvious aliases.
+3. New fingerprint → portraits + pages regenerate under locked style/color.
+4. No manual state edit required; optional: user can clear `pages/` / portraits if they want a clean tree before resume.
+
+## §9 Testing
+
+- Schema round-trip for `VisualBible` / stages / `Name@stage` resolve.
+- Reconcile apply: high-confidence merge updates canon + calls alias merge; low-confidence → `needs_review` only.
+- Prompt contains color hexes + face_lock for on-page metaphor-safe characters.
+- Finished-page ref list non-empty when panels list characters even if `reference_characters=[]`.
+- Fingerprint changes when `content_hash` changes; unchanged bible → stable hash.
+- Sheet hook: with `sheet_ref_local` set in a unit test, it appears first in refs (C readiness).
+
+## §10 Files (expected touch set)
+
+- `core/schemas.py` — bible models + `ProjectState.visual_bible`
+- `core/comic/visual_bible.py` (new) — build/reconcile/apply/hash/resolve stage
+- `core/comic/identity.py` — canon-aware L1 / locks
+- `core/comic/page_prompt.py` — inject style/color/locks
+- `core/comic/consistency.py` — ref collection + sheet hook
+- `core/screenwriter.py` — reconcile tool schema + prompts
+- `core/pipelines/creative_comic.py` — wire order + fingerprint
+- Tests under `tests/test_visual_bible*.py` (+ prompt/fingerprint updates)
diff --git a/docs/superpowers/specs/2026-08-02-visual-bible-v2-hardening-design.md b/docs/superpowers/specs/2026-08-02-visual-bible-v2-hardening-design.md
new file mode 100644
index 0000000..2803117
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-02-visual-bible-v2-hardening-design.md
@@ -0,0 +1,135 @@
+# Design: Visual Bible v2 Hardening
+
+**Date:** 2026-08-02
+**Status:** Approved in chat (thorough fix for 9910f82a3873)
+**Repro:** `comic_out/9910f82a3873` — wrong merges (伯爵→R, 陌生女人→母亲), English prose as character names/portrait_keys, empty hair/outfit locks, character-sheet insets on finished pages, modern clothing, panels with empty `characters`
+**Depends on:** `docs/superpowers/specs/2026-08-02-visual-bible-design.md` (phase B)
+**Product ask:** Thoroughly fix identity + page visual consistency (option 2: identity + page constraints together).
+
+## §1 Goals and non-goals
+
+### Goals
+
+- Stop illegal English-prose strings from becoming character names or `portrait_key`s.
+- Block high-confidence merges across incompatible roles (mother≠daughter, count≠novelist, servant≠master).
+- Require non-empty `face_lock` (face only), `hair_lock`, and period-aware `outfit_lock` on every canon/stage.
+- Sanitize polluted project state on resume (demote prose names, undo bad aliases) and bump to `bible_v2`.
+- Backfill empty `panel.characters` from refs/action so portrait refs attach.
+- Forbid character-sheet / turnaround / multi-age collage on finished pages; reinforce period wardrobe in prompts.
+- Soft-invalidate via fingerprint when hardening changes apply.
+
+### Non-goals
+
+- Phase C visual sheet image generation.
+- Enabling L3 face-swap by default.
+- Pixel-editing existing 9910 PNGs (re-run regenerates).
+- Perfect narrative fidelity of every action shot (planner quality remains separate; this fix ensures identity/refs/locks).
+
+## §2 Problem statement (verified on 9910)
+
+| Symptom | Mechanism |
+|---|---|
+| 帝国伯爵 merged into R | Reconcile high-merge without role compatibility guard |
+| 陌生女人 alias under 母亲 | Same — mother/daughter roles not blocked |
+| English face paragraphs as CharacterAsset names | LLM used description as `portrait_key` / name; no rejector |
+| Empty hair/outfit locks | Schema allowed blank; apply did not harden |
+| face_lock includes clothes | No face-vs-outfit split in prompts/validation |
+| Character turnaround insets on page | Finished-page prompt never forbade design sheets |
+| Modern purple hoodie | Outfit locks empty; no period wardrobe hard line |
+| Caption about skiing, art is writing desk | Panels with `characters=[]` → weak refs; planner drift (mitigate refs only) |
+| Age collage (girl+adult+mother+R) | No anti-collage / stage discipline in page prompt |
+
+## §3 Identity purge (pre/post reconcile)
+
+### Illegal name detector
+
+`is_illegal_character_name(name: str) -> bool` when any of:
+
+- Length > 40 and contains ASCII letters heavily, or
+- Matches description patterns (`,`, ` with `, `hair`, `expression`, `wearing`, `build`, etc.) as majority English prose, or
+- Equals a known English portrait blurb style (comma-separated trait list ≥ 3 clauses)
+
+Illegal names:
+
+- Must not create a root `CharacterAsset` or canon key.
+- If seen as alias/portrait_key, fold into the owning canonical (or drop).
+- Existing assets with illegal names: on sanitize, merge portrait into canonical if linked, else quarantine (delete from `characters` after moving useful appearance into canon locks) and soft-invalidate that portrait path.
+
+### Merge guardrail
+
+Before applying `confidence=high` merge:
+
+- Load roles of alias and canonical (from `state.characters` / canon.role).
+- If `_roles_incompatible(role_a, role_b)` → force `low` (needs_review), skip auto-merge.
+- Incompatible keyword pairs (either side, casefold):
+ `(母|妈|mother|widow)` vs `(女|孩|narrator|少女|女儿)` when both present as distinct role centers;
+ `(伯爵|count|工厂主|情人)` vs `(小说家|作家|novelist)` unless alias is clearly a nickname of the same person;
+ `(仆|butler|约翰)` vs `(主人|novelist|作家)` as person identity (servant is never the master).
+
+Conservative: when unsure, demote to low.
+
+### Lock requirements
+
+On install/upsert canon and each stage:
+
+- `face_lock`: facial features only (eyes, bone, age look) — strip outfit words if present.
+- `hair_lock`, `outfit_lock`: non-empty; if blank, derive from `appearance` or from face string leftovers; if still blank, set safe period defaults from bible style (`early 20th century European period clothing` / dark hair).
+- Reject / repair stages whose `portrait_key` is illegal; set `portrait_key` to `f"{canonical}@{stage}"` short form only.
+
+### State sanitize (resume)
+
+`sanitize_visual_bible_state(state) -> bool` (True if mutated):
+
+1. Strip illegal-named characters into aliases of matching canon or drop.
+2. Remove aliases that fail role-compatibility against their canon.
+3. Ensure locks non-empty on all canons/stages.
+4. Set `version = "bible_v2"` and refresh `content_hash`.
+5. Caller soft-invalidates when sanitize returns True or version/hash changed.
+
+## §4 Storyboard / page plan hygiene
+
+### Character backfill
+
+`backfill_panel_characters(plan, known_names) -> plan`:
+
+- If a panel has empty `characters` but `reference_characters` or `action`/`purpose` mentions a known canonical/alias, append resolved canonical names (cap reasonable).
+- Always union page-level `reference_characters` with on-panel characters after rewrite.
+
+### Stage selection (light)
+
+When rewriting names for render, if action text suggests age (`少女`, `童年`, `十三`, `临终`, `写信`), prefer matching stage portrait_key when present. Do not invent stages here.
+
+### Anti multi-stage collage (prompt)
+
+Page prompt hard line: do not depict multiple age versions of the same person in one page unless `layout_intent` explicitly calls for flashback split.
+
+## §5 Finished-page prompt hardening
+
+Inject when bible present (in addition to v1 color/locks):
+
+- `NO character design sheets, turnarounds, model sheets, or multi-view reference collages inside the page.`
+- `Period-accurate wardrobe only; no modern hoodies, sneakers, or athleisure unless action explicitly requires costume change.`
+- Per-character outfit_lock + hair_lock lines (already via l1_from_canon; ensure locks filled).
+
+## §6 Fingerprint
+
+- Token: `visual_bible: "bible_v2"` (replace `bible_v1`).
+- `bible_hash` still from content hash (now includes non-empty locks after sanitize).
+
+## §7 Testing
+
+- Illegal name detection true/false cases.
+- Merge guard demotes mother↔daughter and count↔novelist.
+- Apply/install fills empty locks; strips outfit from face_lock.
+- Sanitize removes English-prose CharacterAsset keys.
+- Backfill empties panel.characters from action/refs.
+- Prompt contains anti-sheet and period wardrobe lines; fingerprint bible_v2.
+- Preferred: unit tests only; no live Agnes calls.
+
+## §8 Files (expected)
+
+- `core/comic/visual_bible.py` — detectors, guards, sanitize, lock fill, backfill helpers
+- `core/screenwriter.py` — stronger reconcile instructions (no prose names, no cross-role merges, required locks)
+- `core/comic/page_prompt.py` — anti-sheet + period lines
+- `core/pipelines/creative_comic.py` — sanitize on load/resume; backfill before render; bible_v2 fingerprint
+- `tests/test_visual_bible_v2.py` (+ updates to fingerprint/prompt tests)
diff --git a/tests/test_creative_comic.py b/tests/test_creative_comic.py
index 8f682dd..720d930 100644
--- a/tests/test_creative_comic.py
+++ b/tests/test_creative_comic.py
@@ -90,7 +90,7 @@ def test_creative_comic_generates_and_resumes(tmp_path):
assert Path(proj.state.generated.portraits["方鸿渐"]).exists()
assert proj.pdf and Path(proj.pdf).exists()
assert img.calls == 3 # 1 portrait + 2 panels (char reused on 2nd chunk)
- assert chat.calls == 4 # 2 extract + 2 storyboard (page_script off by default)
+ assert chat.calls == 6 # 2 extract + 2 reconcile + 2 storyboard (page_script off by default)
# Resume: everything is in state.json, so no new generation happens.
chat2, img2 = FakeChat(), FakeImage()
diff --git a/tests/test_d2_pipeline.py b/tests/test_d2_pipeline.py
index a9599c9..8603b79 100644
--- a/tests/test_d2_pipeline.py
+++ b/tests/test_d2_pipeline.py
@@ -99,8 +99,8 @@ def test_d2_pipeline_writes_and_resumes_page_script(tmp_path, monkeypatch):
src = "第一章\n方鸿渐在甲板上。\n第二章\n方鸿渐在读书。"
chat = D2FakeChat()
asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat, image=FakeImage()))
- # 2 chunks → 2 extract + 2 storyboard + 2 page_script = 6
- assert chat.calls == 6
+ # 2 chunks → 2 extract + 2 reconcile + 2 storyboard + 2 page_script = 8
+ assert chat.calls == 8
state = ProjectState.load(tmp_path / "state.json")
for key in ("0", "1"):
ps = state.chunk_cache[key].page_script
@@ -121,7 +121,7 @@ def test_d2_pipeline_skips_page_script_by_default(tmp_path, monkeypatch):
src = "第一章\n方鸿渐在甲板上。\n第二章\n方鸿渐在读书。"
chat = D2FakeChat()
asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat, image=FakeImage()))
- assert chat.calls == 4 # 2 extract + 2 storyboard (no plan_page_script)
+ assert chat.calls == 6 # 2 extract + 2 reconcile + 2 storyboard (no plan_page_script)
state = ProjectState.load(tmp_path / "state.json")
for key in ("0", "1"):
assert state.chunk_cache[key].page_script is None
diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py
index 36cfce6..2a834a4 100644
--- a/tests/test_finished_page_pipeline.py
+++ b/tests/test_finished_page_pipeline.py
@@ -1,14 +1,20 @@
"""tests/test_finished_page_pipeline.py — finished-page orchestration (fakes, no network)."""
import asyncio
+import hashlib
+import json
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from core.api import ChatProvider, ImageProvider
-from core.pipelines.creative_comic import creative_comic
-from core.schemas import ProjectState
+from core.pipelines.creative_comic import (
+ _render_fingerprint,
+ creative_comic,
+ is_unsupported_image_size_error,
+)
+from core.schemas import ModelSnapshot, ProjectState
from core.screenwriter import is_content_policy_rejection
@@ -31,6 +37,17 @@ async def generate_single_image(self, prompt, reference_image_paths=None, size=N
return FakeImageOutput()
+class RecordingImage(FakeImage):
+ def __init__(self):
+ super().__init__()
+ self.prompts: list[str] = []
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ if "Finished readable manga/comic page" in prompt:
+ self.prompts.append(prompt)
+ return await super().generate_single_image(prompt, reference_image_paths, size, **kw)
+
+
class FakeChat(ChatProvider):
def __init__(self):
self.calls = 0
@@ -51,6 +68,33 @@ async def chat_function_call(self, messages, tools, tool_choice, **kw):
"settings": [{"name": "村口", "scene_prompt": "village entrance at dusk"}],
"style_guide": "manhua",
}
+ if name == "reconcile_visual_bible":
+ return {
+ "merges": [],
+ "stages": [],
+ "keeps": [],
+ "color_patches": [],
+ "style_guide": "manhua",
+ "color": {
+ "palette": [{"name": "ink", "hex": "#1A1A1A", "usage": "lines"}],
+ "lighting": "soft",
+ "forbidden": [],
+ },
+ "canons": [
+ {
+ "canonical_name": "福贵",
+ "face_lock": "a middle-aged farmer",
+ "stages": [
+ {
+ "stage": "adult",
+ "outfit_lock": "simple farmer clothes",
+ "hair_lock": "short dark hair",
+ "portrait_key": "福贵",
+ }
+ ],
+ }
+ ],
+ }
if name == "plan_comic_pages":
self.page_plan_calls += 1
return {
@@ -70,6 +114,7 @@ async def chat_function_call(self, messages, tools, tool_choice, **kw):
"characters": ["福贵"],
"setting_ref": "村口",
"caption": "傍晚,村口。",
+ "dialogue": [{"speaker": "福贵", "text": "我回来了。"}],
}
],
"reference_characters": ["福贵"],
@@ -86,6 +131,124 @@ def _fake_export_pdf(self, page_dir, out="comic.pdf", layout="TwoPageRight", dir
return out
+def test_render_fingerprint_includes_visual_bible_hash():
+ snapshot = ModelSnapshot(chat="chat", t2i="image", i2i="image")
+ fp = _render_fingerprint(
+ "style",
+ snapshot=snapshot,
+ panel_continuity=False,
+ l3_enabled=False,
+ render_mode="finished_page",
+ page_size="1024x1536",
+ bible_version="bible_v1",
+ bible_hash="deadbeef",
+ )
+ payload = {
+ "style_guide": "style",
+ "model_snapshot": snapshot.model_dump(),
+ "panel_continuity": False,
+ "l3_enabled": False,
+ "render_mode": "finished_page",
+ "page_size": "1024x1536",
+ "identity": "metaphor_v2",
+ "lettering": "deferred_v3",
+ "visual_bible": "bible_v1",
+ "bible_hash": "deadbeef",
+ }
+ expected = hashlib.sha256(
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ assert fp == expected
+
+
+def test_render_fingerprint_uses_bible_v2_token():
+ snapshot = ModelSnapshot(chat="chat", t2i="image", i2i="image")
+ fp = _render_fingerprint(
+ "style",
+ snapshot=snapshot,
+ panel_continuity=False,
+ l3_enabled=False,
+ render_mode="finished_page",
+ page_size="1024x1536",
+ bible_version="bible_v2",
+ bible_hash="abc",
+ )
+ payload = {
+ "style_guide": "style",
+ "model_snapshot": snapshot.model_dump(),
+ "panel_continuity": False,
+ "l3_enabled": False,
+ "render_mode": "finished_page",
+ "page_size": "1024x1536",
+ "identity": "metaphor_v2",
+ "lettering": "deferred_v3",
+ "visual_bible": "bible_v2",
+ "bible_hash": "abc",
+ }
+ expected = hashlib.sha256(
+ json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ assert fp == expected
+
+
+def test_render_fingerprint_tracks_deferred_lettering_version():
+ snapshot = ModelSnapshot(chat="chat", t2i="image", i2i="image")
+ expected_payload = json.dumps(
+ {
+ "style_guide": "manhua",
+ "model_snapshot": snapshot.model_dump(),
+ "panel_continuity": False,
+ "l3_enabled": False,
+ "render_mode": "finished_page",
+ "page_size": "1024x1536",
+ "lettering": "deferred_v3",
+ "identity": "metaphor_v2",
+ },
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+
+ assert (
+ _render_fingerprint(
+ "manhua",
+ snapshot=snapshot,
+ panel_continuity=False,
+ l3_enabled=False,
+ )
+ == hashlib.sha256(expected_payload.encode("utf-8")).hexdigest()
+ )
+
+
+def test_render_fingerprint_omits_lettering_for_panel_compose():
+ snapshot = ModelSnapshot(chat="chat", t2i="image", i2i="image")
+ expected_payload = json.dumps(
+ {
+ "style_guide": "manhua",
+ "model_snapshot": snapshot.model_dump(),
+ "panel_continuity": False,
+ "l3_enabled": False,
+ "render_mode": "panel_compose",
+ "page_size": "1024x1536",
+ "identity": "metaphor_v2",
+ },
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ assert (
+ _render_fingerprint(
+ "manhua",
+ snapshot=snapshot,
+ panel_continuity=False,
+ l3_enabled=False,
+ render_mode="panel_compose",
+ )
+ == hashlib.sha256(expected_payload.encode("utf-8")).hexdigest()
+ )
+ assert '"lettering"' not in expected_payload
+
+
@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch):
monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
@@ -100,14 +263,16 @@ def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch):
assert "c0000:u1_p0001" in proj.state.generated.pages
assert "c0000:u1_p0001" in proj.state.pages_done
generated_page = proj.state.generated.pages["c0000:u1_p0001"]
- assert generated_page.mode == "finished"
+ assert generated_page.mode == "finished_lettered"
+ assert generated_page.blank_local
+ assert Path(generated_page.blank_local).exists()
assert Path(generated_page.local).exists()
assert proj.pdf and Path(proj.pdf).exists()
assert proj.pages == [str(p) for p in page_files]
assert img.calls == 2 # 1 portrait + 1 page
- assert chat.calls == 2 # extract + plan_comic_pages
+ assert chat.calls == 3 # extract + reconcile + plan_comic_pages
# Resume: state.json already has the page recorded, so nothing regenerates.
chat2, img2 = FakeChat(), FakeImage()
@@ -117,6 +282,57 @@ def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch):
assert proj2.state.pages_done == proj.state.pages_done
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_writes_blank_and_lettered(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ img = RecordingImage()
+ proj = asyncio.run(
+ creative_comic("第一章\n福贵在村口。", output_dir=str(tmp_path), chat=FakeChat(), image=img)
+ )
+
+ assert any(
+ "no readable" in p.lower()
+ or "no speech bubbles" in p.lower()
+ or "empty speech" in p.lower()
+ for p in img.prompts
+ )
+ assert all("DIALOGUE (exact):" not in p for p in img.prompts)
+ page_key = next(iter(proj.state.generated.pages))
+ generated_page = proj.state.generated.pages[page_key]
+ assert generated_page.mode == "finished_lettered"
+ assert generated_page.blank_local and Path(generated_page.blank_local).exists()
+ assert Path(generated_page.local).exists()
+ assert Path(generated_page.blank_local).read_bytes() != Path(generated_page.local).read_bytes()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_reletters_from_blank_without_new_image(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ out = str(tmp_path)
+ asyncio.run(
+ creative_comic(
+ "第一章\n福贵在村口。", output_dir=out, chat=FakeChat(), image=RecordingImage()
+ )
+ )
+
+ state = ProjectState.load(tmp_path / "state.json")
+ key = next(iter(state.generated.pages))
+ generated_page = state.generated.pages[key]
+ Path(generated_page.local).unlink()
+ state.pages_done = [done_key for done_key in state.pages_done if done_key != key]
+ generated_page.local = str(tmp_path / "pages" / "missing.png")
+ state.save(tmp_path / "state.json")
+
+ img2 = RecordingImage()
+ proj2 = asyncio.run(
+ creative_comic("第一章\n福贵在村口。", output_dir=out, chat=FakeChat(), image=img2)
+ )
+
+ assert img2.prompts == []
+ assert Path(proj2.state.generated.pages[key].local).exists()
+ assert proj2.state.generated.pages[key].mode == "finished_lettered"
+
+
@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
def test_finished_page_kwarg_overrides_env_default(tmp_path, monkeypatch):
# Env says panel_compose; explicit kwarg should win.
@@ -146,7 +362,7 @@ def test_finished_page_resumes_after_deleted_page(tmp_path, monkeypatch):
chat2, img2 = FakeChat(), FakeImage()
proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
- assert img2.calls == 1 # only the missing page regenerates; portrait reused
+ assert img2.calls == 0 # missing lettered page is rebuilt from the retained blank
assert chat2.calls == 0 # page plan reused from page_cache
assert deleted.exists()
assert "c0000:u1_p0001" in proj.state.pages_done
@@ -175,7 +391,7 @@ def test_finished_page_filenames_are_position_stable_across_partial_resume(tmp_p
chat2, img2 = FakeChat(), FakeImage()
proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
- assert img2.calls == 1 # only the deleted page regenerates
+ assert img2.calls == 0 # deleted lettered page is rebuilt from its position-stable blank
assert Path(first_page.local).exists() # regenerated at the *same* path
assert second_path.exists()
assert second_path.read_bytes() == second_bytes_before # untouched, not overwritten
@@ -208,6 +424,46 @@ def test_is_content_policy_rejection_still_used_by_finished_page_path():
assert is_content_policy_rejection(RuntimeError("content_policy_violation")) is True
+def test_is_unsupported_image_size_error_detects_size_rejects():
+ assert is_unsupported_image_size_error(RuntimeError("invalid size: 1024x1536 is not supported"))
+ assert is_unsupported_image_size_error(
+ RuntimeError("Bad Request: unsupported resolution for this model")
+ )
+ assert not is_unsupported_image_size_error(RuntimeError("rate limit exceeded"))
+ assert not is_unsupported_image_size_error(
+ RuntimeError("content_policy_violation: size mention alone is not enough")
+ )
+
+
+class SizeRejectThenOkImage(FakeImage):
+ """Portrait size rejected once; square fallback succeeds."""
+
+ def __init__(self):
+ super().__init__()
+ self.page_sizes: list[str | None] = []
+
+ async def generate_single_image(self, prompt, reference_image_paths=None, size=None, **kw):
+ self.calls += 1
+ if "Finished readable manga/comic page" in prompt:
+ self.page_sizes.append(size)
+ if size == "1024x1536":
+ raise RuntimeError("Agnes image error: invalid size 1024x1536 is not supported")
+ return FakeImageOutput()
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_finished_page_falls_back_to_square_when_size_rejected(tmp_path, monkeypatch):
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ monkeypatch.delenv("INKSTONE_PAGE_SIZE", raising=False)
+ src = "第一章\n福贵在村口。"
+ img = SizeRejectThenOkImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=FakeChat(), image=img))
+
+ assert img.page_sizes == ["1024x1536", "1024x1024"]
+ assert "c0000:u1_p0001" in proj.state.pages_done
+ assert Path(proj.state.generated.pages["c0000:u1_p0001"].local).exists()
+
+
class FailOncePageImage(FakeImage):
"""First finished-page image call fails generically; second succeeds."""
@@ -349,3 +605,24 @@ def test_finished_page_webtoon_stacks_page_images(tmp_path, monkeypatch):
assert proj.webtoon.endswith("webtoon.png")
assert proj.pages == [proj.webtoon]
assert len(list((tmp_path / "pages").glob("page_c*_p*.png"))) == 2
+
+
+@patch("core.pipelines.creative_comic.ExportEngine.export_pdf", _fake_export_pdf)
+def test_legacy_completed_project_bootstraps_visual_bible(tmp_path, monkeypatch):
+ """Completed chunks without a bible must still reconcile and soft-invalidate render."""
+ monkeypatch.setenv("INKSTONE_RENDER_MODE", "finished_page")
+ src = "第一章\n福贵在村口。"
+ chat, img = FakeChat(), FakeImage()
+ proj = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat, image=img))
+ assert proj.state.visual_bible is not None
+ assert proj.state.pages_done
+
+ state = ProjectState.load(tmp_path / "state.json")
+ state.visual_bible = None
+ state.save(tmp_path / "state.json")
+
+ chat2, img2 = FakeChat(), FakeImage()
+ proj2 = asyncio.run(creative_comic(src, output_dir=str(tmp_path), chat=chat2, image=img2))
+ assert proj2.state.visual_bible is not None
+ assert chat2.calls >= 1 # reconcile on resume
+ assert img2.calls >= 1 # pages re-rendered after first bible bootstrap
diff --git a/tests/test_identity_metaphor.py b/tests/test_identity_metaphor.py
new file mode 100644
index 0000000..67b11d4
--- /dev/null
+++ b/tests/test_identity_metaphor.py
@@ -0,0 +1,33 @@
+from core.comic.identity import (
+ harden_human_identity_prompt,
+ name_suggests_animal_metaphor,
+)
+from core.schemas import CharacterAsset
+
+
+def test_name_suggests_animal_metaphor_huniu():
+ assert name_suggests_animal_metaphor("虎妞") is True
+ assert name_suggests_animal_metaphor("祥子") is False
+
+
+def test_harden_huniu_prompt_forbids_tiger():
+ out = harden_human_identity_prompt("虎妞", "虎妞, sturdy woman in traditional clothes")
+ assert out.lower().startswith("human character")
+ assert "metaphorical" in out.lower()
+ assert "not an animal" in out.lower()
+ assert "tiger" in out.lower()
+ assert out.index("human character") < out.index("虎妞")
+
+
+def test_ordinary_name_prompt_unchanged():
+ base = "middle-aged farmer in patched jacket"
+ assert harden_human_identity_prompt("祥子", base) == base
+
+
+def test_ensure_character_l1_hardens_metaphor_name():
+ from core.comic.identity import ensure_character_l1
+
+ asset = CharacterAsset(name="虎妞", role="factory owner's daughter", l1_prompt="虎妞, sturdy")
+ ensure_character_l1(asset)
+ assert asset.l1_prompt.lower().startswith("human character")
+ assert "not an animal" in asset.l1_prompt.lower()
diff --git a/tests/test_lettering_lang.py b/tests/test_lettering_lang.py
new file mode 100644
index 0000000..f7960ee
--- /dev/null
+++ b/tests/test_lettering_lang.py
@@ -0,0 +1,85 @@
+from core.comic.lettering_lang import (
+ lettering_field_mismatches,
+ sanitize_lettering_text,
+ sanitize_plan_lettering,
+ source_lettering_script,
+ strip_mismatched_lettering,
+ strip_pinyin_glosses,
+)
+from core.schemas import ComicPagePlan
+
+
+def test_source_lettering_script_chinese_novel():
+ assert source_lettering_script("第一章\n福贵在村口看着夕阳。") == "cjk"
+
+
+def test_source_lettering_script_english():
+ assert source_lettering_script("Chapter one. Fugui stood at the gate.") == "latin"
+
+
+def test_mismatches_english_dialogue_on_chinese_source():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [{"panel_id": "1", "dialogue": "Hello there", "caption": "傍晚,村口。"}],
+ }
+ )
+ bad = lettering_field_mismatches(plan, "cjk")
+ assert ("1", "dialogue", "Hello there") in bad
+ assert not any(k == "caption" for _, k, _ in bad)
+
+
+def test_strip_mismatched_lettering_drops_english_on_cjk():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [{"panel_id": "1", "dialogue": "Hello", "caption": "傍晚"}],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.1, "w": 0.3, "h": 0.1},
+ {"kind": "caption", "panel_id": "1", "x": 0.1, "y": 0.0, "w": 0.5, "h": 0.1},
+ ],
+ }
+ )
+ fixed = strip_mismatched_lettering(plan, "cjk")
+ assert fixed.panels[0].dialogue is None
+ assert fixed.panels[0].caption == "傍晚"
+ assert all(b.kind != "dialogue" for b in fixed.lettering_boxes)
+
+
+def test_strip_pinyin_glosses_removes_parentheses():
+ raw = "一清醒过来,他已经是“骆驼祥子”了。(Yī xǐngguò lái, tā yǐjīng shì “Luòtuo Xiángzi” le.)"
+ assert strip_pinyin_glosses(raw) == "一清醒过来,他已经是“骆驼祥子”了。"
+ ascii_paren = "海甸的小店,三日昏迷 (Haidian de xiaodian, sanri hunmi)"
+ assert "Haidian" not in strip_pinyin_glosses(ascii_paren)
+ assert "海甸的小店,三日昏迷" in strip_pinyin_glosses(ascii_paren)
+
+
+def test_sanitize_lettering_strips_pinyin_and_truncates():
+ long_pinyin = (
+ "自从一到城里来,他就是“祥子”,仿佛根本没有个姓;如今,“骆驼祥子”之上,"
+ "就更没有人关心他到底姓什么了。(Zicong yi dao chengli lai...)"
+ )
+ out = sanitize_lettering_text(long_pinyin, kind="caption", script="cjk")
+ assert out is not None
+ assert "Zicong" not in out
+ assert "(" not in out and "(" not in out
+ assert len(out) <= 48
+ assert out.endswith("…") or len(long_pinyin) <= 48
+
+
+def test_sanitize_plan_lettering_cleans_fields():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {
+ "panel_id": "1",
+ "caption": "海甸的小店,三日昏迷(Hǎidiàn de xiǎodiàn, sānrì hūnmí)",
+ "dialogue": "我要这辆车!",
+ }
+ ],
+ }
+ )
+ fixed = sanitize_plan_lettering(plan, "cjk")
+ assert fixed.panels[0].caption == "海甸的小店,三日昏迷"
+ assert fixed.panels[0].dialogue == "我要这辆车!"
diff --git a/tests/test_page_lettering.py b/tests/test_page_lettering.py
new file mode 100644
index 0000000..96d8897
--- /dev/null
+++ b/tests/test_page_lettering.py
@@ -0,0 +1,113 @@
+from PIL import Image, ImageChops
+
+from core.comic.layout import LayoutEngine
+from core.comic.page_lettering import (
+ LETTERING_VERSION,
+ fit_lettering_box,
+ letter_finished_page,
+ resolve_lettering_jobs,
+)
+from core.schemas import ComicPagePlan
+
+
+def test_resolve_uses_boxes_then_heuristics():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {"panel_id": "1", "dialogue": "你好", "caption": "旁白"},
+ {"panel_id": "2", "sfx": "砰"},
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.1},
+ ],
+ }
+ )
+ jobs = resolve_lettering_jobs(plan)
+ kinds = {(p, k) for p, k, _, _ in jobs}
+ assert ("1", "dialogue") in kinds
+ assert ("1", "caption") in kinds # heuristic
+ assert ("2", "sfx") in kinds
+ dialogue = next(b for p, k, t, b in jobs if k == "dialogue")
+ # margin clamp may nudge x slightly inward from 0.1
+ assert dialogue[2] <= 0.3 + 1e-6
+ assert dialogue[0] >= 0.04
+
+
+def test_letter_finished_page_draws_nonzero_ink():
+ blank = Image.new("RGB", (200, 300), (200, 200, 200))
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [{"panel_id": "1", "dialogue": "你好世界"}],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.1, "w": 0.6, "h": 0.2},
+ ],
+ }
+ )
+ out = letter_finished_page(blank, plan)
+ assert out.size == blank.size
+ assert ImageChops.difference(out, blank).getbbox() is not None
+
+
+def test_letter_finished_page_shrink_wraps_huge_plan_box():
+ blank = Image.new("RGB", (200, 400), (200, 200, 200))
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {
+ "panel_id": "2",
+ "dialogue": "假如老头子消了气呢?",
+ }
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "2", "x": 0.1, "y": 0.5, "w": 0.8, "h": 0.4},
+ ],
+ }
+ )
+ out = letter_finished_page(blank, plan)
+ bottom = out.crop((0, 200, 200, 400))
+ whiteish = sum(
+ 1
+ for y in range(bottom.height)
+ for x in range(bottom.width)
+ if bottom.getpixel((x, y))[0] > 240
+ )
+ assert whiteish < 8000
+
+
+def test_fit_lettering_box_stays_inside_page_margin():
+ engine = LayoutEngine()
+ page_w, page_h = 400, 600
+ # Anchor flush to left/top edge — must inset.
+ box = fit_lettering_box(
+ engine,
+ "dialogue",
+ "我要这辆车!",
+ (0, 0, 300, 200),
+ (page_w, page_h),
+ )
+ x, y, bw, bh = box
+ assert x >= int(page_w * 0.04) - 1
+ assert y >= int(page_h * 0.04) - 1
+ assert x + bw <= page_w - int(page_w * 0.04) + 1
+ assert y + bh <= page_h - int(page_h * 0.04) + 1
+
+
+def test_resolve_strips_pinyin_before_overlay():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "panels": [
+ {
+ "panel_id": "1",
+ "caption": "海甸的小店,三日昏迷(Hǎidiàn de xiǎodiàn, sānrì hūnmí)",
+ }
+ ],
+ }
+ )
+ jobs = resolve_lettering_jobs(plan, source_text="祥子在北平。")
+ assert len(jobs) == 1
+ assert jobs[0][2] == "海甸的小店,三日昏迷"
+ assert LETTERING_VERSION == "deferred_v3"
diff --git a/tests/test_page_prompt.py b/tests/test_page_prompt.py
index 6ad9d16..9882411 100644
--- a/tests/test_page_prompt.py
+++ b/tests/test_page_prompt.py
@@ -32,8 +32,88 @@ def test_prompt_includes_layout_lettering_and_identity():
style_guide="manhua comic style",
)
assert "A4 portrait" in text or "portrait comic page" in text.lower()
- assert "MIDNIGHT AT THE SKY ARCHIVE" in text
- assert "This map is moving." in text
+ assert "MIDNIGHT AT THE SKY ARCHIVE" not in text
+ assert "This map is moving." not in text
+ assert "CAPTION (exact):" not in text
+ assert "DIALOGUE (exact):" not in text
+ assert "no readable" in text.lower() or "no speech bubbles" in text.lower()
assert "young woman, dark hair" in text
assert "2x2" not in text.lower() # renderer must not collapse intent to grid slogan
assert "Wide top archive" in text
+
+
+def test_deferred_prompt_omits_glyph_strings_and_forbids_readable_text():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "establish",
+ "layout_intent": "wide top",
+ "panels": [
+ {
+ "panel_id": "1",
+ "action": "福贵 walks",
+ "characters": ["福贵"],
+ "dialogue": "你好",
+ "caption": "傍晚",
+ "lettering_notes": "bubble near face — leave empty",
+ }
+ ],
+ "lettering_boxes": [
+ {"kind": "dialogue", "panel_id": "1", "x": 0.2, "y": 0.3, "w": 0.3, "h": 0.1}
+ ],
+ }
+ )
+ chars = {"福贵": CharacterAsset(name="福贵", l1_prompt="middle-aged farmer")}
+ text = render_finished_page_prompt(plan, characters_by_name=chars, settings_by_name={})
+ assert "CAPTION (exact):" not in text
+ assert "DIALOGUE (exact):" not in text
+ assert "你好" not in text
+ assert "傍晚" not in text
+ assert "no readable" in text.lower() or "no speech bubbles" in text.lower()
+ assert "post-processing" in text.lower() or "leave clear space" in text.lower()
+
+
+def test_in_image_lettering_mode_still_includes_exact_strings():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "dialogue": "你好"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan, characters_by_name={}, settings_by_name={}, lettering="in_image"
+ )
+ assert "DIALOGUE (exact): 你好" in text
+
+
+def test_finished_page_prompt_locks_metaphorical_huniu():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "寿宴",
+ "layout_intent": "single panel",
+ "panels": [
+ {
+ "panel_id": "1",
+ "action": "虎妞向父亲吹嘘祥子送的寿桃",
+ "characters": ["虎妞"],
+ }
+ ],
+ "reference_characters": ["虎妞"],
+ }
+ )
+ chars = {
+ "虎妞": CharacterAsset(
+ name="虎妞",
+ role="factory owner's daughter",
+ l1_prompt="虎妞, sturdy woman in traditional clothes",
+ )
+ }
+ text = render_finished_page_prompt(plan, characters_by_name=chars, settings_by_name={})
+ assert "CRITICAL character identity" in text
+ assert "虎妞" in text
+ assert "NOT a literal animal" in text
+ assert "human character" in text
+ assert "sturdy woman in traditional clothes" in text
diff --git a/tests/test_schemas_finished_page.py b/tests/test_schemas_finished_page.py
index 2d28caf..7f680b7 100644
--- a/tests/test_schemas_finished_page.py
+++ b/tests/test_schemas_finished_page.py
@@ -3,6 +3,7 @@
ComicPagePlan,
ComicPagePlanSet,
GeneratedPage,
+ LetteringBox,
ProjectState,
)
@@ -63,3 +64,45 @@ def test_comic_page_plan_set_and_generated_page_on_state():
assert loaded.page_cache["0"].pages[0].page_id == "p0001"
assert loaded.generated.pages["p0001"].mode == "finished"
assert loaded.render_mode == "finished_page"
+
+
+def test_lettering_box_and_plan_round_trip():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p0001",
+ "purpose": "establish",
+ "layout_intent": "wide top",
+ "panels": [
+ {
+ "panel_id": "1",
+ "dialogue": "你好",
+ "action": "waves",
+ }
+ ],
+ "lettering_boxes": [
+ {
+ "kind": "dialogue",
+ "panel_id": "1",
+ "x": 0.1,
+ "y": 0.2,
+ "w": 0.4,
+ "h": 0.15,
+ }
+ ],
+ }
+ )
+ assert plan.lettering_boxes[0].kind == "dialogue"
+ assert LetteringBox.model_validate(plan.lettering_boxes[0].model_dump()).w == 0.4
+
+
+def test_generated_page_blank_local_and_lettered_mode():
+ page = GeneratedPage(
+ local="/tmp/pages/page_c0000_p0000.png",
+ blank_local="/tmp/pages/blank/page_c0000_p0000.png",
+ page_id="p0001",
+ mode="finished_lettered",
+ )
+ state = ProjectState(project_id="t", generated={"pages": {"c0000:p0001": page}})
+ loaded = ProjectState.model_validate_json(state.model_dump_json())
+ assert loaded.generated.pages["c0000:p0001"].blank_local.endswith("blank/page_c0000_p0000.png")
+ assert loaded.generated.pages["c0000:p0001"].mode == "finished_lettered"
diff --git a/tests/test_screenwriter_pages.py b/tests/test_screenwriter_pages.py
index 6739a1f..2ff252e 100644
--- a/tests/test_screenwriter_pages.py
+++ b/tests/test_screenwriter_pages.py
@@ -16,6 +16,73 @@ async def chat_function_call(self, messages, tools, tool_choice, **kwargs):
return self.payload
+class FlipLangChat(ChatProvider):
+ def __init__(self):
+ self.calls = 0
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kw):
+ self.calls += 1
+ if self.calls == 1:
+ return {
+ "unit_id": "1",
+ "pages": [
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "wide",
+ "panels": [
+ {
+ "panel_id": "1",
+ "dialogue": "Hello friend",
+ "action": "stands",
+ }
+ ],
+ }
+ ],
+ }
+ return {
+ "unit_id": "1",
+ "pages": [
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "wide",
+ "panels": [
+ {
+ "panel_id": "1",
+ "dialogue": "你好啊",
+ "action": "stands",
+ }
+ ],
+ "lettering_boxes": [
+ {
+ "kind": "dialogue",
+ "panel_id": "1",
+ "x": 0.2,
+ "y": 0.3,
+ "w": 0.4,
+ "h": 0.15,
+ }
+ ],
+ }
+ ],
+ }
+
+
+def test_plan_comic_pages_retries_once_on_language_mismatch():
+ elements = StoryElements.model_validate(
+ {
+ "characters": [{"name": "福贵", "l1_prompt": "farmer"}],
+ "settings": [],
+ "style_guide": "manhua",
+ }
+ )
+ chat = FlipLangChat()
+ pageset = asyncio.run(plan_comic_pages("第一章\n福贵在村口。", elements, chat=chat))
+ assert chat.calls == 2
+ assert pageset.pages[0].panels[0].dialogue == "你好啊"
+
+
def test_plan_comic_pages_validates_tool_payload():
payload = {
"unit_id": "0",
diff --git a/tests/test_visual_bible.py b/tests/test_visual_bible.py
new file mode 100644
index 0000000..be62619
--- /dev/null
+++ b/tests/test_visual_bible.py
@@ -0,0 +1,463 @@
+# tests/test_visual_bible.py
+from core.comic.visual_bible import (
+ apply_reconcile,
+ build_visual_sheet,
+ collect_finished_page_refs,
+ compute_bible_hash,
+ ensure_stage_portrait_assets,
+ l1_from_canon,
+ parse_stage_ref,
+ refresh_bible_hash,
+ resolve_character_asset,
+ rewrite_page_plan_names,
+ rewrite_pageset_from_bible,
+ sync_characters_from_bible,
+)
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ColorSwatch,
+ ComicPagePlan,
+ ComicPagePlanSet,
+ ProjectState,
+ VisualBible,
+ VisualBibleMerge,
+ VisualBibleReconcileResult,
+ VisualBibleStageLink,
+)
+
+
+def test_parse_stage_ref():
+ assert parse_stage_ref("陌生女人@teen") == ("陌生女人", "teen")
+ assert parse_stage_ref("R") == ("R", "default")
+
+
+def test_bible_hash_stable_and_sensitive():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(
+ palette=[ColorSwatch(name="ink", hex="#111111", usage="lines")],
+ lighting="soft",
+ forbidden=["neon"],
+ ),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="face A",
+ palette_notes="suit",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit",
+ hair_lock="dark hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ )
+ h1 = compute_bible_hash(bible)
+ bible2 = bible.model_copy(update={"style_guide": "watercolor"})
+ assert compute_bible_hash(bible2) != h1
+ refreshed = refresh_bible_hash(bible)
+ assert compute_bible_hash(refreshed) == h1
+ assert refreshed.content_hash == h1
+
+
+def test_apply_high_confidence_merge_and_low_to_review():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R": CharacterAsset(name="R", role="writer"),
+ "李先生": CharacterAsset(name="李先生", role="man"),
+ "路人": CharacterAsset(name="路人", role="extra"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="soft", forbidden=[]),
+ characters={
+ "R": CharacterCanon(canonical_name="R", face_lock="f", stages=[]),
+ },
+ ),
+ )
+ result = VisualBibleReconcileResult(
+ merges=[
+ VisualBibleMerge(alias="李先生", canonical="R", confidence="high", reason="same"),
+ VisualBibleMerge(alias="路人", canonical="R", confidence="low", reason="unsure"),
+ ],
+ stages=[],
+ keeps=[],
+ )
+ out = apply_reconcile(state, result)
+ assert "李先生" not in out.characters
+ assert (
+ "李先生" in out.characters["R"].aliases
+ or "李先生" in out.visual_bible.characters["R"].aliases
+ )
+ assert "路人" in out.characters
+ assert any(s.new_name == "路人" for s in out.needs_review)
+
+
+def test_apply_stage_link():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "陌生女人": CharacterAsset(name="陌生女人"),
+ "女孩(叙述者)": CharacterAsset(name="女孩(叙述者)"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="x",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "陌生女人": CharacterCanon(
+ canonical_name="陌生女人",
+ face_lock="soft face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dress",
+ hair_lock="long dark",
+ portrait_key="陌生女人",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ result = VisualBibleReconcileResult(
+ stages=[
+ VisualBibleStageLink(
+ name="女孩(叙述者)",
+ stage="teen",
+ of_canonical="陌生女人",
+ reason="younger",
+ )
+ ]
+ )
+ out = apply_reconcile(state, result)
+ stages = {s.stage for s in out.visual_bible.characters["陌生女人"].stages}
+ assert "teen" in stages
+
+
+def test_apply_reconcile_creates_bible_from_canons():
+ state = ProjectState(
+ project_id="p",
+ characters={"R": CharacterAsset(name="R", role="writer")},
+ visual_bible=None,
+ )
+ result = VisualBibleReconcileResult(
+ style_guide="manhua muted tones",
+ color=ColorBible(
+ palette=[ColorSwatch(name="ink", hex="#111111", usage="lines")],
+ lighting="soft",
+ forbidden=["neon"],
+ ),
+ canons=[
+ CharacterCanon(
+ canonical_name="R",
+ face_lock="calm eyes",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit",
+ hair_lock="dark hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ ],
+ )
+ out = apply_reconcile(state, result)
+ assert out.visual_bible is not None
+ assert out.visual_bible.style_guide == "manhua muted tones"
+ assert out.visual_bible.color.lighting == "soft"
+ assert out.visual_bible.color.palette[0].hex == "#111111"
+ assert "R" in out.visual_bible.characters
+ assert out.visual_bible.characters["R"].face_lock == "calm eyes"
+
+
+def test_apply_reconcile_applies_color_patches_on_update():
+ state = ProjectState(
+ project_id="p",
+ characters={"R": CharacterAsset(name="R")},
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="locked style",
+ color=ColorBible(
+ palette=[ColorSwatch(name="ink", hex="#111111", usage="lines")],
+ lighting="soft",
+ forbidden=[],
+ ),
+ characters={
+ "R": CharacterCanon(canonical_name="R", face_lock="f", stages=[]),
+ },
+ ),
+ )
+ result = VisualBibleReconcileResult(
+ style_guide="should not override",
+ color_patches=[ColorSwatch(name="ink", hex="#222222", usage="lines")],
+ color=ColorBible(
+ palette=[ColorSwatch(name="replaced", hex="#999999", usage="bg")],
+ lighting="harsh",
+ forbidden=["red"],
+ ),
+ canons=[],
+ )
+ out = apply_reconcile(state, result)
+ assert out.visual_bible.style_guide == "locked style"
+ assert out.visual_bible.color.palette[0].hex == "#222222"
+ assert out.visual_bible.color.lighting == "soft"
+ assert len(out.visual_bible.color.palette) == 1
+
+
+def test_apply_reconcile_merge_when_canonical_missing():
+ state = ProjectState(
+ project_id="p",
+ characters={"李先生": CharacterAsset(name="李先生", role="man")},
+ visual_bible=None,
+ )
+ result = VisualBibleReconcileResult(
+ canons=[
+ CharacterCanon(
+ canonical_name="R",
+ face_lock="calm eyes",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="suit",
+ hair_lock="dark",
+ portrait_key="R",
+ )
+ ],
+ )
+ ],
+ merges=[
+ VisualBibleMerge(alias="李先生", canonical="R", confidence="high", reason="same"),
+ ],
+ )
+ out = apply_reconcile(state, result)
+ assert "R" in out.characters
+ assert "李先生" not in out.characters
+ assert "李先生" in out.characters["R"].aliases
+
+
+def test_sync_characters_from_bible_updates_l1():
+ state = ProjectState(
+ project_id="p",
+ characters={"R": CharacterAsset(name="R", l1_prompt="stale")},
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="locked face",
+ stages=[
+ CharacterStage(
+ stage="default",
+ outfit_lock="locked outfit",
+ hair_lock="locked hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ sync_characters_from_bible(state)
+ assert "locked face" in state.characters["R"].l1_prompt
+ assert "locked outfit" in state.characters["R"].l1_prompt
+
+
+def test_sync_characters_from_bible_rewrites_page_cache_aliases():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["李先生"], "action": "stands"}],
+ "reference_characters": ["李先生"],
+ }
+ )
+ pageset = ComicPagePlanSet(unit_id="u1", pages=[plan])
+ state = ProjectState(
+ project_id="p",
+ characters={"R": CharacterAsset(name="R", l1_prompt="stale")},
+ page_cache={"0": pageset},
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="locked face",
+ aliases=["李先生"],
+ stages=[
+ CharacterStage(
+ stage="default",
+ outfit_lock="locked outfit",
+ hair_lock="locked hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ sync_characters_from_bible(state)
+ assert "李先生" in state.characters["R"].aliases
+ fixed = state.page_cache["0"].pages[0]
+ assert fixed.panels[0].characters == ["R"]
+ assert fixed.reference_characters == ["R"]
+
+
+def test_l1_from_canon_includes_locks():
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="calm eyes",
+ palette_notes="dark suit colors",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit",
+ hair_lock="dark short hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ text = l1_from_canon(canon, "adult")
+ assert "calm eyes" in text
+ assert "dark suit" in text
+ assert "dark short hair" in text
+
+
+def test_rewrite_page_plan_names():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["李先生"], "action": "stands"}],
+ "reference_characters": ["李先生"],
+ }
+ )
+ fixed = rewrite_page_plan_names(plan, {"李先生": "R"})
+ assert fixed.panels[0].characters == ["R"]
+ assert fixed.reference_characters == ["R"]
+
+
+def test_build_visual_sheet_noop():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={},
+ )
+ assert build_visual_sheet(bible) is None
+
+
+def test_collect_refs_uses_panel_characters_and_sheet_first():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["R"], "action": "sits"}],
+ "reference_characters": [],
+ }
+ )
+ chars = {"R": CharacterAsset(name="R", portrait_local="/tmp/r.png")}
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={},
+ sheet_ref_local="/tmp/sheet.png",
+ )
+ refs = collect_finished_page_refs(plan, chars, bible, prev_blank="/tmp/prev.png")
+ assert refs[0] == "/tmp/sheet.png"
+ assert "/tmp/r.png" in refs
+
+
+def test_ensure_stage_portrait_assets_creates_portrait_key_rows():
+ state = ProjectState(
+ project_id="p",
+ characters={"陌生女人": CharacterAsset(name="陌生女人", l1_prompt="adult")},
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="x",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "陌生女人": CharacterCanon(
+ canonical_name="陌生女人",
+ face_lock="soft face",
+ stages=[
+ CharacterStage(
+ stage="teen",
+ outfit_lock="school uniform",
+ hair_lock="long dark",
+ portrait_key="陌生女人@teen",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ ensure_stage_portrait_assets(state)
+ assert "陌生女人@teen" in state.characters
+ assert "school uniform" in state.characters["陌生女人@teen"].l1_prompt
+
+
+def test_resolve_character_asset_via_alias():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="x",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="f",
+ aliases=["李先生"],
+ stages=[],
+ )
+ },
+ )
+ chars = {"R": CharacterAsset(name="R", l1_prompt="canonical")}
+ asset = resolve_character_asset("李先生", chars, bible)
+ assert asset is not None
+ assert asset.name == "R"
+
+
+def test_rewrite_pageset_from_bible():
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "x",
+ "layout_intent": "y",
+ "panels": [{"panel_id": "1", "characters": ["李先生"], "action": "stands"}],
+ "reference_characters": ["李先生"],
+ }
+ )
+ pageset = ComicPagePlanSet(unit_id="u1", pages=[plan])
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="x",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ aliases=["李先生"],
+ face_lock="f",
+ stages=[],
+ )
+ },
+ )
+ fixed = rewrite_pageset_from_bible(pageset, bible)
+ assert fixed.pages[0].panels[0].characters == ["R"]
diff --git a/tests/test_visual_bible_prompt.py b/tests/test_visual_bible_prompt.py
new file mode 100644
index 0000000..acbb65e
--- /dev/null
+++ b/tests/test_visual_bible_prompt.py
@@ -0,0 +1,128 @@
+from core.comic.page_prompt import render_finished_page_prompt
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ColorSwatch,
+ ComicPagePlan,
+ VisualBible,
+)
+
+
+def test_finished_page_prompt_injects_color_and_face_lock():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="manhua muted European period",
+ color=ColorBible(
+ palette=[ColorSwatch(name="skin", hex="#E8C4A8", usage="skin")],
+ lighting="soft even cel",
+ forbidden=["neon"],
+ ),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="calm dark eyes",
+ palette_notes="dark suit",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="dark suit jacket",
+ hair_lock="dark short hair",
+ portrait_key="R",
+ )
+ ],
+ )
+ },
+ content_hash="x",
+ )
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "meet",
+ "layout_intent": "two shot",
+ "panels": [{"panel_id": "1", "characters": ["R"], "action": "R sits"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name={"R": CharacterAsset(name="R", l1_prompt="old loose")},
+ settings_by_name={},
+ style_guide="IGNORE_ME_IF_BIBLE",
+ visual_bible=bible,
+ )
+ assert "manhua muted European period" in text
+ assert "#E8C4A8" in text
+ assert "soft even cel" in text
+ assert "neon" in text
+ assert "calm dark eyes" in text
+ assert "dark suit jacket" in text
+ lower = text.lower()
+ assert "do not change hair color" in lower or "unless action says costume change" in lower
+
+
+def test_finished_page_prompt_resolves_alias_to_canonical():
+ bible = VisualBible(
+ version="bible_v1",
+ style_guide="manhua",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="calm dark eyes",
+ aliases=["李先生"],
+ stages=[],
+ )
+ },
+ content_hash="x",
+ )
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "meet",
+ "layout_intent": "two shot",
+ "panels": [{"panel_id": "1", "characters": ["李先生"], "action": "stands"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name={"R": CharacterAsset(name="R", l1_prompt="old loose")},
+ settings_by_name={},
+ visual_bible=bible,
+ )
+ assert "calm dark eyes" in text
+ assert "old loose" not in text
+
+
+def test_prompt_forbids_character_sheets_and_modern_athleisure():
+ bible = VisualBible(
+ version="bible_v2",
+ style_guide="manhua muted European period",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R": CharacterCanon(
+ canonical_name="R",
+ face_lock="calm dark eyes",
+ stages=[],
+ )
+ },
+ content_hash="x",
+ )
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "street",
+ "layout_intent": "two shot",
+ "panels": [{"panel_id": "1", "characters": ["R"], "action": "R walks"}],
+ }
+ )
+ text = render_finished_page_prompt(
+ plan,
+ characters_by_name={"R": CharacterAsset(name="R", l1_prompt="old loose")},
+ settings_by_name={},
+ visual_bible=bible,
+ )
+ lower = text.lower()
+ assert "design sheet" in lower or "turnaround" in lower or "model sheet" in lower
+ assert "hoodie" in lower or "period-accurate" in lower or "period accurate" in lower
+ assert "flashback" in lower or "multiple age" in lower
diff --git a/tests/test_visual_bible_reconcile.py b/tests/test_visual_bible_reconcile.py
new file mode 100644
index 0000000..cb334be
--- /dev/null
+++ b/tests/test_visual_bible_reconcile.py
@@ -0,0 +1,86 @@
+import asyncio
+
+from core.api import ChatProvider
+from core.schemas import CharacterAsset
+from core.screenwriter import reconcile_visual_bible
+
+
+class _FakeChat(ChatProvider):
+ def __init__(self):
+ self.last_user = ""
+
+ async def chat_function_call(self, messages, tools, tool_choice, **kwargs):
+ self.last_user = messages[-1]["content"]
+ return {
+ "merges": [
+ {
+ "alias": "李先生",
+ "canonical": "R",
+ "confidence": "high",
+ "reason": "same protagonist",
+ }
+ ],
+ "stages": [],
+ "keeps": [{"name": "老约翰", "reason": "servant"}],
+ "color_patches": [],
+ "style_guide": "manhua, muted European period",
+ "color": {
+ "palette": [
+ {"name": "ink", "hex": "#1A1A1A", "usage": "lines"},
+ {"name": "skin", "hex": "#E8C4A8", "usage": "skin"},
+ ],
+ "lighting": "soft even cel",
+ "forbidden": ["neon"],
+ },
+ "canons": [
+ {
+ "canonical_name": "R",
+ "aliases": ["李先生"],
+ "face_lock": "handsome man calm eyes",
+ "palette_notes": "dark suit",
+ "role": "writer",
+ "stages": [
+ {
+ "stage": "adult",
+ "outfit_lock": "dark suit",
+ "hair_lock": "dark short hair",
+ "portrait_key": "R",
+ }
+ ],
+ }
+ ],
+ }
+
+
+def test_reconcile_visual_bible_parses_tool_payload():
+ chars = {
+ "R": CharacterAsset(name="R"),
+ "李先生": CharacterAsset(name="李先生"),
+ "老约翰": CharacterAsset(name="老约翰"),
+ }
+ result = asyncio.run(
+ reconcile_visual_bible(
+ "excerpt about R and 李先生",
+ chars,
+ None,
+ alias_hints=[("李先生", "R", "similar")],
+ chat=_FakeChat(),
+ )
+ )
+ assert result.merges[0].alias == "李先生"
+ assert result.style_guide.startswith("manhua")
+ assert result.canons[0].canonical_name == "R"
+
+
+def test_reconcile_visual_bible_includes_preferred_style():
+ chat = _FakeChat()
+ asyncio.run(
+ reconcile_visual_bible(
+ "excerpt",
+ {"R": CharacterAsset(name="R")},
+ None,
+ preferred_style="watercolor manhua",
+ chat=chat,
+ )
+ )
+ assert "preferred_style='watercolor manhua'" in chat.last_user
diff --git a/tests/test_visual_bible_schema.py b/tests/test_visual_bible_schema.py
new file mode 100644
index 0000000..71a83ae
--- /dev/null
+++ b/tests/test_visual_bible_schema.py
@@ -0,0 +1,84 @@
+# tests/test_visual_bible_schema.py
+from core.schemas import ProjectState, VisualBibleReconcileResult
+
+
+def test_visual_bible_round_trip_on_project_state():
+ raw = {
+ "project_id": "p1",
+ "visual_bible": {
+ "version": "bible_v1",
+ "style_guide": "manhua, muted European period tones",
+ "color": {
+ "palette": [
+ {"name": "ink_black", "hex": "#1A1A1A", "usage": "line art"},
+ {"name": "skin_warm", "hex": "#E8C4A8", "usage": "skin"},
+ ],
+ "lighting": "soft even cel lighting",
+ "forbidden": ["neon", "hyper-saturated"],
+ },
+ "characters": {
+ "R": {
+ "canonical_name": "R",
+ "aliases": ["R·", "李先生"],
+ "face_lock": "handsome European man, dark short hair, calm eyes",
+ "palette_notes": "dark suit, white shirt",
+ "role": "writer",
+ "stages": [
+ {
+ "stage": "adult",
+ "appearance": {"hair": "dark short", "outfit_top": "suit"},
+ "outfit_lock": "dark suit jacket, white shirt",
+ "hair_lock": "dark short neat hair",
+ "portrait_key": "R",
+ }
+ ],
+ }
+ },
+ "sheet_ref_local": None,
+ "content_hash": "abc",
+ },
+ }
+ state = ProjectState.model_validate(raw)
+ assert state.visual_bible is not None
+ assert state.visual_bible.characters["R"].aliases == ["R·", "李先生"]
+ assert state.visual_bible.color.palette[0].hex == "#1A1A1A"
+ dumped = state.model_dump()
+ assert dumped["visual_bible"]["version"] == "bible_v1"
+
+
+def test_project_state_loads_without_visual_bible():
+ state = ProjectState.model_validate({"project_id": "old"})
+ assert state.visual_bible is None
+
+
+def test_reconcile_result_schema():
+ result = VisualBibleReconcileResult.model_validate(
+ {
+ "merges": [
+ {
+ "alias": "李先生",
+ "canonical": "R",
+ "confidence": "high",
+ "reason": "same man",
+ }
+ ],
+ "stages": [
+ {
+ "name": "女孩(叙述者)",
+ "stage": "teen",
+ "of_canonical": "陌生女人",
+ "reason": "younger self",
+ }
+ ],
+ "keeps": [{"name": "老约翰", "reason": "servant"}],
+ "color_patches": [],
+ "style_guide": "manhua muted tones",
+ "color": {
+ "palette": [{"name": "ink", "hex": "#111111", "usage": "lines"}],
+ "lighting": "soft",
+ "forbidden": ["neon"],
+ },
+ "canons": [],
+ }
+ )
+ assert result.merges[0].confidence == "high"
diff --git a/tests/test_visual_bible_v2.py b/tests/test_visual_bible_v2.py
new file mode 100644
index 0000000..2b8e200
--- /dev/null
+++ b/tests/test_visual_bible_v2.py
@@ -0,0 +1,263 @@
+from core.comic.visual_bible import (
+ DEFAULT_OUTFIT_LOCK,
+ apply_reconcile,
+ ensure_canon_locks,
+ is_illegal_character_name,
+ roles_incompatible,
+ sanitize_visual_bible_state,
+)
+from core.schemas import (
+ CharacterAsset,
+ CharacterCanon,
+ CharacterStage,
+ ColorBible,
+ ProjectState,
+ VisualBible,
+ VisualBibleMerge,
+ VisualBibleReconcileResult,
+)
+
+
+def test_illegal_english_prose_name():
+ assert is_illegal_character_name(
+ "41-year-old Viennese novelist, athletic elegant build, glossy dark hair"
+ )
+ assert not is_illegal_character_name("R(小说家)")
+ assert not is_illegal_character_name("老约翰")
+
+
+def test_roles_incompatible_mother_daughter_and_count_novelist():
+ assert roles_incompatible("女主角母亲,寡妇", "女主角,信件叙述者")
+ assert roles_incompatible("帝国伯爵,情人", "著名小说家")
+ assert not roles_incompatible("著名小说家", "男主角作家")
+
+
+def test_apply_reconcile_demotes_incompatible_high_merge():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R(小说家)": CharacterAsset(name="R(小说家)", role="著名小说家"),
+ "帝国伯爵": CharacterAsset(name="帝国伯爵", role="帝国伯爵,年长情人"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="period vienna",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R(小说家)": CharacterCanon(
+ canonical_name="R(小说家)",
+ role="著名小说家",
+ face_lock="handsome face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="suit",
+ hair_lock="dark hair",
+ portrait_key="R(小说家)",
+ )
+ ],
+ )
+ },
+ ),
+ )
+ out = apply_reconcile(
+ state,
+ VisualBibleReconcileResult(
+ merges=[
+ VisualBibleMerge(
+ alias="帝国伯爵",
+ canonical="R(小说家)",
+ confidence="high",
+ reason="wrong",
+ )
+ ]
+ ),
+ )
+ assert "帝国伯爵" in out.characters
+ assert any(s.new_name == "帝国伯爵" for s in out.needs_review)
+
+
+def test_ensure_canon_locks_fills_empty_and_strips_outfit_from_face():
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="handsome face, wearing athletic hoodie",
+ stages=[CharacterStage(stage="default", outfit_lock="", hair_lock="", portrait_key="R")],
+ )
+ fixed = ensure_canon_locks(canon)
+ assert "hoodie" not in fixed.face_lock.lower()
+ assert fixed.stages[0].hair_lock == "dark hair"
+ assert fixed.stages[0].outfit_lock == DEFAULT_OUTFIT_LOCK
+
+
+def test_ensure_canon_locks_outfit_not_style_guide():
+ style_guide = "Manhua/comic style: clean black ink line art, soft cel shading, flat colors"
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="handsome face",
+ stages=[
+ CharacterStage(stage="default", outfit_lock="", hair_lock="dark hair", portrait_key="R")
+ ],
+ )
+ fixed = ensure_canon_locks(canon)
+ assert fixed.stages[0].outfit_lock == DEFAULT_OUTFIT_LOCK
+ assert "Manhua" not in fixed.stages[0].outfit_lock
+ assert style_guide not in fixed.stages[0].outfit_lock
+
+
+def test_ensure_canon_locks_repairs_illegal_and_empty_portrait_key():
+ prose = "41-year-old Viennese novelist, athletic elegant build, glossy dark hair"
+ canon = CharacterCanon(
+ canonical_name="R(小说家)",
+ face_lock="handsome face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ outfit_lock="suit",
+ hair_lock="dark hair",
+ portrait_key="",
+ ),
+ CharacterStage(
+ stage="teen",
+ outfit_lock="school",
+ hair_lock="dark hair",
+ portrait_key=prose,
+ ),
+ ],
+ )
+ fixed = ensure_canon_locks(canon)
+ assert fixed.stages[0].portrait_key == "R(小说家)@adult"
+ assert fixed.stages[1].portrait_key == "R(小说家)@teen"
+
+
+def test_ensure_canon_locks_derives_hair_lock_from_canon_face():
+ canon = CharacterCanon(
+ canonical_name="R",
+ face_lock="glossy dark hair, handsome face",
+ stages=[CharacterStage(stage="default", outfit_lock="", hair_lock="", portrait_key="R")],
+ )
+ fixed = ensure_canon_locks(canon)
+ assert fixed.stages[0].hair_lock == "glossy dark hair"
+
+
+def test_sanitize_drops_stranger_woman_alias_from_mother():
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "陌生女人的母亲": CharacterAsset(
+ name="陌生女人的母亲",
+ role="女主角母亲,寡妇",
+ aliases=["寡妇", "陌生女人"],
+ ),
+ "陌生女人(信中叙述者)": CharacterAsset(
+ name="陌生女人(信中叙述者)",
+ role="女主角,信件叙述者",
+ aliases=["陌生女人"],
+ ),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="Manhua/comic style: clean black ink line art",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "陌生女人(信中叙述者)": CharacterCanon(
+ canonical_name="陌生女人(信中叙述者)",
+ role="女主角,信件叙述者",
+ aliases=["陌生女人"],
+ face_lock="pale fragile beauty",
+ stages=[
+ CharacterStage(
+ stage="default",
+ outfit_lock="simple dress",
+ hair_lock="dark hair",
+ portrait_key="陌生女人(信中叙述者)",
+ )
+ ],
+ ),
+ "陌生女人的母亲": CharacterCanon(
+ canonical_name="陌生女人的母亲",
+ role="女主角母亲,寡妇",
+ aliases=["寡妇", "陌生女人"],
+ face_lock="thin somber face",
+ stages=[
+ CharacterStage(
+ stage="default",
+ outfit_lock="black mourning clothes",
+ hair_lock="dark hair",
+ portrait_key="陌生女人的母亲",
+ )
+ ],
+ ),
+ },
+ ),
+ )
+ assert sanitize_visual_bible_state(state) is True
+ mother_asset = state.characters["陌生女人的母亲"]
+ mother_canon = state.visual_bible.characters["陌生女人的母亲"]
+ assert "陌生女人" not in mother_asset.aliases
+ assert "陌生女人" not in mother_canon.aliases
+
+
+def test_sanitize_removes_prose_character_and_bad_alias():
+ prose = "41-year-old Viennese novelist, athletic elegant build, glossy dark hair"
+ state = ProjectState(
+ project_id="p",
+ characters={
+ "R(小说家)": CharacterAsset(
+ name="R(小说家)",
+ role="小说家",
+ aliases=["帝国伯爵", prose],
+ ),
+ prose: CharacterAsset(name=prose, role="小说家"),
+ "帝国伯爵": CharacterAsset(name="帝国伯爵", role="伯爵情人"),
+ },
+ visual_bible=VisualBible(
+ version="bible_v1",
+ style_guide="vienna",
+ color=ColorBible(palette=[], lighting="", forbidden=[]),
+ characters={
+ "R(小说家)": CharacterCanon(
+ canonical_name="R(小说家)",
+ role="小说家",
+ aliases=["帝国伯爵", prose],
+ face_lock="face",
+ stages=[
+ CharacterStage(
+ stage="adult",
+ hair_lock="",
+ outfit_lock="",
+ portrait_key=prose,
+ )
+ ],
+ )
+ },
+ ),
+ )
+ assert sanitize_visual_bible_state(state) is True
+ assert prose not in state.characters
+ assert state.visual_bible.version == "bible_v2"
+ assert "帝国伯爵" not in state.visual_bible.characters["R(小说家)"].aliases
+ assert state.visual_bible.characters["R(小说家)"].stages[0].portrait_key.startswith("R")
+
+
+def test_backfill_panel_characters_from_action_and_refs():
+ from core.comic.visual_bible import backfill_panel_characters
+ from core.schemas import ComicPagePlan
+
+ plan = ComicPagePlan.model_validate(
+ {
+ "page_id": "p1",
+ "purpose": "海滩",
+ "layout_intent": "三格",
+ "panels": [
+ {
+ "panel_id": "1",
+ "characters": [],
+ "action": "女人牵着金发男孩在海滩散步",
+ }
+ ],
+ "reference_characters": ["陌生女人(信中叙述者)", "死去的儿子"],
+ }
+ )
+ fixed = backfill_panel_characters(plan, ["陌生女人(信中叙述者)", "死去的儿子", "R(小说家)"])
+ assert "陌生女人(信中叙述者)" in fixed.panels[0].characters
+ assert "死去的儿子" in fixed.panels[0].characters