diff --git a/core/comic/page_prompt.py b/core/comic/page_prompt.py index b17ff29..4075f23 100644 --- a/core/comic/page_prompt.py +++ b/core/comic/page_prompt.py @@ -14,12 +14,14 @@ ANTI_CHARACTER_SHEET_LINE, ANTI_MULTI_AGE_COLLAGE_LINE, COSTUME_CHANGE_LOCK_LINE, - PERIOD_WARDROBE_LINE, + DIEGETIC_TEXT_LINE, format_color_bible_block, + format_identity_line, l1_from_canon, parse_stage_ref, resolve_canonical_name, resolve_character_asset, + wardrobe_banline_for_bible, ) from core.schemas import CharacterAsset, ComicPagePlan, Setting, VisualBible @@ -64,6 +66,7 @@ def render_finished_page_prompt( "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.", + DIEGETIC_TEXT_LINE, ] ) if strict: @@ -95,7 +98,7 @@ def render_finished_page_prompt( lines.append(color_block) lines.append(COSTUME_CHANGE_LOCK_LINE) lines.append(ANTI_CHARACTER_SHEET_LINE) - lines.append(PERIOD_WARDROBE_LINE) + lines.append(wardrobe_banline_for_bible(visual_bible)) lines.append(ANTI_MULTI_AGE_COLLAGE_LINE) lines.append(f"Page purpose: {plan.purpose}") lines.append(f"Layout intent: {plan.layout_intent}") @@ -121,6 +124,14 @@ def render_finished_page_prompt( desc = _character_desc_for_prompt(name, asset, visual_bible) if desc: lines.append(f" character {name}: {desc}") + 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: + lines.append(f" {format_identity_line(name, canon)}") if lettering == "in_image": if panel.caption: lines.append(f" CAPTION (exact): {panel.caption}") diff --git a/core/comic/visual_bible.py b/core/comic/visual_bible.py index 64b4b57..0460bfe 100644 --- a/core/comic/visual_bible.py +++ b/core/comic/visual_bible.py @@ -7,6 +7,7 @@ import logging import re from collections.abc import Iterable +from typing import Literal from core.comic.identity import merge_character_alias, suggestion_from_alias from core.schemas import ( @@ -39,11 +40,27 @@ "unless action explicitly requires costume change." ) +CONTEMPORARY_WARDROBE_LINE = ( + "Wardrobe must match the project era; do not force historical costume " + "unless action explicitly requires period dress." +) + +DIEGETIC_TEXT_LINE = ( + "Diegetic props that are letters, books, newspapers, signs, or screens must " + "show blank aged paper or abstract ink texture only — no letterforms, no " + "Latin or CJK glyphs, no pseudo-script." +) + 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." ) +GENDER_NO_SWAP_LINE = ( + "single human matching locked gender exactly; no gender swap or androgynous " + "reinterpretation of a gendered canon" +) + _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)", @@ -55,6 +72,100 @@ r")\b", ) +_MODERN_WARDROBE_RE = re.compile( + r"\b(" + r"hoodie|hoodies|sneakers|athleisure|zip-?up|jeans|t-?shirts?|" + r"sweatpants|trainers|sportswear|sporty|tracksuit|converse|" + r"sports?\s*shoes|light\s+sports|sporty\s+jacket|athletic\s+wear" + r")\b", + re.IGNORECASE, +) + +_HISTORICAL_ERA_MARKERS = ( + "1900", + "1910", + "1920", + "18th", + "19th", + "20th century", + "early-20th", + "early 20th", + "victorian", + "edwardian", + "vienna", + "period", + "qing", + "民国", + "清末", + "vienna", + "belle epoque", + "meiji", + "historical", + "century european", +) +_CONTEMPORARY_ERA_MARKERS = ( + "contemporary", + "modern day", + "present day", + "21st", + "today", + "current era", + "现代", + "当代", +) + +_FEMALE_MARKERS = ( + "寡妇", + "母亲", + "少女", + "女儿", + "女人", + "女孩", + "女士", + "mother", + "widow", + "girl", + "woman", + "female", + "lady", + "she", + "her", +) +_MALE_MARKERS = ( + "男仆", + "男人", + "先生", + "小说家", + "作家", + "男孩", + "gentleman", + "man", + "male", + "butler", + "boy", + "he", + "him", + "约翰", + "stepfather", + "继父", + "novelist", +) + +_LETTER_WRITER_MARKERS = ( + "写信", + "letter writer", + "narrator", + "叙述者", + "陌生女人", + "unknown woman", +) +_LETTER_READER_MARKERS = ("收信", "letter reader", "reading the letter", "收信人") +_SERVANT_FUNCTION_MARKERS = ("仆", "butler", "servant", "男仆") +_PARENT_FUNCTION_MARKERS = ("母", "妈", "mother", "widow", "父", "father", "parent") +_CHILD_FUNCTION_MARKERS = ("孩子", "child", "son", "daughter", "儿子", "女儿") +_LOVE_INTEREST_MARKERS = ("情人", "lover", "love interest", "被爱") +_PROTAGONIST_MARKERS = ("protagonist", "主角", "novelist", "小说家", "作家") + _MOTHER_ROLE_MARKERS = ("母", "妈", "mother", "widow", "寡妇") _DAUGHTER_ROLE_MARKERS = ("女", "孩", "narrator", "少女", "女儿", "叙述者") _COUNT_LOVER_ROLE_MARKERS = ("伯爵", "count", "工厂主", "情人") @@ -63,6 +174,214 @@ _MASTER_ROLE_MARKERS = ("主人", "novelist", "作家") +EraClass = Literal["historical", "contemporary", "unspecified"] +GenderLiteral = Literal["male", "female", "nonbinary", "unknown"] + + +def classify_era(era: str, style_guide: str = "") -> EraClass: + """Classify project era for wardrobe defaults and banlines.""" + blob = f"{era or ''} {style_guide or ''}".casefold() + if any(marker in blob for marker in _CONTEMPORARY_ERA_MARKERS): + return "contemporary" + if any(marker.casefold() in blob for marker in _HISTORICAL_ERA_MARKERS): + return "historical" + return "unspecified" + + +def infer_era_text(era: str, style_guide: str) -> str: + """Keep explicit era, else lift a short hint from style_guide, else unspecified.""" + text = (era or "").strip() + if text: + return text + style = (style_guide or "").strip() + if not style: + return "unspecified" + # Prefer a short clause that looks era-like. + lower = style.casefold() + for marker in _HISTORICAL_ERA_MARKERS + _CONTEMPORARY_ERA_MARKERS: + if marker.casefold() in lower: + return style[:120].strip() + return "unspecified" + + +def default_outfit_for_era(era: str, style_guide: str = "") -> str: + """Era-aware outfit default (no Vienna hardcode for every project).""" + era_class = classify_era(era, style_guide) + era_text = (era or "").strip() + if era_class == "contemporary": + return "contemporary everyday clothing matching the story setting" + if era_class == "historical": + if era_text and era_text.casefold() != "unspecified": + return f"period-accurate clothing for {era_text}" + return "period-accurate historical clothing matching the story era" + return "clothing matching the story setting and era" + + +def outfit_has_modern_tokens(outfit: str) -> bool: + """True when outfit text contains modern streetwear tokens.""" + return bool(_MODERN_WARDROBE_RE.search(outfit or "")) + + +def repair_outfit_lock(outfit: str, *, era: str, style_guide: str = "") -> str: + """Fill blank outfits and rewrite modern tokens under historical eras.""" + text = (outfit or "").strip() + era_class = classify_era(era, style_guide) + if not text: + return default_outfit_for_era(era, style_guide) + if era_class == "historical" and outfit_has_modern_tokens(text): + return default_outfit_for_era(era, style_guide) + return text + + +def wardrobe_banline_for_bible(bible: VisualBible) -> str: + """Era-conditioned wardrobe hard line for image prompts.""" + era_class = classify_era(bible.era, bible.style_guide) + forbidden = list(bible.era_forbidden_wardrobe or []) + if not forbidden and era_class == "historical": + forbidden = ["hoodies", "sneakers", "athleisure", "sports shoes", "jeans", "t-shirts"] + if era_class == "contemporary": + line = CONTEMPORARY_WARDROBE_LINE + else: + line = PERIOD_WARDROBE_LINE + if forbidden: + line = f"{line} Forbidden wardrobe: {', '.join(forbidden)}." + if bible.era and bible.era.strip().casefold() != "unspecified": + line = f"Era lock: {bible.era.strip()}. {line}" + return line + + +def _blob_has_marker(blob: str, markers: tuple[str, ...]) -> bool: + lower = blob.casefold() + return any(marker in blob or marker.casefold() in lower for marker in markers) + + +def infer_gender( + *, + name: str = "", + role: str = "", + face_lock: str = "", + aliases: Iterable[str] | None = None, + explicit: str = "unknown", +) -> GenderLiteral: + """Infer gender from explicit field, then role/name/face markers.""" + if explicit in {"male", "female", "nonbinary"}: + return explicit # type: ignore[return-value] + parts = [name or "", role or "", face_lock or ""] + if aliases: + parts.extend(aliases) + blob = " ".join(parts) + female = _blob_has_marker(blob, _FEMALE_MARKERS) + male = _blob_has_marker(blob, _MALE_MARKERS) + if female and not male: + return "female" + if male and not female: + return "male" + # Face-lock pronouns alone + face = (face_lock or "").casefold() + if re.search(r"\b(woman|girl|female|lady)\b", face): + return "female" + if re.search(r"\b(man|boy|male|gentleman)\b", face): + return "male" + return "unknown" + + +def gender_prefix(gender: str) -> str: + """Idempotent face_lock gender phrase.""" + if gender == "male": + return "adult man" + if gender == "female": + return "adult woman" + if gender == "nonbinary": + return "adult nonbinary person" + return "" + + +def apply_gender_to_face_lock(face_lock: str, gender: str) -> str: + """Prepend gender phrase when known; strip duplicate prefixes.""" + face = normalize_face_lock(face_lock) or (face_lock or "").strip() + prefix = gender_prefix(gender) + if not prefix: + return face + # Strip existing gender lead-ins for idempotency. + face = re.sub( + r"(?i)^(adult\s+)?(man|woman|male|female|nonbinary person)\s*,\s*", + "", + face, + ).strip() + if face: + return f"{prefix}, {face}" + return prefix + + +def infer_narrative_function( + *, + name: str = "", + role: str = "", + explicit: str = "", +) -> str: + """Infer a short narrative_function tag when markers are clear.""" + text = (explicit or "").strip() + allowed = { + "letter_reader", + "letter_writer", + "protagonist", + "love_interest", + "servant", + "parent", + "child", + "extra", + } + if text in allowed: + return text + blob = f"{name or ''} {role or ''}" + if _blob_has_marker(blob, _LETTER_WRITER_MARKERS): + return "letter_writer" + if _blob_has_marker(blob, _LETTER_READER_MARKERS): + return "letter_reader" + if _blob_has_marker(blob, _SERVANT_FUNCTION_MARKERS): + return "servant" + if _blob_has_marker(blob, _PARENT_FUNCTION_MARKERS): + return "parent" + if _blob_has_marker(blob, _CHILD_FUNCTION_MARKERS): + return "child" + if _blob_has_marker(blob, _LOVE_INTEREST_MARKERS): + return "love_interest" + if _blob_has_marker(blob, _PROTAGONIST_MARKERS): + return "protagonist" + return "extra" + + +def narrative_functions_incompatible(a: str, b: str) -> bool: + """True when functions must not high-merge (reader vs writer).""" + pair = {(a or "").strip(), (b or "").strip()} + return pair == {"letter_reader", "letter_writer"} + + +def genders_conflict(a: str, b: str) -> bool: + """True when both genders are known and disagree as male vs female.""" + left = (a or "unknown").strip() + right = (b or "unknown").strip() + if left in {"", "unknown", "nonbinary"} or right in {"", "unknown", "nonbinary"}: + return False + return left != right and {left, right} == {"male", "female"} + + +def format_identity_line(name: str, canon: CharacterCanon) -> str: + """Prompt line: identity: Name (gender, narrative_function).""" + gender = canon.gender or "unknown" + function = (canon.narrative_function or "extra").strip() or "extra" + return f"identity: {name} ({gender}, {function})" + + +def portrait_gender_era_suffix(bible: VisualBible, canon: CharacterCanon) -> str: + """Extra portrait prompt clauses for gender + era wardrobe.""" + parts = [GENDER_NO_SWAP_LINE] + if canon.gender in {"male", "female", "nonbinary"}: + parts.insert(0, f"gender-locked {gender_prefix(canon.gender)}") + parts.append(wardrobe_banline_for_bible(bible)) + return ", ".join(parts) + + def is_illegal_character_name(name: str) -> bool: """True when ``name`` looks like English prose description, not a character label.""" text = (name or "").strip() @@ -148,11 +467,11 @@ def _hair_lock_from_canon_face(canon_face: str) -> str | None: return first_clause[:80].strip() -DEFAULT_OUTFIT_LOCK = "early 20th century European period clothing" +DEFAULT_OUTFIT_LOCK = "clothing matching the story setting and era" -def _default_outfit_lock() -> str: - return DEFAULT_OUTFIT_LOCK +def _default_outfit_lock(*, era: str = "", style_guide: str = "") -> str: + return default_outfit_for_era(era, style_guide) def ensure_stage_locks( @@ -160,13 +479,19 @@ def ensure_stage_locks( *, canon_face: str, canonical_name: str = "", + era: str = "", + style_guide: 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() + outfit_lock = repair_outfit_lock( + (stage.outfit_lock or "").strip(), + era=era, + style_guide=style_guide, + ) 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}" @@ -179,18 +504,44 @@ def ensure_stage_locks( ) -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() +def ensure_canon_locks( + canon: CharacterCanon, + *, + era: str = "", + style_guide: str = "", +) -> CharacterCanon: + """Normalize face/gender locks and ensure every stage has hair/outfit/portrait locks.""" + gender = infer_gender( + name=canon.canonical_name, + role=canon.role, + face_lock=canon.face_lock, + aliases=canon.aliases, + explicit=canon.gender or "unknown", + ) + face_lock = apply_gender_to_face_lock(canon.face_lock, gender) + function = infer_narrative_function( + name=canon.canonical_name, + role=canon.role, + explicit=canon.narrative_function or "", + ) stages = [ ensure_stage_locks( stage, canon_face=face_lock, canonical_name=canon.canonical_name, + era=era, + style_guide=style_guide, ) for stage in canon.stages ] - return canon.model_copy(update={"face_lock": face_lock, "stages": stages}) + return canon.model_copy( + update={ + "face_lock": face_lock, + "gender": gender, + "narrative_function": function, + "stages": stages, + } + ) def _canonical_for_character(state: ProjectState, name: str) -> str: @@ -282,7 +633,7 @@ def _drop_incompatible_aliases( def sanitize_visual_bible_state(state: ProjectState) -> bool: - """Clean polluted bible/character state and bump to bible_v2. Returns True if mutated.""" + """Clean polluted bible/character state and bump to bible_v3. Returns True if mutated.""" bible = state.visual_bible if bible is None: return False @@ -309,6 +660,11 @@ def sanitize_visual_bible_state(state: ProjectState) -> bool: del bible.characters[key] mutated = True + inferred_era = infer_era_text(bible.era, bible.style_guide) + if inferred_era != (bible.era or "").strip(): + bible.era = inferred_era + mutated = True + canon_alias_snapshot = {key: list(canon.aliases) for key, canon in bible.characters.items()} for key, canon in list(bible.characters.items()): @@ -320,13 +676,24 @@ def sanitize_visual_bible_state(state: ProjectState) -> bool: owner_canonical=key, alias_snapshot=canon_alias_snapshot, ) - fixed = ensure_canon_locks(canon.model_copy(update={"aliases": cleaned_aliases})) + fixed = ensure_canon_locks( + canon.model_copy(update={"aliases": cleaned_aliases, "role": owner_role or canon.role}), + era=bible.era, + style_guide=bible.style_guide, + ) + if fixed.gender == "unknown": + suggestion = suggestion_from_alias( + fixed.canonical_name or key, + "gender:unknown", + "gender could not be inferred; set male/female explicitly", + ) + _append_needs_review(state, suggestion) 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" + if bible.version != "bible_v3": + bible.version = "bible_v3" mutated = True old_hash = bible.content_hash @@ -362,10 +729,14 @@ def _bible_hash_payload(bible: VisualBible) -> dict: characters[name] = { "face_lock": canon.face_lock, "palette_notes": canon.palette_notes, + "gender": canon.gender, + "narrative_function": canon.narrative_function, "stages": stages, } return { "style_guide": bible.style_guide, + "era": bible.era, + "era_forbidden_wardrobe": list(bible.era_forbidden_wardrobe or []), "color": bible.color.model_dump(), "characters": characters, } @@ -415,6 +786,10 @@ def _upsert_canon(existing: CharacterCanon, incoming: CharacterCanon) -> Charact updates["palette_notes"] = incoming.palette_notes if incoming.role: updates["role"] = incoming.role + if incoming.gender and incoming.gender != "unknown": + updates["gender"] = incoming.gender + if incoming.narrative_function: + updates["narrative_function"] = incoming.narrative_function merged = existing.model_copy(update=updates) if updates else existing.model_copy(deep=True) for alias in incoming.aliases: @@ -446,6 +821,8 @@ def _install_reconcile_bible( out.visual_bible = VisualBible( version="bible_v1", style_guide=result.style_guide or "", + era=result.era or "", + era_forbidden_wardrobe=list(result.era_forbidden_wardrobe or []), color=result.color or ColorBible(palette=[], lighting="", forbidden=[]), characters={c.canonical_name: c for c in result.canons}, sheet_ref_local=None, @@ -463,6 +840,10 @@ def _install_reconcile_bible( if not bible.style_guide and result.style_guide: bible.style_guide = result.style_guide + if not bible.era and result.era: + bible.era = result.era + if result.era_forbidden_wardrobe and not bible.era_forbidden_wardrobe: + bible.era_forbidden_wardrobe = list(result.era_forbidden_wardrobe) if result.color_patches: _apply_color_patches(bible.color, result.color_patches) @@ -522,6 +903,30 @@ def _role_for_character(out: ProjectState, name: str) -> str: return "" +def _gender_for_character(out: ProjectState, name: str) -> str: + if out.visual_bible is not None: + canon = out.visual_bible.characters.get(name) + if canon is None: + canonical = _build_alias_to_canonical_map(out.visual_bible).get(name) + if canonical: + canon = out.visual_bible.characters.get(canonical) + if canon is not None and (canon.gender or "unknown") != "unknown": + return canon.gender + return "unknown" + + +def _narrative_function_for_character(out: ProjectState, name: str) -> str: + if out.visual_bible is not None: + canon = out.visual_bible.characters.get(name) + if canon is None: + canonical = _build_alias_to_canonical_map(out.visual_bible).get(name) + if canonical: + canon = out.visual_bible.characters.get(canonical) + if canon is not None and (canon.narrative_function or "").strip(): + return canon.narrative_function.strip() + return "" + + def apply_reconcile( state: ProjectState, result: VisualBibleReconcileResult, @@ -535,7 +940,15 @@ def apply_reconcile( 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): + gender_alias = _gender_for_character(out, merge.alias) + gender_canon = _gender_for_character(out, merge.canonical) + fn_alias = _narrative_function_for_character(out, merge.alias) + fn_canon = _narrative_function_for_character(out, merge.canonical) + if ( + roles_incompatible(role_alias, role_canon) + or genders_conflict(gender_alias, gender_canon) + or narrative_functions_incompatible(fn_alias, fn_canon) + ): suggestion = suggestion_from_alias(merge.alias, merge.canonical, merge.reason) _append_needs_review(out, suggestion) continue @@ -721,8 +1134,12 @@ def format_color_bible_block(bible: VisualBible) -> str: 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) + prefix = gender_prefix(canon.gender or "unknown") + face = (canon.face_lock or "").strip() + if prefix and not face.casefold().startswith(prefix.casefold()): + parts.append(prefix) + if face: + parts.append(face) if canon.palette_notes: parts.append(canon.palette_notes) stage_row = next((s for s in canon.stages if s.stage == stage), None) diff --git a/core/pipelines/creative_comic.py b/core/pipelines/creative_comic.py index c04da8c..27a0bb7 100644 --- a/core/pipelines/creative_comic.py +++ b/core/pipelines/creative_comic.py @@ -64,6 +64,7 @@ format_color_bible_block, l1_from_canon, parse_stage_ref, + portrait_gender_era_suffix, refresh_bible_hash, resolve_canonical_name, resolve_character_asset, @@ -1045,6 +1046,7 @@ async def _render_portrait( canon_prompt = l1_from_canon(canon, stage) if canon_prompt: prompt = canon_prompt + prompt = f"{prompt}, {portrait_gender_era_suffix(_state.visual_bible, canon)}" prompt = harden_human_identity_prompt(name, prompt) if _state.visual_bible is not None: color_block = format_color_bible_block(_state.visual_bible) diff --git a/core/schemas.py b/core/schemas.py index 37f8800..e354b4c 100644 --- a/core/schemas.py +++ b/core/schemas.py @@ -1099,6 +1099,9 @@ def _coerce_text_fields(cls, value: Any) -> Any: return coerce_str(value) +GenderLiteral = Literal["male", "female", "nonbinary", "unknown"] + + class CharacterCanon(BaseModel): """Canonical character identity with shared face lock and optional stages.""" @@ -1110,18 +1113,31 @@ class CharacterCanon(BaseModel): palette_notes: str = "" stages: list[CharacterStage] = Field(default_factory=list) role: str = "" + gender: GenderLiteral = "unknown" + narrative_function: str = "" @field_validator( "canonical_name", "face_lock", "palette_notes", "role", + "narrative_function", mode="before", ) @classmethod def _coerce_text_fields(cls, value: Any) -> Any: return coerce_str(value) + @field_validator("gender", mode="before") + @classmethod + def _coerce_gender(cls, value: Any) -> Any: + text = coerce_str(value).strip().casefold() + if text in {"male", "female", "nonbinary", "unknown"}: + return text + if not text: + return "unknown" + return "unknown" + @field_validator("aliases", mode="before") @classmethod def _coerce_aliases(cls, value: Any) -> Any: @@ -1140,16 +1156,23 @@ class VisualBible(BaseModel): version: str = "bible_v1" style_guide: str = "" + era: str = "" + era_forbidden_wardrobe: list[str] = Field(default_factory=list) 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") + @field_validator("version", "style_guide", "era", "content_hash", mode="before") @classmethod def _coerce_text_fields(cls, value: Any) -> Any: return coerce_str(value) + @field_validator("era_forbidden_wardrobe", mode="before") + @classmethod + def _coerce_era_forbidden_wardrobe(cls, value: Any) -> Any: + return coerce_str_list(value) + @field_validator("color", mode="before") @classmethod def _coerce_color(cls, value: Any) -> Any: @@ -1223,6 +1246,8 @@ class VisualBibleReconcileResult(BaseModel): keeps: list[VisualBibleKeep] = Field(default_factory=list) color_patches: list[ColorSwatch] = Field(default_factory=list) style_guide: str = "" + era: str = "" + era_forbidden_wardrobe: list[str] = Field(default_factory=list) color: ColorBible | None = None canons: list[CharacterCanon] = Field(default_factory=list) @@ -1246,11 +1271,16 @@ def _coerce_keeps(cls, value: Any) -> Any: def _coerce_color_patches(cls, value: Any) -> Any: return coerce_model_list(value, ColorSwatch) - @field_validator("style_guide", mode="before") + @field_validator("style_guide", "era", mode="before") @classmethod def _coerce_style_guide(cls, value: Any) -> Any: return coerce_str(value) + @field_validator("era_forbidden_wardrobe", mode="before") + @classmethod + def _coerce_era_forbidden_wardrobe(cls, value: Any) -> Any: + return coerce_str_list(value) + @field_validator("color", mode="before") @classmethod def _coerce_color(cls, value: Any) -> Any: diff --git a/core/screenwriter.py b/core/screenwriter.py index bd9b46a..8a26c14 100644 --- a/core/screenwriter.py +++ b/core/screenwriter.py @@ -278,7 +278,16 @@ async def reconcile_visual_bible( "(no 'man with dark hair, wearing a jacket').\n" "- Never high-merge incompatible roles " "(mother≠daughter, count≠novelist, servant≠master).\n" + "- Never high-merge conflicting genders (male≠female) or " + "letter_reader↔letter_writer.\n" + "- Fill project era once (e.g. 'Vienna c.1900–1910' or 'contemporary China') " + "and keep it stable across chunks; set era_forbidden_wardrobe when helpful.\n" + "- 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" + "- For historical eras use period wardrobe only — never hoodies, sneakers, " + "or athleisure.\n" "- portrait_key must be short form {canonical_name}@{stage} only " "(e.g. R@adult), never prose.\n" f"- {bible_note}\n" diff --git a/docs/superpowers/specs/2026-08-03-visual-bible-v3-design.md b/docs/superpowers/specs/2026-08-03-visual-bible-v3-design.md new file mode 100644 index 0000000..1afc202 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-visual-bible-v3-design.md @@ -0,0 +1,100 @@ +# Design: Visual Bible v3 — Era / Gender / Diegetic Gates + +**Date:** 2026-08-03 +**Status:** Approved (plan A) +**Depends on:** `docs/superpowers/specs/2026-08-02-visual-bible-v2-hardening-design.md` +**Product ask:** General pipeline fix for identity gender drift, period wardrobe anachronism, and diegetic prop gibberish — not novel-specific. + +## §1 Goals and non-goals + +### Goals + +- Lock project-level `era` and per-canon `gender` + `narrative_function` as structured fields. +- Sanitize polluted state: infer gender/function when safe, repair historical outfits that contain modern streetwear tokens, bump to `bible_v3`. +- Era-conditioned wardrobe defaults (historical ≠ forced Vienna 1900s for every project; contemporary does not get early-20th-century defaults). +- Generation gates: portrait and finished-page prompts inject explicit gender, era wardrobe banlines, and diegetic-text ban (blank paper / abstract ink — no letterforms). +- Demote high-merges that conflict on gender or `letter_reader` ↔ `letter_writer`. +- Soft-invalidate via fingerprint when hardening mutates locks. + +### Non-goals + +- VLM / post-hoc image QA loops. +- Composition de-duplication or narrative pacing. +- Per-novel hardcoding (e.g. Zweig-only rules). +- Pixel-editing existing project PNGs (re-run regenerates). +- Phase C visual sheet generation. + +## §2 Problem statement (verified generally) + +| Symptom | Mechanism | +|---|---| +| Famous male character drawn as glamorous woman | No `gender` field; face_lock lacks sex; model defaults female | +| Hoodie / sneakers in period stories | Soft period prompt; outfit_lock may contain modern tokens; default outfit always “early 20th century European” | +| Contemporary novels forced into 1900s dress | Same hardcoded `DEFAULT_OUTFIT_LOCK` | +| Letter/book props full of gibberish glyphs | Deferred lettering bans chrome only; diegetic prop text still generated | +| Reader vs writer identity confusion | No `narrative_function`; merges/aliases can collapse distinct roles | + +## §3 Schema + +### VisualBible + +- `era: str` — free-text era lock (`Vienna c.1900–1910`, `contemporary China`, `unspecified`, …) +- `era_forbidden_wardrobe: list[str]` — optional; if empty, derive from era class +- `version` → `"bible_v3"` after sanitize/apply hardening + +### CharacterCanon + +- `gender: Literal["male","female","nonbinary","unknown"]` (default `unknown`) +- `narrative_function: str` — `letter_reader` | `letter_writer` | `protagonist` | `love_interest` | `servant` | `parent` | `child` | `extra` | `""` + +### VisualBibleReconcileResult + +- Add `era: str` (and optional forbidden list if present on bible) so create/update can set project era once. + +## §4 Sanitize and apply + +`sanitize_visual_bible_state`: + +1. Keep v2 illegal-name / role-alias cleanup. +2. Infer / fill `era` from existing `era` or `style_guide` heuristics; classify `historical` | `contemporary` | `unspecified`. +3. Per canon: infer `gender` and `narrative_function` when markers are clear; leave `unknown` / push `needs_review` when not (do not invent). +4. Prepend idempotent gender phrase to `face_lock` (`adult man,` / `adult woman,`). +5. Historical: rewrite outfit_locks containing modern tokens to era-safe defaults; fill blanks with era-derived outfit (not a fixed Vienna string when era text exists). +6. Contemporary: blank outfits get a neutral modern-casual default — **never** early-20th-century European. +7. Set `version = "bible_v3"`, refresh hash (payload includes era/gender/function/outfit). + +`apply_reconcile` high-merge demotion also when: + +- genders conflict (`male` vs `female`), or +- narrative functions are `letter_reader` vs `letter_writer`. + +## §5 Prompt gates + +- `l1_from_canon` leads with gender phrase when known; includes locks. +- Portrait path: gender + era wardrobe + “single human matching gender; no gender swap”. +- Page prompt when bible present: + - Era-conditioned wardrobe banline (replace always-on early-20th line). + - Per-character `identity: {name} ({gender}, {narrative_function})`. + - When `lettering=deferred`: `DIEGETIC_TEXT_LINE` — letters/books/newspapers/signs show blank aged paper or abstract ink only; no letterforms / pseudo-script. + +## §6 Fingerprint + +- Token: `visual_bible: "bible_v3"`. +- `bible_hash` includes era + gender + narrative_function + locks. + +## §7 Testing + +- Gender inference + face_lock prefix; conflicting-gender merge demotion; letter_reader↔writer demotion. +- Historical outfit strip; contemporary does not force 1900s default. +- Page prompt contains gender/era/diegetic lines; fingerprint `bible_v3`. +- Sanitize bumps version. + +## §8 Files + +- `docs/superpowers/specs/2026-08-03-visual-bible-v3-design.md` +- `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_v3.py` (+ fingerprint/prompt test updates) diff --git a/tests/test_finished_page_pipeline.py b/tests/test_finished_page_pipeline.py index 2a834a4..aff22da 100644 --- a/tests/test_finished_page_pipeline.py +++ b/tests/test_finished_page_pipeline.py @@ -191,6 +191,36 @@ def test_render_fingerprint_uses_bible_v2_token(): assert fp == expected +def test_render_fingerprint_uses_bible_v3_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", + ) + 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_v3", + "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( diff --git a/tests/test_visual_bible_v2.py b/tests/test_visual_bible_v2.py index 2b8e200..8d3f75a 100644 --- a/tests/test_visual_bible_v2.py +++ b/tests/test_visual_bible_v2.py @@ -234,7 +234,7 @@ def test_sanitize_removes_prose_character_and_bad_alias(): ) assert sanitize_visual_bible_state(state) is True assert prose not in state.characters - assert state.visual_bible.version == "bible_v2" + assert state.visual_bible.version == "bible_v3" assert "帝国伯爵" not in state.visual_bible.characters["R(小说家)"].aliases assert state.visual_bible.characters["R(小说家)"].stages[0].portrait_key.startswith("R") diff --git a/tests/test_visual_bible_v3.py b/tests/test_visual_bible_v3.py new file mode 100644 index 0000000..f6fe995 --- /dev/null +++ b/tests/test_visual_bible_v3.py @@ -0,0 +1,230 @@ +# tests/test_visual_bible_v3.py +from core.comic.page_prompt import render_finished_page_prompt +from core.comic.visual_bible import ( + apply_gender_to_face_lock, + apply_reconcile, + classify_era, + default_outfit_for_era, + genders_conflict, + infer_gender, + infer_narrative_function, + l1_from_canon, + narrative_functions_incompatible, + repair_outfit_lock, + sanitize_visual_bible_state, + wardrobe_banline_for_bible, +) +from core.schemas import ( + CharacterAsset, + CharacterCanon, + CharacterStage, + ColorBible, + ComicPagePlan, + ProjectState, + VisualBible, + VisualBibleMerge, + VisualBibleReconcileResult, +) + + +def test_infer_gender_from_role_and_aliases(): + assert ( + infer_gender( + name="R", + role="novelist", + aliases=["男人(被叙述者)"], + explicit="unknown", + ) + == "male" + ) + assert infer_gender(name="母亲(寡妇)", role="widow", explicit="unknown") == "female" + assert infer_gender(name="X", role="", face_lock="calm eyes", explicit="unknown") == "unknown" + + +def test_apply_gender_to_face_lock_is_idempotent(): + once = apply_gender_to_face_lock("calm dark eyes", "male") + assert once.startswith("adult man") + twice = apply_gender_to_face_lock(once, "male") + assert twice == once + + +def test_genders_and_functions_conflict(): + assert genders_conflict("male", "female") is True + assert genders_conflict("male", "unknown") is False + assert narrative_functions_incompatible("letter_reader", "letter_writer") is True + assert narrative_functions_incompatible("protagonist", "extra") is False + + +def test_apply_reconcile_demotes_conflicting_gender_merge(): + state = ProjectState( + project_id="t", + characters={ + "R": CharacterAsset(name="R", role="novelist"), + "陌生女人": CharacterAsset(name="陌生女人", role="narrator"), + }, + visual_bible=VisualBible( + version="bible_v2", + style_guide="Vienna 1900 period", + era="Vienna c.1900–1910", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "R": CharacterCanon( + canonical_name="R", + role="novelist", + gender="male", + narrative_function="letter_reader", + face_lock="calm eyes", + stages=[], + ), + "陌生女人": CharacterCanon( + canonical_name="陌生女人", + role="narrator", + gender="female", + narrative_function="letter_writer", + face_lock="wistful eyes", + stages=[], + ), + }, + content_hash="x", + ), + ) + out = apply_reconcile( + state, + VisualBibleReconcileResult( + merges=[ + VisualBibleMerge( + alias="陌生女人", + canonical="R", + confidence="high", + reason="same person", + ) + ], + canons=[], + ), + ) + assert any(s.new_name == "陌生女人" and s.candidate == "R" for s in out.needs_review) + assert "陌生女人" not in (out.visual_bible.characters["R"].aliases if out.visual_bible else []) + + +def test_historical_outfit_repairs_modern_tokens(): + repaired = repair_outfit_lock( + "light-colored sporty jacket, sports shoes", + era="Vienna c.1900–1910", + style_guide="European period", + ) + assert "sport" not in repaired.casefold() + assert "period" in repaired.casefold() or "1900" in repaired + + +def test_contemporary_default_outfit_not_1900s(): + assert classify_era("contemporary China", "") == "contemporary" + outfit = default_outfit_for_era("contemporary China") + assert "20th century" not in outfit.casefold() + assert "european" not in outfit.casefold() + blank = repair_outfit_lock("", era="contemporary China") + assert "20th century" not in blank.casefold() + + +def test_l1_leads_with_gender(): + canon = CharacterCanon( + canonical_name="R", + gender="male", + face_lock="adult man, calm eyes", + stages=[ + CharacterStage( + stage="adult", + outfit_lock="dark tailored suit", + hair_lock="dark swept hair", + portrait_key="R@adult", + ) + ], + ) + text = l1_from_canon(canon, "adult") + assert text.lower().startswith("adult man") + assert "calm eyes" in text + + +def test_sanitize_bumps_to_bible_v3_and_fills_gender(): + state = ProjectState( + project_id="t", + characters={ + "R": CharacterAsset(name="R", role="novelist", aliases=["男人(被叙述者)"]), + }, + visual_bible=VisualBible( + version="bible_v2", + style_guide="Melancholic, early-20th-century European atmosphere", + era="", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "R": CharacterCanon( + canonical_name="R", + role="novelist", + gender="unknown", + face_lock="calm detached expression", + aliases=["男人(被叙述者)"], + stages=[ + CharacterStage( + stage="teen", + outfit_lock="sporty jacket and sports shoes", + hair_lock="", + portrait_key="R@teen", + ) + ], + ) + }, + content_hash="old", + ), + ) + assert sanitize_visual_bible_state(state) is True + assert state.visual_bible is not None + assert state.visual_bible.version == "bible_v3" + assert state.visual_bible.era + canon = state.visual_bible.characters["R"] + assert canon.gender == "male" + assert canon.face_lock.lower().startswith("adult man") + assert not any("sport" in s.outfit_lock.casefold() for s in canon.stages) + + +def test_page_prompt_includes_era_gender_diegetic(): + bible = VisualBible( + version="bible_v3", + style_guide="manhua muted European period", + era="Vienna c.1900–1910", + color=ColorBible(palette=[], lighting="", forbidden=[]), + characters={ + "R": CharacterCanon( + canonical_name="R", + gender="male", + narrative_function="letter_reader", + face_lock="adult man, calm dark eyes", + stages=[], + ) + }, + content_hash="x", + ) + plan = ComicPagePlan.model_validate( + { + "page_id": "p1", + "purpose": "reads letter", + "layout_intent": "focus", + "panels": [{"panel_id": "1", "characters": ["R"], "action": "R reads a letter"}], + } + ) + text = render_finished_page_prompt( + plan, + characters_by_name={"R": CharacterAsset(name="R", l1_prompt="old loose")}, + settings_by_name={}, + visual_bible=bible, + lettering="deferred", + ) + lower = text.lower() + assert "diegetic" in lower or "pseudo-script" in lower or "blank aged paper" in lower + assert "identity: r (male, letter_reader)" in lower + assert "vienna" in lower or "period-accurate" in lower or "hoodie" in lower + ban = wardrobe_banline_for_bible(bible) + assert "Era lock" in ban or "period" in ban.casefold() + + +def test_infer_narrative_function_letter_roles(): + assert infer_narrative_function(name="陌生女人", role="叙述者") == "letter_writer" + assert infer_narrative_function(name="老约翰", role="男仆") == "servant"