Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions core/comic/key_beats.py
Original file line number Diff line number Diff line change
@@ -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."
)
82 changes: 82 additions & 0 deletions core/comic/layout_diversity.py
Original file line number Diff line number Diff line change
@@ -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"
)
18 changes: 18 additions & 0 deletions core/comic/page_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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 ""
Expand Down
115 changes: 115 additions & 0 deletions core/comic/visual_bible.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading