diff --git a/core/comic/key_beats.py b/core/comic/key_beats.py new file mode 100644 index 0000000..b431916 --- /dev/null +++ b/core/comic/key_beats.py @@ -0,0 +1,49 @@ +"""Key-beat coverage helpers for finished-page planning.""" + +from __future__ import annotations + +from core.schemas import ComicPagePlan, ComicPagePlanSet, KeyBeat, KeyBeatSet + + +def uncovered_must_draw_beats(beats: KeyBeatSet, pageset: ComicPagePlanSet) -> list[KeyBeat]: + """Return must_draw beats not listed in any page's covers_beats.""" + covered: set[str] = set() + for page in pageset.pages: + for beat_id in page.covers_beats or []: + if beat_id: + covered.add(beat_id) + return [ + beat + for beat in beats.beats + if beat.must_draw and beat.beat_id and beat.beat_id not in covered + ] + + +def beat_coverage_retry_note(uncovered: list[KeyBeat]) -> str: + """User-message appendix asking the planner to stage uncovered beats.""" + if not uncovered: + return "" + lines = [ + "CRITICAL: these must_draw beats were not covered. Stage each as drawable " + "panels/pages (physical action + environment), not caption-only, and set " + "covers_beats on the covering page:" + ] + for beat in uncovered[:12]: + lines.append( + f"- {beat.beat_id}: {beat.summary}" + + (f" (chars: {', '.join(beat.characters)})" if beat.characters else "") + ) + return "\n".join(lines) + + +def covers_beats_prompt_line(plan: ComicPagePlan) -> str | None: + """Prompt line requiring physical staging for covered beats.""" + ids = [b for b in (plan.covers_beats or []) if b] + if not ids: + return None + return ( + "This page covers key beats: " + + ", ".join(ids) + + ". Depict them as physical staged scenes (body + environment), " + "not as a character merely standing and holding a letter." + ) diff --git a/core/comic/layout_diversity.py b/core/comic/layout_diversity.py new file mode 100644 index 0000000..cc221fb --- /dev/null +++ b/core/comic/layout_diversity.py @@ -0,0 +1,82 @@ +"""Layout diversity helpers for finished-page anti-template planning.""" + +from __future__ import annotations + +from collections.abc import Iterable + +LAYOUT_CATALOG: frozenset[str] = frozenset( + { + "splash_action", + "dialogue_grid", + "inset_memory", + "widescreen_scene", + "diagonal_motion", + "crowd_establishing", + "object_closeup", + "over_shoulder", + "split_timeline", + "environmental_wide", + } +) + +ANTI_CENTER_STANDEE_LINE = ( + "Avoid repeating a centered full-body standing hero (especially holding a letter " + "or book) as the page focus; prefer environmental staging, action blocking, and " + "varied shots unless layout_intent is explicitly splash_action." +) + + +def normalize_layout_intent(intent: str) -> str: + """Return the catalog token if intent starts with/contains one; else stripped text.""" + text = (intent or "").strip() + if not text: + return "" + lower = text.casefold() + for token in sorted(LAYOUT_CATALOG, key=len, reverse=True): + if lower == token or lower.startswith(token + " ") or lower.startswith(token + ":"): + return token + if token in lower.split()[0:1]: + return token + # allow "splash_action — fight in the rain" + first = lower.replace("—", " ").replace("-", " ").split()[0] + if first in LAYOUT_CATALOG: + return first + return text + + +def consecutive_layout_streak(intents: Iterable[str]) -> int: + """Length of the trailing run of identical normalized layout intents.""" + normalized = [normalize_layout_intent(i) for i in intents if normalize_layout_intent(i)] + if not normalized: + return 0 + last = normalized[-1] + streak = 0 + for intent in reversed(normalized): + if intent == last: + streak += 1 + else: + break + return streak + + +def summarize_recent_layouts(intents: Iterable[str], *, limit: int = 5) -> str: + """Human-readable recent layout_intent list for planner context.""" + items = [normalize_layout_intent(i) or (i or "").strip() for i in intents] + items = [i for i in items if i][-limit:] + if not items: + return "(none)" + return ", ".join(items) + + +def layout_diversity_instructions(recent_layouts: list[str] | None) -> str: + """Instructions block injected into plan_comic_pages user message.""" + catalog = ", ".join(sorted(LAYOUT_CATALOG)) + recent = summarize_recent_layouts(recent_layouts or []) + return ( + "Layout diversity rules:\n" + f"- Prefer layout_intent tokens from this catalog (then free detail): {catalog}.\n" + f"- Recent layout_intent values in this project/chunk: {recent}.\n" + "- Do NOT reuse the same layout_intent as the immediately previous page.\n" + "- Avoid consecutive full-body standing hero / letter-holding standee pages; " + "stage environment and action instead.\n" + ) diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py index 4075f23..25dcd0a 100644 --- a/core/comic/page_prompt.py +++ b/core/comic/page_prompt.py @@ -10,11 +10,14 @@ metaphor_identity_lock_line, metaphor_names_on_page, ) +from core.comic.key_beats import covers_beats_prompt_line +from core.comic.layout_diversity import ANTI_CENTER_STANDEE_LINE from core.comic.visual_bible import ( ANTI_CHARACTER_SHEET_LINE, ANTI_MULTI_AGE_COLLAGE_LINE, COSTUME_CHANGE_LOCK_LINE, DIEGETIC_TEXT_LINE, + HAIR_STABILITY_LINE, format_color_bible_block, format_identity_line, l1_from_canon, @@ -23,6 +26,7 @@ resolve_character_asset, wardrobe_banline_for_bible, ) +from core.comic.voice import timeline_prompt_lines from core.schemas import CharacterAsset, ComicPagePlan, Setting, VisualBible @@ -100,8 +104,15 @@ def render_finished_page_prompt( lines.append(ANTI_CHARACTER_SHEET_LINE) lines.append(wardrobe_banline_for_bible(visual_bible)) lines.append(ANTI_MULTI_AGE_COLLAGE_LINE) + lines.append(HAIR_STABILITY_LINE) + lines.append(ANTI_CENTER_STANDEE_LINE) lines.append(f"Page purpose: {plan.purpose}") lines.append(f"Layout intent: {plan.layout_intent}") + beat_line = covers_beats_prompt_line(plan) + if beat_line: + lines.append(beat_line) + for line in timeline_prompt_lines(getattr(plan, "timeline", "") or ""): + lines.append(line) metaphor_names = metaphor_names_on_page(plan, characters_by_name) if metaphor_names: lines.append( @@ -113,6 +124,13 @@ def render_finished_page_prompt( f"Panel {i} ({panel.panel_id}): role={panel.role}, shape={panel.shape_hint}, " f"shot={panel.shot}, action={panel.action}" ) + panel_tl = getattr(panel, "timeline", "") or "" + if panel_tl: + for line in timeline_prompt_lines(panel_tl): + lines.append(f" {line}") + speaker = getattr(panel, "speaker", "") or "" + if speaker: + lines.append(f" speaker={speaker}") if panel.setting_ref: setting = settings_by_name.get(panel.setting_ref) scene = getattr(setting, "scene_prompt", "") if setting else "" diff --git a/core/comic/visual_bible.py b/core/comic/visual_bible.py index 0460bfe..62ec112 100644 --- a/core/comic/visual_bible.py +++ b/core/comic/visual_bible.py @@ -56,6 +56,115 @@ "layout_intent explicitly calls for a flashback split." ) +HAIR_STABILITY_LINE = ( + "Keep hair color and hair length stable for each locked identity across panels " + "unless the character stage explicitly changes." +) + +# Generic age/stage cues (CN + EN) → CharacterStageLiteral. Order matters: first match wins. +_STAGE_CUE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "child", + re.compile( + r"(童年|孩童|小孩|幼年|十三|12岁|13岁|少女时代|as a child|childhood|\bchild\b)", + re.IGNORECASE, + ), + ), + ( + "teen", + re.compile( + r"(少年|十六|17岁|18岁临别|teenager|boarding school|\bteen\b)", + re.IGNORECASE, + ), + ), + ( + "elder", + re.compile(r"(老年|暮年|白发苍苍|\belderly\b|\bold man\b|\bold woman\b)", re.IGNORECASE), + ), + ( + "adult", + re.compile( + r"(临终|写信|成年|交际花|丧子|成人|\badult\b|dying|deathbed)", + re.IGNORECASE, + ), + ), +) + + +def default_age_look_for_stage(stage: str) -> str: + """Soft age_look default from stage literal (not novel-specific).""" + mapping = { + "child": "about 10–13 years old, clearly a child", + "teen": "about 16–18 years old, adolescent", + "adult": "adult, roughly late twenties to forties", + "elder": "elderly, visibly aged", + "default": "age matching the story role", + } + return mapping.get(stage, mapping["default"]) + + +def infer_stage_from_text(text: str) -> str | None: + """Infer a stage literal from action/purpose/caption cues, or None.""" + blob = text or "" + if not blob.strip(): + return None + for stage, pattern in _STAGE_CUE_PATTERNS: + if pattern.search(blob): + return stage + return None + + +def _stage_ref_name(canonical: str, stage: str) -> str: + return f"{canonical}@{stage}" + + +def _canon_has_stage(canon: CharacterCanon, stage: str) -> bool: + return any(s.stage == stage for s in canon.stages) + + +def _rewrite_name_to_stage(name: str, stage: str, bible: VisualBible) -> str: + """Rewrite bare canonical/alias to Name@stage when that stage exists.""" + base, existing = parse_stage_ref(name) + if existing != "default" and "@" in (name or ""): + return name # already staged + canonical = resolve_canonical_name(base, bible) + canon = bible.characters.get(canonical) + if canon is None or not _canon_has_stage(canon, stage): + return name + return _stage_ref_name(canonical, stage) + + +def resolve_panel_stage_refs(plan: ComicPagePlan, bible: VisualBible) -> ComicPagePlan: + """Rewrite bare character names to Name@stage using age cues in page/panel text.""" + updated = plan.model_copy(deep=True) + page_cue = " ".join( + part + for part in ( + updated.purpose or "", + updated.layout_intent or "", + " ".join(p.action or "" for p in updated.panels), + " ".join(p.caption or "" for p in updated.panels), + ) + if part + ) + page_stage = infer_stage_from_text(page_cue) + + for panel in updated.panels: + panel_cue = " ".join( + part for part in (panel.action or "", panel.caption or "", panel.dialogue or "") if part + ) + stage = infer_stage_from_text(panel_cue) or page_stage + if not stage: + continue + panel.characters = [_rewrite_name_to_stage(name, stage, bible) for name in panel.characters] + + if page_stage: + updated.reference_characters = [ + _rewrite_name_to_stage(name, page_stage, bible) for name in updated.reference_characters + ] + return updated + + GENDER_NO_SWAP_LINE = ( "single human matching locked gender exactly; no gender swap or androgynous " "reinterpretation of a gendered canon" @@ -495,11 +604,13 @@ def ensure_stage_locks( 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}" + age_look = (stage.age_look or "").strip() or default_age_look_for_stage(stage.stage) return CharacterStage( stage=stage.stage, appearance=stage.appearance, outfit_lock=outfit_lock, hair_lock=hair_lock, + age_look=age_look, portrait_key=portrait_key, ) @@ -723,6 +834,7 @@ def _bible_hash_payload(bible: VisualBible) -> dict: "stage": stage.stage, "outfit_lock": stage.outfit_lock, "hair_lock": stage.hair_lock, + "age_look": stage.age_look, } for stage in canon.stages ] @@ -805,6 +917,7 @@ def _upsert_canon(existing: CharacterCanon, incoming: CharacterCanon) -> Charact stage=stage.stage, outfit_lock=stage.outfit_lock or old.outfit_lock, hair_lock=stage.hair_lock or old.hair_lock, + age_look=stage.age_look or old.age_look, portrait_key=stage.portrait_key or old.portrait_key, ) else: @@ -1146,6 +1259,8 @@ def l1_from_canon(canon: CharacterCanon, stage: str = "default") -> str: if stage_row is None and canon.stages: stage_row = canon.stages[0] if stage_row is not None: + if stage_row.age_look: + parts.append(stage_row.age_look) if stage_row.outfit_lock: parts.append(stage_row.outfit_lock) if stage_row.hair_lock: diff --git a/core/comic/voice.py b/core/comic/voice.py new file mode 100644 index 0000000..b8170d8 --- /dev/null +++ b/core/comic/voice.py @@ -0,0 +1,146 @@ +"""Voice and timeline helpers for finished-page lettering attribution.""" + +from __future__ import annotations + +import re + +from core.comic.visual_bible import parse_stage_ref, resolve_canonical_name +from core.schemas import ComicPagePlan, PagePanelSpec, VisualBible + +_FIRST_PERSON_LETTER_RE = re.compile( + r"(我的儿子|我从来|请你相信我|你从来也没有认识过我|我爱你|昨天死了|" + r"\bmy son\b|\bi love you\b|you never knew me)", + re.IGNORECASE, +) + +TIMELINE_PRESENT_LINE = ( + "Timeline=present: cool static reading-frame atmosphere; calm interior; " + "focus on the letter-reader now." +) +TIMELINE_PAST_LINE = ( + "Timeline=past: warmer memory staging with slight film grain; depict lived scenes " + "in environment — do not default to letter-holding unless action requires it." +) +TIMELINE_LIMINAL_LINE = ( + "Timeline=liminal: bridge present and memory; keep visual transition cues clear." +) + + +def letter_role_names(bible: VisualBible | None) -> tuple[set[str], set[str]]: + """Return (reader_names, writer_names) including aliases and stage refs.""" + readers: set[str] = set() + writers: set[str] = set() + if bible is None: + return readers, writers + for key, canon in bible.characters.items(): + names = {key, canon.canonical_name, *canon.aliases} + for stage in canon.stages: + if stage.portrait_key: + names.add(stage.portrait_key) + names.add(f"{canon.canonical_name}@{stage.stage}") + fn = (canon.narrative_function or "").strip() + if fn == "letter_reader": + readers |= {n for n in names if n} + elif fn == "letter_writer": + writers |= {n for n in names if n} + return readers, writers + + +def _base_names(names: list[str], bible: VisualBible | None) -> set[str]: + out: set[str] = set() + for name in names: + base, _ = parse_stage_ref(name) + if bible is not None: + base = resolve_canonical_name(base, bible) + if base: + out.add(base) + out.add(name) + return out + + +def looks_like_letter_narration(text: str | None) -> bool: + """Heuristic: first-person confession / letter voice.""" + if not text: + return False + return bool(_FIRST_PERSON_LETTER_RE.search(text)) + + +def sanitize_panel_voice( + panel: PagePanelSpec, + bible: VisualBible | None, + *, + page_timeline: str = "", +) -> PagePanelSpec: + """Move mis-attributed letter narration from dialogue into caption.""" + updated = panel.model_copy(deep=True) + if not updated.timeline and page_timeline: + updated.timeline = page_timeline # type: ignore[assignment] + readers, writers = letter_role_names(bible) + on_panel = _base_names(list(updated.characters), bible) + reader_only = bool(on_panel) and on_panel.issubset(readers) and not (on_panel & writers) + dialogue = updated.dialogue + if dialogue and looks_like_letter_narration(dialogue) and reader_only: + # Letter-writer I-voice must not be the reader's spoken bubble. + caption = (updated.caption or "").strip() + moved = dialogue.strip() + updated.caption = f"{caption}\n{moved}".strip() if caption else moved + updated.dialogue = None + updated.speaker = "" + # Drop dialogue lettering boxes for this panel; caption boxes kept/added by planner. + return updated + speaker = (updated.speaker or "").strip() + if speaker and bible is not None: + base, _ = parse_stage_ref(speaker) + updated.speaker = resolve_canonical_name(base, bible) + return updated + + +def sanitize_plan_voice(plan: ComicPagePlan, bible: VisualBible | None) -> ComicPagePlan: + """Sanitize every panel's voice attribution on a page plan.""" + updated = plan.model_copy(deep=True) + page_tl = (updated.timeline or "").strip() + new_panels: list[PagePanelSpec] = [] + dialogue_panels_cleared: set[str] = set() + for panel in updated.panels: + before = panel.dialogue + fixed = sanitize_panel_voice(panel, bible, page_timeline=page_tl) + if before and not fixed.dialogue: + dialogue_panels_cleared.add(panel.panel_id) + new_panels.append(fixed) + updated.panels = new_panels + if dialogue_panels_cleared: + updated.lettering_boxes = [ + box + for box in updated.lettering_boxes + if not (box.kind == "dialogue" and box.panel_id in dialogue_panels_cleared) + ] + return updated + + +def timeline_prompt_lines(timeline: str) -> list[str]: + """Visual grammar lines for a timeline value.""" + value = (timeline or "").strip().casefold() + if value == "present": + return [TIMELINE_PRESENT_LINE] + if value == "past": + return [TIMELINE_PAST_LINE] + if value == "liminal": + return [TIMELINE_LIMINAL_LINE] + return [] + + +def voice_timeline_plan_instructions(bible: VisualBible | None) -> str: + """Extra planner instructions for speaker + timeline fields.""" + readers, writers = letter_role_names(bible) + reader_s = ", ".join(sorted(readers)[:6]) or "(none marked)" + writer_s = ", ".join(sorted(writers)[:6]) or "(none marked)" + return ( + "Voice and timeline rules:\n" + "- Set page timeline to present | past | liminal when clear " + "(present = reading-the-letter frame; past = remembered life events).\n" + "- Panels may override timeline.\n" + "- Narration / letter first-person / time-place → caption with empty speaker.\n" + "- Spoken lines → dialogue with speaker = speaking character name.\n" + f"- letter_reader canons: {reader_s}. letter_writer canons: {writer_s}.\n" + "- Never put letter_writer first-person confession into letter_reader dialogue bubbles.\n" + ) diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index 27a0bb7..284282e 100644 --- a/core/pipelines/creative_comic.py +++ b/core/pipelines/creative_comic.py @@ -53,6 +53,7 @@ merge_settings, suggestion_from_alias, ) +from core.comic.key_beats import beat_coverage_retry_note, uncovered_must_draw_beats 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 @@ -68,10 +69,12 @@ refresh_bible_hash, resolve_canonical_name, resolve_character_asset, + resolve_panel_stage_refs, rewrite_pageset_from_bible, sanitize_visual_bible_state, sync_characters_from_bible, ) +from core.comic.voice import sanitize_plan_voice 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 @@ -90,6 +93,7 @@ StoryElements, ) from core.screenwriter import ( + extract_key_beats, extract_story_elements, is_content_policy_rejection, plan_comic_pages, @@ -162,6 +166,20 @@ def _known_character_names(state: ProjectState) -> list[str]: return names +def _recent_layout_intents(state: ProjectState, *, limit: int = 8) -> list[str]: + """Collect recent page layout_intent strings from cached page plans.""" + intents: list[str] = [] + for cache_key in sorted(state.page_cache.keys()): + pageset = state.page_cache.get(cache_key) + if pageset is None: + continue + for plan in pageset.pages: + intent = (plan.layout_intent or "").strip() + if intent: + intents.append(intent) + return intents[-limit:] + + def _render_fingerprint( style_guide: str | None, *, @@ -181,6 +199,10 @@ def _render_fingerprint( "render_mode": render_mode, "page_size": page_size, "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", } if render_mode == "finished_page": fp_payload["lettering"] = "deferred_v3" @@ -1109,7 +1131,42 @@ async def _render_portrait( state.stage = "page_plan" try: with perf.measure("page_plan"): - pageset = await plan_comic_pages(chunk, elements, chat=chat) + beats = state.beat_cache.get(key) + if beats is None: + try: + beats = await extract_key_beats(chunk, elements, chat=chat) + state.beat_cache[key] = beats + except Exception as beat_exc: # noqa: BLE001 + logger.warning( + "chunk %s key-beat extract failed (%s); continuing", + ci, + beat_exc, + ) + beats = None + pageset = await plan_comic_pages( + chunk, + elements, + chat=chat, + recent_layouts=_recent_layout_intents(state), + visual_bible=state.visual_bible, + key_beats=beats, + ) + if beats is not None: + missing = uncovered_must_draw_beats(beats, pageset) + if missing: + logger.warning( + "chunk %s uncovered must_draw beats; retrying page plan once", + ci, + ) + pageset = await plan_comic_pages( + chunk, + elements, + chat=chat, + recent_layouts=_recent_layout_intents(state), + visual_bible=state.visual_bible, + key_beats=beats, + extra_user_note=beat_coverage_retry_note(missing), + ) except Exception as exc: # noqa: BLE001 — content rejections must not abort the run if is_content_policy_rejection(exc): logger.warning( @@ -1151,6 +1208,10 @@ async def _render_portrait( for page_index, plan in enumerate(pageset.pages): if state.visual_bible is not None: plan = backfill_panel_characters(plan, _known_character_names(state)) + plan = resolve_panel_stage_refs(plan, state.visual_bible) + plan = sanitize_plan_voice(plan, state.visual_bible) + else: + plan = sanitize_plan_voice(plan, None) page_id = plan.page_id state_key = _page_state_key(ci, page_id) existing = state.generated.pages.get(state_key) diff --git a/core/schemas.py b/core/schemas.py index e354b4c..6562c4c 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -690,6 +690,8 @@ class PagePanelSpec(BaseModel): caption: str | None = None sfx: str | None = None lettering_notes: str = "" + speaker: str = "" + timeline: Literal["present", "past", "liminal", ""] = "" @model_validator(mode="before") @classmethod @@ -711,6 +713,8 @@ def _ensure_panel_id(cls, value: Any) -> Any: "caption", "sfx", "lettering_notes", + "speaker", + "timeline", }, stash_value_into="action", ) @@ -729,12 +733,21 @@ def _ensure_panel_id(cls, value: Any) -> Any: "action", "setting_ref", "lettering_notes", + "speaker", mode="before", ) @classmethod def _coerce_text(cls, value: Any) -> Any: return coerce_str(value) + @field_validator("timeline", mode="before") + @classmethod + def _coerce_timeline(cls, value: Any) -> Any: + text = coerce_str(value).strip().casefold() + if text in {"present", "past", "liminal"}: + return text + return "" + @field_validator("characters", mode="before") @classmethod def _coerce_characters(cls, value: Any) -> Any: @@ -783,10 +796,12 @@ class ComicPagePlan(BaseModel): page_id: str purpose: str = "" layout_intent: str = "" + timeline: Literal["present", "past", "liminal", ""] = "" 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) + covers_beats: list[str] = Field(default_factory=list) @model_validator(mode="before") @classmethod @@ -797,7 +812,7 @@ def _ensure_page_id(cls, value: Any) -> Any: return value out = repair_fused_keys( value, - {"page_id", "purpose", "layout_intent"}, + {"page_id", "purpose", "layout_intent", "timeline"}, stash_value_into="purpose", ) return ensure_str_field( @@ -812,6 +827,14 @@ def _ensure_page_id(cls, value: Any) -> Any: def _coerce_text(cls, value: Any) -> Any: return coerce_str(value) + @field_validator("timeline", mode="before") + @classmethod + def _coerce_timeline(cls, value: Any) -> Any: + text = coerce_str(value).strip().casefold() + if text in {"present", "past", "liminal"}: + return text + return "" + @field_validator("panels", mode="before") @classmethod def _coerce_panels(cls, value: Any) -> Any: @@ -822,12 +845,47 @@ def _coerce_panels(cls, value: Any) -> Any: def _coerce_lettering_boxes(cls, value: Any) -> Any: return coerce_model_list(value, LetteringBox) - @field_validator("reference_characters", "setting_refs", mode="before") + @field_validator("reference_characters", "setting_refs", "covers_beats", mode="before") @classmethod def _coerce_name_lists(cls, value: Any) -> Any: return coerce_str_list(value) +class KeyBeat(BaseModel): + """A dramatizable turning point that should appear as drawable scene(s).""" + + model_config = ConfigDict(extra="ignore") + + beat_id: str + summary: str = "" + must_draw: bool = True + characters: list[str] = Field(default_factory=list) + setting_hint: str = "" + + @field_validator("beat_id", "summary", "setting_hint", mode="before") + @classmethod + def _coerce_text(cls, value: Any) -> Any: + return coerce_str(value) + + @field_validator("characters", mode="before") + @classmethod + def _coerce_characters(cls, value: Any) -> Any: + return coerce_str_list(value) + + +class KeyBeatSet(BaseModel): + """Key beats extracted for one chunk or project window.""" + + model_config = ConfigDict(extra="ignore") + + beats: list[KeyBeat] = Field(default_factory=list) + + @field_validator("beats", mode="before") + @classmethod + def _coerce_beats(cls, value: Any) -> Any: + return coerce_model_list(value, KeyBeat) + + class ComicPagePlanSet(BaseModel): """Finished-page plans for one storyboard chunk (unit).""" @@ -1081,6 +1139,7 @@ class CharacterStage(BaseModel): appearance: Appearance = Field(default_factory=Appearance) outfit_lock: str = "" hair_lock: str = "" + age_look: str = "" portrait_key: str = "" @field_validator("appearance", mode="before") @@ -1093,7 +1152,7 @@ def _coerce_appearance(cls, value: Any) -> Any: return {"distinguishing": value} return value - @field_validator("outfit_lock", "hair_lock", "portrait_key", mode="before") + @field_validator("outfit_lock", "hair_lock", "age_look", "portrait_key", mode="before") @classmethod def _coerce_text_fields(cls, value: Any) -> Any: return coerce_str(value) @@ -1335,6 +1394,7 @@ class ProjectState(BaseModel): chunk_cache: dict[str, ChunkCache] = Field(default_factory=dict) render_mode: RenderMode = "finished_page" page_cache: dict[str, ComicPagePlanSet] = Field(default_factory=dict) + beat_cache: dict[str, KeyBeatSet] = Field(default_factory=dict) pages_done: list[str] = Field(default_factory=list) stale_pages: list[str] = Field(default_factory=list) skipped_pages: list[str] = Field(default_factory=list) diff --git a/core/screenwriter.py b/core/screenwriter.py index 8a26c14..0058adb 100644 --- a/core/screenwriter.py +++ b/core/screenwriter.py @@ -22,6 +22,7 @@ ) from core.schemas import ( ComicPagePlanSet, + KeyBeatSet, PageScript, Storyboard, StoryElements, @@ -68,7 +69,14 @@ "plan_comic_pages", "Plan finished comic pages for one text unit: per-page purpose, " "dynamic layout_intent, panel specs with source-language lettering, and " - "lettering_boxes as normalized 0-1 page rectangles.", + "lettering_boxes as normalized 0-1 page rectangles. " + "When key beats are provided, set covers_beats on pages that stage them.", +) +KEY_BEATS_TOOL = to_tool_schema( + KeyBeatSet, + "extract_key_beats", + "Extract dramatizable turning points (key beats) that must be shown as " + "drawable comic scenes, not caption-only summaries. Cap at about 12 beats.", ) RECONCILE_BIBLE_TOOL = to_tool_schema( VisualBibleReconcileResult, @@ -159,8 +167,44 @@ async def plan_storyboard(text: str, elements: StoryElements, *, chat=None) -> S return Storyboard.model_validate(args) -async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> ComicPagePlanSet: +async def extract_key_beats(text: str, elements: StoryElements, *, chat=None) -> KeyBeatSet: + """Extract must-draw dramatizable beats for a text unit.""" + chat = chat or get_chat_provider() + user = ( + f"{sanitize_text(text)}\n\n" + f"Known elements:\n{elements.model_dump_json()}\n\n" + "Extract key beats: dramatizable turning points that a comic must SHOW " + "(environment + body action), not only narrate in captions. " + "Prefer 4–12 beats. Each beat needs beat_id (short slug), summary, " + "must_draw=true for critical ones, optional characters and setting_hint. " + "Do not invent novel-specific fixed lists — derive from this excerpt only." + ) + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user}, + ] + args = await chat.chat_function_call( + messages, + [KEY_BEATS_TOOL], + _tool_choice("extract_key_beats"), + ) + return KeyBeatSet.model_validate(args) + + +async def plan_comic_pages( + text: str, + elements: StoryElements, + *, + chat=None, + recent_layouts: list[str] | None = None, + visual_bible=None, + key_beats: KeyBeatSet | None = None, + extra_user_note: str = "", +) -> ComicPagePlanSet: """Plan finished readable pages for ``text`` given ``elements``.""" + from core.comic.layout_diversity import layout_diversity_instructions + from core.comic.voice import voice_timeline_plan_instructions + chat = chat or get_chat_provider() script = source_lettering_script(text) lang_reminder = ( @@ -173,12 +217,25 @@ async def plan_comic_pages(text: str, elements: StoryElements, *, chat=None) -> "Also emit lettering_boxes: normalized 0-1 page rectangles " "(kind, panel_id, x, y, w, h) for every non-null lettering field." ) + diversity = layout_diversity_instructions(recent_layouts) + voice = voice_timeline_plan_instructions(visual_bible) + beats_blob = "" + if key_beats is not None and key_beats.beats: + beats_blob = ( + "Key beats to cover (set covers_beats on staging pages):\n" + f"{key_beats.model_dump_json()}\n\n" + ) + note = f"\n\n{extra_user_note}" if extra_user_note else "" user = ( f"{sanitize_text(text)}\n\n" f"Known elements:\n{elements.model_dump_json()}\n\n" + f"{beats_blob}" "Plan finished readable pages (not a flat 2x2 collage). " "Each page needs purpose, layout_intent, panels, and lettering_boxes. " + f"{diversity}" + f"{voice}" f"{lang_reminder}" + f"{note}" ) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, @@ -285,7 +342,7 @@ async def reconcile_visual_bible( "- Every canon must include gender (male/female/nonbinary/unknown) and " "narrative_function (letter_reader/letter_writer/protagonist/love_interest/" "servant/parent/child/extra).\n" - "- Always fill face_lock, hair_lock, and outfit_lock for every stage.\n" + "- Always fill face_lock, hair_lock, outfit_lock, and age_look for every stage.\n" "- For historical eras use period wardrobe only — never hoodies, sneakers, " "or athleisure.\n" "- portrait_key must be short form {canonical_name}@{stage} only " diff --git a/docs/superpowers/plans/2026-08-03-narrative-fidelity-pipeline.md b/docs/superpowers/plans/2026-08-03-narrative-fidelity-pipeline.md new file mode 100644 index 0000000..47eaed4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-narrative-fidelity-pipeline.md @@ -0,0 +1,156 @@ +# Narrative Fidelity Pipeline 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:** Close the four pipeline gaps in `.issue/2026-08-03-15_04-review.md` — identity stage lock, layout anti-template, voice/timeline layers, and must-draw beats — as sequential, mergeable phases. + +**Architecture:** Extend Visual Bible + finished-page planner/prompt/fingerprint without novel-specific hardcoding. Each phase adds schema and/or helpers, wires `creative_comic` / `screenwriter` / `page_prompt`, bumps a fingerprint token, and ships unit tests. + +**Tech Stack:** Python 3.10+, pydantic schemas, pytest, existing `core/comic/*` + `core/pipelines/creative_comic.py`. + +**Spec:** `docs/superpowers/specs/2026-08-03-narrative-fidelity-pipeline-design.md` + +## Global Constraints + +- No Zweig / novel-specific constants in code (QA checklists may live under `.issue/`). +- No in-image lettering regression; keep deferred lettering. +- No VLM QA loops in these phases. +- One phase per PR; fingerprint tokens as named in the spec. +- Prefer TDD: failing test → implement → pass → commit. + +--- + +## File map + +| File | A | B | C | D | +|------|---|---|---|---| +| `core/schemas.py` | age_look | — | speaker, timeline | beats / covers_beats | +| `core/comic/visual_bible.py` | stage resolve, refs | — | — | — | +| `core/comic/page_prompt.py` | hair/age lines | anti-standee | timeline grammar | beat action lines | +| `core/comic/layout_diversity.py` | — | **new** | — | — | +| `core/comic/voice.py` | — | — | **new** (sanitize) | — | +| `core/screenwriter.py` | age_look reconcile | prior layouts | speaker/timeline plan rules | extract_key_beats + retry | +| `core/pipelines/creative_comic.py` | wire resolve + fp | pass prior layouts | wire sanitize + fp | wire beats + fp | +| `tests/test_stage_lock.py` | **new** | — | — | — | +| `tests/test_layout_diversity.py` | — | **new** | — | — | +| `tests/test_voice_timeline.py` | — | — | **new** | — | +| `tests/test_key_beats.py` | — | — | — | **new** | + +--- + +## Phase A — Identity lock hardening + +### Task A1: Schema `age_look` + stage resolve helper + +**Files:** `core/schemas.py`, `core/comic/visual_bible.py`, `tests/test_stage_lock.py` + +- [ ] Add failing tests for `resolve_panel_stage_refs`: action containing 少女 / child cue rewrites bare name → `Name@child` when stage exists; no rewrite when stage missing. +- [ ] Add `CharacterStage.age_look: str = ""` with coerce. +- [ ] Implement `AGE_CUE_TO_STAGE` heuristics (generic CN/EN markers → child|teen|adult|elder). +- [ ] Implement `resolve_panel_stage_refs(plan, bible) -> ComicPagePlan`. +- [ ] Extend `ensure_stage_locks` / sanitize to fill blank `age_look` from stage literal defaults. +- [ ] Run `pytest tests/test_stage_lock.py -q`. +- [ ] Commit: `feat: resolve panel character stages from age cues` + +### Task A2: Prompt + refs + fingerprint + +**Files:** `core/comic/page_prompt.py`, `core/pipelines/creative_comic.py`, `core/screenwriter.py`, tests + +- [ ] Failing test: finished-page prompt includes hair_lock and age_look when present; includes “do not change hair color/length” lock line. +- [ ] Inject hair/age lines in `_character_desc_for_prompt` / identity block. +- [ ] Wire `resolve_panel_stage_refs` after bible rewrite / before render in finished_page path. +- [ ] Reconcile instructions: fill `age_look` per stage. +- [ ] Fingerprint: add `"identity": "stage_lock_v1"` (replace or alongside metaphor_v2 — prefer replace metaphor token only if tests allow; else add `stage_lock: "v1"` key). Update fingerprint tests. +- [ ] Run related pytest; commit: `feat: force stage identity lines and stage_lock fingerprint` + +### Task A3: Phase A verification + +- [ ] `pytest tests/test_stage_lock.py tests/test_visual_bible_v3.py tests/test_finished_page_pipeline.py tests/test_page_prompt.py -q` +- [ ] Open PR for Phase A only. + +--- + +## Phase B — Layout anti-template + +### Task B1: Diversity helper + catalog + +**Files:** `core/comic/layout_diversity.py` (new), `tests/test_layout_diversity.py` + +- [ ] Define `LAYOUT_CATALOG` frozenset of intent tokens. +- [ ] `summarize_recent_layouts(pages) -> str` for planner context. +- [ ] `consecutive_layout_streak(intents) -> int` helper. +- [ ] Tests for summarize + streak. +- [ ] Commit: `feat: add layout diversity helpers` + +### Task B2: Planner + prompt wire + +**Files:** `core/screenwriter.py`, `core/comic/page_prompt.py`, `core/pipelines/creative_comic.py` + +- [ ] `plan_comic_pages(..., recent_layouts: list[str] | None = None)` inject anti-repeat instructions + catalog. +- [ ] `ANTI_CENTER_STANDEE_LINE` in page_prompt when bible or always for finished_page. +- [ ] Pipeline: collect last K `layout_intent` from state / prior pages in chunk; pass into planner. +- [ ] Fingerprint `layout: "anti_template_v1"`. +- [ ] Tests; commit; PR Phase B. + +--- + +## Phase C — Voice + timeline + +### Task C1: Schema + voice sanitize + +**Files:** `core/schemas.py`, `core/comic/voice.py`, `tests/test_voice_timeline.py` + +- [ ] Add `speaker`, page/panel `timeline` fields. +- [ ] `sanitize_panel_voice(panel, bible) -> panel`: letter-writer I-voice in reader-only dialogue → caption. +- [ ] Tests; commit. + +### Task C2: Planner + prompt + fingerprint + +**Files:** `core/screenwriter.py`, `core/comic/page_prompt.py`, `core/pipelines/creative_comic.py` + +- [ ] Plan instructions for speaker + timeline; inject letter_reader/writer names from bible. +- [ ] Timeline visual grammar lines in `render_finished_page_prompt`. +- [ ] Wire sanitize before lettering / storage. +- [ ] Fingerprint `voice_timeline: "v1"`. +- [ ] Tests; PR Phase C. + +--- + +## Phase D — Key beats + +### Task D1: Beat schema + extract tool + +**Files:** `core/schemas.py`, `core/screenwriter.py`, `tests/test_key_beats.py` + +- [ ] Models: `KeyBeat`, list on state or chunk cache; `covers_beats` on `ComicPagePlan`. +- [ ] `extract_key_beats` tool + fake chat test. +- [ ] Commit. + +### Task D2: Coverage retry + prompt + fingerprint + +**Files:** `core/pipelines/creative_comic.py`, `core/comic/page_prompt.py` + +- [ ] After `plan_comic_pages`, if must_draw uncovered → one retry with beat list. +- [ ] Prompt: when covers_beats, require physical staging. +- [ ] Fingerprint `beats: "v1"`. +- [ ] Tests; PR Phase D. + +--- + +## Manual QA (after A–D) + +Use `.issue/2026-08-03-15_04-review.md` §八.5 as a **human checklist** for 《来信》 re-runs — not asserted in CI: + +1. 厚信开封 2. 死婴四烛 3. 初见一瞥 4. 门铃与空屋 5. 初夜白玫瑰 6. 产院 7. 舞厅一小时 8. 钞票/约翰/空蓝花瓶 + +--- + +## Progress + +| Phase | Status | +|-------|--------| +| Spec | written | +| A Identity | done (PR #32) | +| B Layout | done (PR #33) | +| C Voice/timeline | done (PR #34) | +| D Beats | done (PR next) | diff --git a/docs/superpowers/specs/2026-08-03-narrative-fidelity-pipeline-design.md b/docs/superpowers/specs/2026-08-03-narrative-fidelity-pipeline-design.md new file mode 100644 index 0000000..1a75fca --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-narrative-fidelity-pipeline-design.md @@ -0,0 +1,172 @@ +# Design: Narrative Fidelity Pipeline (post–bible_v3) + +**Date:** 2026-08-03 +**Status:** Draft for sequential implementation +**Repro / product ask:** `.issue/2026-08-03-15_04-review.md` (41-page 《一个陌生女人的来信》 review) +**Depends on:** Visual Bible v1–v3 (`docs/superpowers/specs/2026-08-03-visual-bible-v3-design.md`), deferred lettering + +## §1 Problem (general, not novel-specific) + +bible_v3 hardens era / gender / diegetic prop text. Review still fails on four orthogonal axes: + +1. **Identity drift across pages** — same canon jumps hair color/length/age; stages exist but planner rarely emits `Name@stage`; refs are optional. +2. **Layout template collapse** — pages converge on “center full-body standing + corner closeups”; no anti-repetition. +3. **Voice + timeline muddle** — letter narration lands in the reader’s dialogue bubbles; present (reading letter) vs past (life story) share one visual grammar. +4. **Caption-only drama** — key plot beats are narrated in captions without being staged as drawable scenes. + +Goal: fix these as **generic finished-page pipeline capabilities**. Any novel is a regression sample; no Zweig hardcoding in code. + +## §2 Non-goals + +- VLM post-hoc image QA loops (optional later). +- Full character-sheet image generation (Phase C stub remains deferred unless Identity phase needs a minimal sheet). +- Perfect literary fidelity of every beat (planner quality improves via structure + prompts, not human editorial). +- Pixel-editing existing comic_out PNGs (re-run regenerates). +- Replacing deferred lettering with in-image text. + +## §3 Phased delivery (do in order) + +```text +Phase A Identity lock hardening + ↓ +Phase B Layout anti-template + ↓ +Phase C Voice + timeline layers + ↓ +Phase D Monoscene / key-beat planner gate +``` + +Each phase: design slice (below) → unit tests → wire → fingerprint bump if render inputs change → mergeable PR. Do **not** start B until A lands; same for C/D. + +--- + +### Phase A — Identity lock hardening + +**Symptoms addressed:** R/heroine/child look like different people; age span flattened; wrong stage outfit/hair. + +**Design:** + +1. **Hair + age locks as first-class strings** + - Ensure every stage has non-empty `hair_lock` (already) and add optional `age_look: str` on `CharacterStage` (e.g. `about 13`, `about 41`) when reconcile provides it; sanitize fills from role/stage heuristics when blank (`child`→young, `adult`→middle-aged defaults are soft, not novel-specific). +2. **Action → stage resolution** + - `resolve_panel_stage_refs(plan, bible)`: if panel characters are bare canonical names and action/purpose/caption mention age cues (童年/少女/临终/写信/少年… + EN), rewrite to matching `portrait_key` / `Name@stage` when that stage exists. +3. **Forced portrait refs** + - `collect_finished_page_refs`: for every on-page character with a resolved stage portrait, **require** that path in refs (already prefers stage keys); fail soft-log if missing file after `ensure_stage_portrait_assets`. +4. **Prompt harden** + - Per character: `hair_lock` + `age_look` + “do not change hair color/length across panels for this identity unless stage changes”. +5. **Fingerprint** + - Bump identity token or bible version note (`identity_v3` or keep metaphor_v2 + bible hash already covers lock text). Prefer new `identity: "stage_lock_v1"` in render fingerprint when stage rewrite is active. + +**Files:** `core/schemas.py`, `core/comic/visual_bible.py`, `core/comic/page_prompt.py`, `core/pipelines/creative_comic.py`, `core/screenwriter.py` (reconcile: fill age_look), tests. + +**Exit criteria:** Unit tests: action cue → `@child` rewrite; page prompt contains hair/age lines; refs include stage portrait paths. Manual: re-run polluted project → fewer hair color swaps (best-effort). + +--- + +### Phase B — Layout anti-template + +**Symptoms addressed:** 41 pages share center full-body + letter-holding pose; no scene blocking. + +**Design:** + +1. **Layout catalog (enum-ish strings)** + - Document allowed `layout_intent` tokens: `splash_action`, `dialogue_grid`, `inset_memory`, `widescreen_scene`, `diagonal_motion`, `crowd_establishing`, `object_closeup`, … Planner must pick from catalog + free detail. +2. **Anti-repetition memory** + - When planning page N, pass last K pages’ `layout_intent` + dominant `shot` summary; instruct “do not reuse the same layout_intent as the previous page; avoid consecutive full-body standing hero shots.” +3. **Pose banline in page prompt** + - `ANTI_CENTER_STANDEE_LINE`: forbid repeating centered full-body standing character holding a letter/prop as the page hero if `layout_intent` is not splash; prefer environment + action staging. +4. **Light validation** + - Soft sanitize: if ≥3 consecutive pages share identical `layout_intent`, append needs_review or bump a warning in state (no hard fail in v1). + +**Files:** `core/screenwriter.py` (`plan_comic_pages`), `core/comic/page_prompt.py`, optional `core/comic/layout_diversity.py`, tests. + +**Exit criteria:** Prompt contains anti-standee line; planner user message includes prior layout_intents; unit test for diversity helper. + +--- + +### Phase C — Voice + timeline layers + +**Symptoms addressed:** Letter-writer narration in R’s bubbles; present/past visual mush; living child drawn for death beat (partially identity, partially timeline). + +**Design:** + +1. **Schema** + - `PagePanelSpec.speaker: str = ""` — empty = narrator/unspecified; character name for dialogue. + - `ComicPagePlan.timeline: Literal["present","past","liminal",""] = ""` (page-level default). + - Optional `PagePanelSpec.timeline` override. +2. **Planner rules** + - Narration / letter first-person / time-place → `caption`, `speaker=""`. + - Spoken lines → `dialogue` + `speaker` = speaking character (never attribute letter-writer I-voice to letter_reader). + - If bible has `letter_writer` / `letter_reader`, inject into plan prompt. +3. **Sanitize lettering voice** + - If `dialogue` text looks like first-person letter confession and panel characters are only the reader, move to `caption` (heuristic) or strip speaker. +4. **Timeline visual grammar in page prompt** + - `present`: cool/static interior, reading frame. + - `past`: warmer / slight grain / clear “memory” staging; no letter-holding unless action requires. + - Inject when timeline set. +5. **Deferred lettering** + - Overlay unchanged (caption vs dialogue chrome); speaker may inform placement notes later — not required for C v1. + +**Files:** `core/schemas.py`, `core/screenwriter.py`, `core/comic/page_prompt.py`, `core/comic/lettering_lang.py` or new `voice.py`, fingerprint, tests. + +**Exit criteria:** Schema round-trip; sanitize moves mis-attributed letter voice; prompt includes timeline lines. + +--- + +### Phase D — Monoscene / key-beat planner gate + +**Symptoms addressed:** Opera box / death candles / doorbell / paying insult only exist as captions. + +**Design:** + +1. **Beat extraction (LLM tool, once per project or per chunk)** + - `extract_key_beats(chunk|whole) → list[{beat_id, summary, must_draw, characters, setting_hint}]` capped (e.g. ≤12/chunk or ≤24/project). Generic: “dramatizable turning points,” not a fixed Zweig list. +2. **Coverage on `ComicPagePlanSet`** + - Each page may declare `covers_beats: list[str]`. + - After planning a chunk, if any `must_draw` beat uncovered → one repair retry with “stage these beats as panels/pages, not captions only.” +3. **Page prompt** + - When `covers_beats` set, require the panel action to depict the beat physically (environment + body), not standing-with-letter metaphor. +4. **Non-goal for D v1:** human-authored beat lists; novel-specific 8 monoscenes stay in review docs as **manual QA checklist**, not code constants. + +**Files:** `core/schemas.py`, `core/screenwriter.py`, `core/pipelines/creative_comic.py`, tests. + +**Exit criteria:** Uncovered must_draw triggers retry in fake chat test; covered beats appear in plan JSON. + +--- + +## §4 Fingerprint / resume policy + +| Phase | Fingerprint change | +|-------|-------------------| +| A | `identity: "stage_lock_v1"` (or additive key) | +| B | `layout: "anti_template_v1"` | +| C | `voice_timeline: "v1"` | +| D | `beats: "v1"` | + +Sanitize/resume soft-invalidates when tokens change (existing pattern). + +## §5 Testing strategy + +- Unit tests per phase (fakes, no Agnes). +- Update fingerprint tests when tokens bump. +- Manual regression: re-run `a069a5c667bd` (or successor) after A+B at minimum; full A–D before calling adaptation “publishable.” + +## §6 Mapping to review §八 + +| Review priority | Phase | +|-----------------|-------| +| 1 Lock character sheets / hair / age stages | A | +| 2 Storyboard first; ban standee template | B (+ planner in D) | +| 3 Lettering post (already deferred) | done; C hardens voice | +| 4 Period wardrobe table | done in bible_v3; A reinforces stage outfits | +| 5 Eight monoscenes | D generic beats + manual QA checklist | +| 6 Dual timeline visuals | C | + +## §7 Implementation order for agents + +1. Land Phase A PR. +2. Land Phase B PR. +3. Land Phase C PR. +4. Land Phase D PR. + +Do not combine phases in one PR unless trivial coupling requires it. diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py index aff22da..e594873 100644 --- a/tests/test_finished_page_pipeline.py +++ b/tests/test_finished_page_pipeline.py @@ -95,6 +95,8 @@ async def chat_function_call(self, messages, tools, tool_choice, **kw): } ], } + if name == "extract_key_beats": + return {"beats": []} if name == "plan_comic_pages": self.page_plan_calls += 1 return { @@ -151,6 +153,10 @@ def test_render_fingerprint_includes_visual_bible_hash(): "render_mode": "finished_page", "page_size": "1024x1536", "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", "lettering": "deferred_v3", "visual_bible": "bible_v1", "bible_hash": "deadbeef", @@ -181,6 +187,10 @@ def test_render_fingerprint_uses_bible_v2_token(): "render_mode": "finished_page", "page_size": "1024x1536", "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", "lettering": "deferred_v3", "visual_bible": "bible_v2", "bible_hash": "abc", @@ -211,6 +221,10 @@ def test_render_fingerprint_uses_bible_v3_token(): "render_mode": "finished_page", "page_size": "1024x1536", "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", "lettering": "deferred_v3", "visual_bible": "bible_v3", "bible_hash": "abc", @@ -233,6 +247,10 @@ def test_render_fingerprint_tracks_deferred_lettering_version(): "page_size": "1024x1536", "lettering": "deferred_v3", "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", }, ensure_ascii=False, sort_keys=True, @@ -261,6 +279,10 @@ def test_render_fingerprint_omits_lettering_for_panel_compose(): "render_mode": "panel_compose", "page_size": "1024x1536", "identity": "metaphor_v2", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", }, ensure_ascii=False, sort_keys=True, @@ -302,7 +324,7 @@ def test_finished_page_mode_writes_generated_pages(tmp_path, monkeypatch): assert proj.pages == [str(p) for p in page_files] assert img.calls == 2 # 1 portrait + 1 page - assert chat.calls == 3 # extract + reconcile + plan_comic_pages + assert chat.calls == 4 # extract + reconcile + extract_key_beats + plan_comic_pages # Resume: state.json already has the page recorded, so nothing regenerates. chat2, img2 = FakeChat(), FakeImage() diff --git a/tests/test_key_beats.py b/tests/test_key_beats.py new file mode 100644 index 0000000..46011fa --- /dev/null +++ b/tests/test_key_beats.py @@ -0,0 +1,175 @@ +# tests/test_key_beats.py +import asyncio +import hashlib +import json + +from core.comic.key_beats import ( + beat_coverage_retry_note, + covers_beats_prompt_line, + uncovered_must_draw_beats, +) +from core.comic.page_prompt import render_finished_page_prompt +from core.pipelines.creative_comic import _render_fingerprint +from core.schemas import ( + CharacterAsset, + ComicPagePlan, + ComicPagePlanSet, + KeyBeat, + KeyBeatSet, + ModelSnapshot, + StoryElements, +) +from core.screenwriter import extract_key_beats, plan_comic_pages + + +class FakeBeatChat: + def __init__(self): + self.calls = 0 + self.names: list[str] = [] + + async def chat_function_call(self, messages, tools, tool_choice, **kw): + self.calls += 1 + name = tool_choice["function"]["name"] + self.names.append(name) + if name == "extract_key_beats": + return { + "beats": [ + { + "beat_id": "open_letter", + "summary": "thick unsigned letter opened", + "must_draw": True, + "characters": ["R"], + }, + { + "beat_id": "child_death", + "summary": "dead child and four candles", + "must_draw": True, + "characters": ["陌生女人"], + }, + ] + } + if name == "plan_comic_pages": + # First plan misses a beat; second covers both if retry note present. + user = messages[-1]["content"] + if "child_death" in user and "CRITICAL" in user: + covers = ["open_letter", "child_death"] + elif self.names.count("plan_comic_pages") > 1: + covers = ["open_letter", "child_death"] + else: + covers = ["open_letter"] + return { + "unit_id": "u1", + "pages": [ + { + "page_id": "p1", + "purpose": "beats", + "layout_intent": "widescreen_scene", + "covers_beats": covers, + "panels": [ + { + "panel_id": "1", + "characters": ["R"], + "action": "stages the beat", + } + ], + } + ], + } + return {} + + +def test_uncovered_must_draw_and_retry_note(): + beats = KeyBeatSet( + beats=[ + KeyBeat(beat_id="a", summary="A", must_draw=True), + KeyBeat(beat_id="b", summary="B", must_draw=True), + KeyBeat(beat_id="c", summary="C", must_draw=False), + ] + ) + pageset = ComicPagePlanSet( + pages=[ + ComicPagePlan.model_validate( + { + "page_id": "p1", + "covers_beats": ["a"], + "panels": [], + } + ) + ] + ) + missing = uncovered_must_draw_beats(beats, pageset) + assert [b.beat_id for b in missing] == ["b"] + note = beat_coverage_retry_note(missing) + assert "b" in note and "CRITICAL" in note + + +def test_extract_and_plan_retry_covers_beats(): + chat = FakeBeatChat() + elements = StoryElements(characters=[], settings=[], style_guide="manhua") + beats = asyncio.run(extract_key_beats("厚信。孩子死了。", elements, chat=chat)) + assert {b.beat_id for b in beats.beats} == {"open_letter", "child_death"} + pageset = asyncio.run( + plan_comic_pages("厚信。孩子死了。", elements, chat=chat, key_beats=beats) + ) + missing = uncovered_must_draw_beats(beats, pageset) + if missing: + pageset = asyncio.run( + plan_comic_pages( + "厚信。孩子死了。", + elements, + chat=chat, + key_beats=beats, + extra_user_note=beat_coverage_retry_note(missing), + ) + ) + assert not uncovered_must_draw_beats(beats, pageset) + + +def test_page_prompt_includes_covers_beats_staging_line(): + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "x", + "layout_intent": "widescreen_scene", + "covers_beats": ["open_letter"], + "panels": [{"panel_id": "1", "characters": ["R"], "action": "opens letter"}], + } + ) + assert covers_beats_prompt_line(plan) is not None + text = render_finished_page_prompt( + plan, + characters_by_name={"R": CharacterAsset(name="R", l1_prompt="man")}, + settings_by_name={}, + ) + assert "open_letter" in text + assert "physical" in text.lower() or "environment" in text.lower() + + +def test_fingerprint_includes_beats_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", + ) + 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", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", + "lettering": "deferred_v3", + } + expected = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert fp == expected diff --git a/tests/test_layout_diversity.py b/tests/test_layout_diversity.py new file mode 100644 index 0000000..b9f3302 --- /dev/null +++ b/tests/test_layout_diversity.py @@ -0,0 +1,96 @@ +# tests/test_layout_diversity.py +import hashlib +import json + +from core.comic.layout_diversity import ( + ANTI_CENTER_STANDEE_LINE, + LAYOUT_CATALOG, + consecutive_layout_streak, + layout_diversity_instructions, + normalize_layout_intent, + summarize_recent_layouts, +) +from core.comic.page_prompt import render_finished_page_prompt +from core.pipelines.creative_comic import _render_fingerprint +from core.schemas import CharacterAsset, ColorBible, ComicPagePlan, ModelSnapshot, VisualBible + + +def test_normalize_and_catalog(): + assert "splash_action" in LAYOUT_CATALOG + assert normalize_layout_intent("splash_action — fight") == "splash_action" + assert normalize_layout_intent("object_closeup: letter") == "object_closeup" + + +def test_consecutive_layout_streak(): + assert consecutive_layout_streak([]) == 0 + assert consecutive_layout_streak(["splash_action", "dialogue_grid"]) == 1 + assert consecutive_layout_streak(["dialogue_grid", "dialogue_grid", "dialogue_grid: talk"]) == 3 + + +def test_summarize_and_instructions_include_recent(): + summary = summarize_recent_layouts( + ["splash_action", "widescreen_scene", "object_closeup"], limit=2 + ) + assert "widescreen_scene" in summary + assert "object_closeup" in summary + text = layout_diversity_instructions(["splash_action", "splash_action"]) + assert "splash_action" in text + assert "Do NOT reuse" in text + assert "dialogue_grid" in text # catalog token present + + +def test_page_prompt_includes_anti_standee_line(): + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "read", + "layout_intent": "widescreen_scene", + "panels": [{"panel_id": "1", "characters": ["R"], "action": "R walks"}], + } + ) + text = render_finished_page_prompt( + plan, + characters_by_name={"R": CharacterAsset(name="R", l1_prompt="man")}, + settings_by_name={}, + visual_bible=VisualBible( + version="bible_v3", + style_guide="manhua", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={}, + content_hash="x", + ), + ) + assert "full-body standing" in text.lower() or "standee" in text.lower() + assert ANTI_CENTER_STANDEE_LINE.split(";")[0][:20].lower() in text.lower() or ( + "centered full-body" in text.lower() + ) + + +def test_render_fingerprint_includes_anti_template_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", + ) + 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", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", + "lettering": "deferred_v3", + } + expected = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert fp == expected diff --git a/tests/test_stage_lock.py b/tests/test_stage_lock.py new file mode 100644 index 0000000..f1e94ca --- /dev/null +++ b/tests/test_stage_lock.py @@ -0,0 +1,205 @@ +# tests/test_stage_lock.py +from core.comic.page_prompt import render_finished_page_prompt +from core.comic.visual_bible import ( + default_age_look_for_stage, + ensure_stage_locks, + infer_stage_from_text, + resolve_panel_stage_refs, +) +from core.pipelines.creative_comic import _render_fingerprint +from core.schemas import ( + CharacterAsset, + CharacterCanon, + CharacterStage, + ColorBible, + ComicPagePlan, + ModelSnapshot, + VisualBible, +) + + +def _bible_with_girl_stages() -> VisualBible: + return VisualBible( + version="bible_v3", + style_guide="period", + era="Vienna c.1900", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "少女时代的她": CharacterCanon( + canonical_name="少女时代的她", + gender="female", + narrative_function="letter_writer", + face_lock="adult woman, wistful eyes", + stages=[ + CharacterStage( + stage="child", + outfit_lock="dark dress with apron", + hair_lock="dark neat braid", + age_look="about 13 years old", + portrait_key="少女时代的她@child", + ), + CharacterStage( + stage="adult", + outfit_lock="plain dark dress", + hair_lock="dark hair pulled back", + age_look="about 28 years old", + portrait_key="少女时代的她@adult", + ), + ], + ) + }, + content_hash="x", + ) + + +def test_infer_stage_from_age_cues(): + assert infer_stage_from_text("十三岁的少女站在门口") == "child" + assert infer_stage_from_text("临终写信的女人") == "adult" + assert infer_stage_from_text("as a teenager at boarding school") == "teen" + assert infer_stage_from_text("R stands in the study") is None + + +def test_resolve_panel_stage_refs_rewrites_bare_name(): + bible = _bible_with_girl_stages() + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "童年初见", + "layout_intent": "two shot", + "panels": [ + { + "panel_id": "1", + "characters": ["少女时代的她"], + "action": "十三岁的少女偷看楼上", + } + ], + "reference_characters": ["少女时代的她"], + } + ) + out = resolve_panel_stage_refs(plan, bible) + assert out.panels[0].characters == ["少女时代的她@child"] + assert out.reference_characters == ["少女时代的她@child"] + + +def test_resolve_panel_stage_refs_skips_missing_stage(): + bible = VisualBible( + version="bible_v3", + style_guide="period", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "R": CharacterCanon( + canonical_name="R", + gender="male", + face_lock="adult man, calm eyes", + stages=[ + CharacterStage( + stage="adult", + outfit_lock="dark suit", + hair_lock="dark swept hair", + portrait_key="R@adult", + ) + ], + ) + }, + content_hash="x", + ) + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "childhood", + "layout_intent": "focus", + "panels": [ + { + "panel_id": "1", + "characters": ["R"], + "action": "as a child playing", + } + ], + } + ) + out = resolve_panel_stage_refs(plan, bible) + assert out.panels[0].characters == ["R"] + + +def test_default_age_look_and_ensure_fills(): + assert ( + "13" in default_age_look_for_stage("child") + or "child" in default_age_look_for_stage("child").casefold() + ) + stage = ensure_stage_locks( + CharacterStage(stage="teen", outfit_lock="", hair_lock="", portrait_key=""), + canon_face="calm eyes", + canonical_name="R", + era="Vienna c.1900", + ) + assert stage.age_look + assert stage.portrait_key == "R@teen" + + +def test_page_prompt_includes_hair_age_and_stability_line(): + bible = _bible_with_girl_stages() + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "meet", + "layout_intent": "focus", + "panels": [ + { + "panel_id": "1", + "characters": ["少女时代的她@child"], + "action": "girl watches", + } + ], + } + ) + text = render_finished_page_prompt( + plan, + characters_by_name={ + "少女时代的她": CharacterAsset(name="少女时代的她", l1_prompt="old"), + "少女时代的她@child": CharacterAsset(name="少女时代的她@child", l1_prompt="old child"), + }, + settings_by_name={}, + visual_bible=bible, + ) + lower = text.lower() + assert "dark neat braid" in lower or "braid" in lower + assert "13" in text or "age" in lower + assert "hair color" in lower or "hair length" in lower + + +def test_render_fingerprint_includes_stage_lock_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_v3", + bible_hash="abc", + ) + # Decode by recomputing expected payload shape + import hashlib + import json + + 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", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", + "lettering": "deferred_v3", + "visual_bible": "bible_v3", + "bible_hash": "abc", + } + expected = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert fp == expected diff --git a/tests/test_voice_timeline.py b/tests/test_voice_timeline.py new file mode 100644 index 0000000..f2eb73e --- /dev/null +++ b/tests/test_voice_timeline.py @@ -0,0 +1,129 @@ +# tests/test_voice_timeline.py +import hashlib +import json + +from core.comic.page_prompt import render_finished_page_prompt +from core.comic.voice import ( + looks_like_letter_narration, + sanitize_plan_voice, + timeline_prompt_lines, +) +from core.pipelines.creative_comic import _render_fingerprint +from core.schemas import ( + CharacterAsset, + CharacterCanon, + ColorBible, + ComicPagePlan, + ModelSnapshot, + VisualBible, +) + + +def _bible() -> VisualBible: + return VisualBible( + version="bible_v3", + style_guide="period", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "R": CharacterCanon( + canonical_name="R", + gender="male", + narrative_function="letter_reader", + face_lock="adult man", + stages=[], + ), + "陌生女人": CharacterCanon( + canonical_name="陌生女人", + gender="female", + narrative_function="letter_writer", + face_lock="adult woman", + stages=[], + ), + }, + content_hash="x", + ) + + +def test_looks_like_letter_narration(): + assert looks_like_letter_narration("我的儿子昨天死了") + assert not looks_like_letter_narration("请进。") + + +def test_sanitize_moves_writer_voice_off_reader_dialogue(): + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "read letter", + "timeline": "present", + "layout_intent": "object_closeup", + "panels": [ + { + "panel_id": "1", + "characters": ["R"], + "action": "R reads", + "dialogue": "我的儿子昨天死了,请你相信我", + "speaker": "R", + } + ], + "lettering_boxes": [ + {"kind": "dialogue", "panel_id": "1", "x": 0.1, "y": 0.1, "w": 0.3, "h": 0.1} + ], + } + ) + out = sanitize_plan_voice(plan, _bible()) + assert out.panels[0].dialogue is None + assert "儿子" in (out.panels[0].caption or "") + assert out.panels[0].speaker == "" + assert not any(b.kind == "dialogue" for b in out.lettering_boxes) + + +def test_timeline_prompt_lines_and_page_injection(): + assert any("present" in line.casefold() for line in timeline_prompt_lines("present")) + past_lines = timeline_prompt_lines("past") + assert any("past" in line.casefold() or "memory" in line.casefold() for line in past_lines) + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "memory", + "timeline": "past", + "layout_intent": "widescreen_scene", + "panels": [{"panel_id": "1", "characters": ["陌生女人"], "action": "she waits"}], + } + ) + text = render_finished_page_prompt( + plan, + characters_by_name={"陌生女人": CharacterAsset(name="陌生女人", l1_prompt="woman")}, + settings_by_name={}, + visual_bible=_bible(), + ) + assert "Timeline=past" in text or "memory" in text.lower() + + +def test_fingerprint_includes_voice_timeline(): + 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", + ) + 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", + "stage_lock": "v1", + "layout": "anti_template_v1", + "voice_timeline": "v1", + "beats": "v1", + "lettering": "deferred_v3", + } + expected = hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert fp == expected