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.

How it works @@ -68,7 +68,7 @@ Inkstone is an **independent implementation, not a fork** — inspired by [`lcy3 Pipeline: Split → Extract → Board → Paint → 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 (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