Files
kami ff6a512630 Reconstruct repo from Claude Code + codex transcripts
Working tree (including .git) was lost to an rm. Rebuilt by replaying Write/Edit/
Read/attachment events from 25 Claude sessions and 22 successful codex apply_patch
blocks into one timestamp-ordered timeline.

Verified against ground truth recorded in the transcripts: wc -l on 10 files and
ls -l on 5 files at 2026-07-18T13:13:44Z both match exactly; 18 files are
byte-identical to their newest ~/.claude/file-history blob.

See HANDOFF.md for sources, gaps, and how to rebuild .venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:42:41 +04:00

349 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
this is a v3 delta spec: it lists what changes on top of the working v2 pipeline (see spec-v2.md).
v2 is validated end-to-end (a full chapter renders to one narrated mp4). v3 is about quality of the
narration and the look of the video, plus panel curation. structured for hand-off to an agent.
# manga recap v3 — quality + look
## 0. what v2 does today (baseline)
- one narrated `chapter.mp4` per chapter: fit-on-black panels, uniform ken-burns zoom, burned
subtitles, hard cuts, single default TTS voice.
- identity assigns a stable `character_id` per body (siglip2), but characters are unnamed, so the
script prints "Person A/B". the script worker sees one panel at a time (no memory).
- every panel is narrated (no curation).
---
## A. cross-panel identity & continuity [biggest quality lever] [DONE]
SHIPPED:
- descriptive frozen labels: worker_scene emits `label` per character (name -> frozen registry
appearance via `_describe`, "the one with black hair and glasses" -> Person X fallback);
worker_script `_name_map` consumes it. no "Person A" when appearance is known, char_id preserved.
- chapter_context populated: service.py `_chapter_context` feeds a cast+entities roster to every
script call so the narrator has memory (kills "Unknown voice", item 11).
- name acquisition + backfill (item 12): vision emits `name` only when a char is explicitly named;
service.py scene stage binds it via `set_character_name` (first name wins, only if unnamed);
script stage resolves display names from the FINAL registry (`names_by_id`) so a name learned in
ANY panel backfills to every panel's narration. ceiling: vision name-inference can misattribute;
first confident name sticks. upgrade path = roster seed for higher precision.
STILL TODO (small, deferred):
- true per-panel story-state (only what's been seen so far) for "establishing shot once / don't
re-narrate repeats" (the C-todo) — currently whole-manga roster.
--- original plan ---
one feature: carry a running **story-state** through a chapter so narration has memory. covers
former items 4, 11, 12.
- **stable anchor, mutable label.** the `character_id` is never lost (already stable via siglip2).
only its *display label* changes:
- name known -> the name.
- name unknown -> a **descriptive phrase** ("guy with black hair and glasses", "girl with short
hair holding a phone") derived ONCE from vision's appearance data and frozen per character_id,
reused across all panels. never regenerate per-panel (labels would drift).
- **name acquisition + backfill.** when dialogue addresses a character by name and speaker
attribution is confident, bind name -> character_id and switch the label to the name from then on
(optionally backfill earlier references).
- **story-state threaded per chapter.** accumulate {cast seen (id,label,name?), entities/props seen
(the cat)} and feed it into scene+script (populate the currently-empty `chapter_context`). this is
what lets the narrator say "he calls out to his cat" instead of leaking a bare name (item 12), and
removes "Unknown voice" (item 11).
- **optional roster seed (not required).** a per-manga character list (names, maybe ref art) makes
naming + cross-panel matching more reliable than dialogue inference. graceful-degrade without it;
add when available.
touches: worker_vision (appearance -> label), worker_scene / orchestrator (story-state store +
speaker continuity), worker_script (consume context), orchestrator characters table (label, name).
## B. narration voice & style (items 2, 3) [DONE]
- **less descriptive gemma** (item 2): DONE. prompt now demands TIGHT recap (1-2 sentences, ~35
words, "no purple prose"); max_tokens 512->160, temp 0.7->0.6 (worker_script).
- **one fixed voice** (item 3): DONE. dots.tts samples a RANDOM speaker per call with no reference,
so the voice drifted. worker_tts now bootstraps one seeded reference clip (`~/.cache/manga-tts/
narrator_ref.wav`) and clones from it every synth. delete the file to reroll; swap REF_WAV/REF_TEXT
for a hand-picked voice.
- **multi-voice per character: future**, not v3 (user: "later") — same mechanism, per-character ref clips.
## C. panel curation (item 10) [DONE — basic]
- skip panels with no narrative content (pure scenery / transition art); show an establishing shot
once, don't re-narrate repeats. vision flags "scenery-only / skip". big pacing + cost win; do early
because it shrinks every downstream stage.
- SHIPPED: vision emits `skip:true` for no-narrative panels (worker_vision prompt+schema); the
script stage honors it (service.py run_stage_script) -> no narration -> cascades to no audio, no
clip. Single chokepoint, no new db columns (skip rides in the vision result_json blob).
- STILL TODO (needs cross-panel memory, folds into A): "establishing shot once, don't re-narrate
repeats" — requires dedup of repeated scenery across panels. deferred to story-state.
## D. render / look overhaul (items 1, 5, 6, 7, 8)
all in worker_render.py (self-contained ffmpeg builder), except #6.
- **#1 dynamic clip duration** — DONE. `_audio_dur` probes narration length via ffprobe; scene_timing
kept only as fallback. no more 4.0s sawtooth / vanishing subs.
- **#5 blurred-panel background fill** — DONE. filter_complex: cover-scaled + boxblur copy behind the
fitted panel (replaces black bars).
- **#7 subtitles** — DONE. boxed caption: bold white on translucent black box (BorderStyle 3),
lower-center, DejaVu Sans 52. (user chose boxed over outline/minimal.)
- **#8 content-aware motion** — DONE. vision picks a camera.effect per panel from the fixed vocab
(+ to=[x,y] for dolly_to_subject); it rides the vision result_json -> scene_graph.camera ->
render. worker_render `_motion(camera, frames)` maps the vocab to a zoompan z/x/y expression
(static/zoom/pan/dolly/shake/orbit); default zoom_in = old ken burns. orchestrator render stage
reads scene_graph.camera. self-checked (ffmpeg renders pan + dolly clips).
- **#6 multi-panel composites + non-generic transitions** [the ambitious one, sequenced last]
- **non-generic transitions — DONE.** vision emits `transition` (cut|crossfade|dissolve|
fade_black|fade_white|wipe_left|wipe_right|push) per panel -> scene_graph.transition ->
orchestrator assemble collects a per-clip list -> worker_render `_xfade_chain` builds an
xfade+acrossfade graph (audio kept in sync, overlap clamped to clip length). all-cut/empty
keeps the fast stream-copy concat (no re-encode); any real transition re-encodes the chapter.
self-checked (two clips fade_white into one mp4). transition = OUT of that panel.
- **multi-panel composites — DONE (basic).** grouping source = new pass (`grouping.py`,
deterministic): consecutive SMALL panels (area < 0.7*page-max, same source page, capped at
MAX_GROUP=3) share ONE shot. render + assemble both call plan_groups so their grouping matches.
a composite = vertical stack of the members on a W×H still; each panel keeps its own script+tts
(per-panel stages unchanged); the audios play in sequence while a moving highlight marks the
active row ("swap the front panel while audio plays"). clip keyed by the group LEADER; assemble
takes one clip per group and the group's exit transition = last member's. solo groups render
exactly as before, so blast radius is only genuinely-small adjacent panels.
knobs: MAX_GROUP, SMALL_FRAC. ceilings: vertical-stack layout only (no grid/overlay/front-swap
animation beyond the highlight), size+adjacency grouping (no narrative-beat grouping), black
gutters (no blurred fill in composites). upgrade paths noted in code.
## D2. beat pacing, timed cues, subtitle redesign, collage (vikunja 185/187/188/183)
scene-level narration made the beat (not the panel) the render unit; these refine how a beat plays.
all in worker_render.py + collage.py, orchestrator run_stage_render.
- **185 content-aware panel timing — DONE.** a beat's one narration used to split EQUALLY across its
member panels (`D/n`). now the orchestrator scores each member from its scene graph (dialogue amount,
action density, camera emphasis: `_beat_weights`) and passes bounded weights to render; worker
`_beat_slices(D, n, weights)` distributes screen-time by weight with a per-panel floor (no flashing)
and a deterministic equal-split fallback (degenerate/short weights). self-checked.
- **187 timed subtitle cues — DONE.** the whole narration paragraph used to show from frame 1, leaking
later-panel lines early. `cue_plan` splits the flowing narration into sentence cues, times them on the
SAME [0,D] timeline as the panel slices (length-weighted + readable floor, reused `_beat_slices`),
maps each cue to the panel on screen at its start, and clamps a cue never to precede its panel. per-cue
ASS events (not one paragraph). cue plan returned + persisted to `clips.cues_json` for the review UI /
collage. no-TTS-alignment fallback = punctuation/length estimate. self-checked ("Hold up." can't
appear before its panel).
- **188 subtitle redesign — DONE.** `_ass_multi` now takes mode (off|minimal|boxed, env `SUB_MODE`,
default minimal) + orientation presets (portrait/landscape safe-areas). smaller type (40/32 vs 52),
minimal = thin outline + soft shadow (no box), boxed = restrained ~50% box. `_wrap2` caps every cue at
≤2 short lines (ellipsis truncation) so it never regrows into a paragraph. `_sub_align` dodges the
subject vertically using the director focus point (camera.to.y low -> caption to top). ceiling: no
real face/bubble CV yet — camera focus is the only subject signal wired.
- **183 animated manga collage — DONE (core + wired behind `COLLAGE` flag).** `collage.py plan_layout`
is a pure deterministic planner: ordered panel aspects + RTL + dominant + frame -> template
(centered_wide / vertical_pair / stacked_wides / strip_over_dominant / supporting_left_dominant_right
/ quad), aspect-correct resting rects (no stretch), entrance vectors (RTL slide / dominant scale),
z-order, hold/transition timing. `worker_render collage_cmd` renders it: blurred plate of the dominant
panel + sharp aspect-fit panels with restrained drop shadows, non-dominant panels sliding into place
over ~0.45s, crisp holds; reuses 185 slices + 187 cues + 188 subtitles. orchestrator run_stage_render
calls it when `COLLAGE=1` (else the ken-burns montage). ceilings (QA-tuned against the reference video,
no still to eyeball here): transition-only directional/radial MOTION BLUR not applied (clean
slide/scale); per-panel hold DRIFT omitted (static hold); beat-replace "streak" rides the assemble
xfade. planner + renderer both self-checked; visual acceptance is the QA run.
## D3. identity tracklets + review gates (vikunja 160/136)
- **160 tracklet spine — DONE (code, QA-pending).** the Tier-2 gemma decider (identity phase-2) used to
resolve identity once PER CROP, so one person got adjudicated repeatedly and could get two answers on
two panels. `tracklets.py link_tracklets` (pure, union-find, self-checked) first LINKS the per-panel
siglip shortlists into per-person tracklets over a locality window — hard gender gate + appearance-token
overlap + shared-candidate — then the resolver runs gemma ONCE per tracklet against the UNION of the
members' shortlists (the person's gallery, consensus-ordered) and applies that one character_id to every
crop in the tracklet. fewer gemma calls, one answer per person. ceiling: gemma NONE (cosine over-merged
onto an existing char) still isn't split to a fresh id — needs a 3rd siglip re-embed pass; add if the
NONE-rate is high.
- **136 review gates — DONE (code, QA-pending), off by default (`GATES=1`).** two human sign-off gates in
the pipeline loop: gate `script` pauses BEFORE tts/render, gate `audiovisual` pauses BEFORE assemble.
When enabled and unapproved the job goes `awaiting_review` and the loop returns; `/review/approve`
records the gate (`gates` table) and relaunches `_run_pipeline` (skips completed stages → resumes at the
gated stage). Gate-1: `/review/scripts` shows narration per panel grouped by beat + cheap textual flags
(`review_flags.py`: generic-handle / repeated / empty, self-checked; semantic flags — spoilers, altered
meaning, attribution — left to the human eye, ceiling noted). Gate-2: `/review/audio` serves the per-panel
wav so the reviewer actually listens; `/review/retts` re-synthesizes ONE panel's TTS + re-renders just its
beat clip (delete_clip forces it) and clears the AV gate. web UI (index.html review tab): gate-approve
buttons + status, per-panel audio player, flags badge, re-synthesize button. reuses existing
/review/panels + /review/script + /review/preview. ceiling: no panel-to-cue drag-edit UI (cues persisted
in clips.cues_json, edit endpoint not built); flags are textual only.
## E. reference
generic manga-recap youtube style (single narrator, static panels w/ slow pan/zoom, minimal text,
dramatic pacing) — but with **less generic transitions** (feeds #6). #6 goes beyond the reference.
---
## sequencing (proposed)
1. **C panel curation** — shrinks everything downstream first.
2. **A identity/continuity** — most changes how it *feels* (names + memory).
3. **B voice+style** — small, high impact.
4. **D basics (#1,#5,#7,#8)** — most changes how it *looks*, low risk.
5. **D#6 compositing** — last; highest risk/effort, isolated to render.
## direction schema (decided — enables #8 and #6)
narration (text->TTS) and direction (camera/transition/layers->render) are separate concerns from
the SAME scene graph. VISION picks the effect (it sees the panel + has char bboxes for focus points)
and emits a direction block into the scene_graph JSON; RENDER just executes it. fixed vocab:
- camera.effect: static | zoom_in | zoom_out | pan_left | pan_right | pan_up | pan_down |
dolly_to_subject | orbit | shake | hold (+ from/to [x,y] normalized, easing)
- transition.type: cut | crossfade | fade_black | fade_white | wipe_left | wipe_right | push | dissolve
(+ duration)
- layers: {artifact, motion} (parallax, when the depth renderer is wired)
not built yet; lands before #8 (needs camera) and #6 (needs transition).
## open design points
- roster seed format if/when available.
- B#3: which fixed dots.tts reference voice to pin for the whole video (needs a pick).
---
# v3.1 delta — identity is broken; add human-in-loop review
## evidence (last job, 116-panel Teto X Egen, from manga.db)
- **53 character rows for ~8 actual people.** "Seonho" stored 5× (ids 85ad36b0/811bf0d7/0033ff7f/
bbddf20c/90269899), Egen Girl 2×. ~40 rows have a single identity_assignment (one-time NPCs).
- **root cause: siglip2 over-splits.** same person across panels (pose/expression/B&W line art)
doesn't clear cosine 0.85, so nearly every appearance spawns a new character_id.
- **cascade:** names never propagate (panel 10 names id A, panel 51's Seonho is id C -> `names_by_id`
can't map it -> narration falls back to "the woman with straight dark hair", flips gender to "her").
re-introductions + DB/S3 bloat all follow from the split.
- **concrete bug:** `worker_identity.py` create call hardcodes `"name": None` — vision reads the name
but identity discards it at registration. `db.create_character` is a blind INSERT, zero dedup.
## E1. pipeline decomposition [DECIDED — the vision mega-call splits]
Reason: one gemma4 call doing 4 jobs (detect + dialogue + direction + scene) does each worse, and
there was nowhere to run a real cross-panel merge. Split the jobs; insert a merge stage where the
model actually compares two crops.
**Final stage order** (OCR removed — gemma4 transcribes bubble text image-natively; a `roster`
prepass now runs after crop, see below):
```
fetch → crop → roster → vision → identity → reconcile → dialogue → direct → scene → script → tts → layers → render → assemble
```
- **`vision`** (per panel, image call) — character DETECT only: `local_id`, `bbox`, `appearance`
(hair/clothing/features), `gender` (m|f|unknown), `emotion`, `action`. plus `skip` + `scene`
(location/time). the only stage that must precede identity (identity crops its bboxes).
- **`identity`** (per panel) — siglip assigns `local_id → character_id`. on a collision (siglip
ambiguous OR hair+clothing appearance overlap OR name collision) it records a **merge-candidate
pair** instead of silently minting a twin. still creates provisional ids; reconcile prunes them.
- **`reconcile`** (NEW, once per chapter, after all panels) — the second vision pass. for each
flagged candidate pair, `POST worker_vision /vision/same` with the two `ref_image_uris` crops ->
gemma4 "same character? {yes|no, confidence}". confirmed -> `db.merge_characters(loser->keeper)`:
repoint `identity_assignments`, union `name`/`aliases`/`ref_image_uris`, delete loser row. loser
ref crops are PRESERVED on the keeper as gallery views (not deleted from S3 — deleting them left the
keeper's own uris dangling). cast is CLEAN + named from here on. only flagged pairs get a call.
lives as `run_stage_reconcile` in service.py + `/vision/same` on worker_vision (reuses warm gemma4).
- **`dialogue`** (per panel, image call) — gemma reads bubble text straight from the panel image (no
external OCR): bubble classify (speech/thought/shout/narration/sfx), merge split bubbles, text
cleanup (sentence case, fix names/proper nouns, sfx literal), speaker attribution
(`speaker=local_id` + `confidence`), entities. prompt gets the CLEAN
named cast present in the panel as context -> better attribution ("Seonho (m) vs Haeseon (f)").
- **`direct`** (per panel, off scene understanding) — `camera.effect` (+`to`) and `transition` from
the fixed direction vocab (unchanged schema, section D/direction-schema). runs after identity so it
can weight who's present. may glance at the image or work from the scene graph — cheap.
new workers/endpoints: `worker_vision` becomes detect + `/vision/same`; new `worker_dialogue.py` and
`worker_direct.py` (or `/dialogue` + `/direct` endpoints — same warm gemma4, decide at build like
reconcile). db: `STAGES` gains `reconcile`,`dialogue`,`direct`; `run_stage_*` for each; session_proxy
call_* wrappers. the old omni-`vision` prompt/schema is partitioned across the four prompts.
## F. identity overhaul [rides on E1]
Goal: 53 rows -> ~12; named recurring cast stays one id; NPCs never persist.
- **F1 merge = the `reconcile` stage** (see E1). the fix for the hardcoded `"name": None` bug: vision/
identity now carry `name`+`gender`+`appearance` into `create_character` so provisional rows are
named/typed; reconcile does the actual dedup with the model comparing crops (not a blind string
match). `db.merge_characters` + candidate-flagging in identity.
- **F2 gender field.** add `gender TEXT` to characters schema. `vision` emits `gender`. worker_script
`_name_map` feeds it to the model ("Seonho (he)") for correct pronouns — kills "her bold
personality" flips even when the name resolves.
- **F3 confirm-before-persist.** hold unnamed provisional embeddings in a per-session cache; only
write a DB row + S3 crop/npy when an embedding is seen >=2x OR is named. the ~40 one-timers never
touch the bucket. session_id already flows into identity. (complements reconcile: reconcile merges
the twins that WERE created; F3 stops most from being created at all.)
- **F4 centroid + re-rank matching.** store multiple refs per char, match against the accumulated
centroid not one arbitrary embedding; break siglip ties by name/gender agreement. ceiling: perfect
auto-ID on B&W is not achievable — reconcile+F4 get ~80%, the UI (G) closes the rest. don't
over-invest.
- **DECIDED: start clean.** no migration script. wipe manga.db + the minio `manga` bucket before the
first v3.1 run (destructive ops step, run at build time). fixed logic applies to fresh data.
## G. review / edit UI (replaces the auto CoT-strip idea, item C-bleed)
Rationale: the human reviews the final video and knows exactly what's wrong and where. Give them a
synced editor instead of chasing every failure through the pipeline. ~80% of infra already exists in
the orchestrator: `static/index.html` dashboard, `/job/video` (streams mp4), `/db/tables` + `/db/table`,
`/stage/run` + `/stage/clear` (re-render), `set_character_name`.
- **timestamp -> panel is trivial:** clips concat in `panel_order`, so panel start offset = cumsum of
prior panels' `audio.duration`. store the offset at assemble time (or compute on the fly) as an
offset table the page binary-searches on `<video>` timeupdate.
- **view:** `<video>` + side panel that, for the current panel, shows editable: script text, assigned
characters (name / gender / appearance), camera.effect, transition.
- **edits -> small endpoints** (most exist): add `set_gender`, `merge_characters(ids[]->keep)`,
`set_script(panel_id,text)`, `set_camera(panel_id,effect)`. **merge_characters is where the Seonho
mess gets fixed in seconds** — and the merge is ground truth that feeds F.
- **re-render:** "re-render from stage X" button = existing `/stage/clear` + `/stage/run` on the
affected panels/clips. effect preview = dropdown of `_motion` vocab + `XFADE` map, re-render one clip.
- **lazy ceiling:** no DAW timeline, no non-destructive edit graph. "video + synced editable form +
re-render button" over existing data/endpoints. ~1 day. highest-leverage addition: turns every
future quality bug into a 30-second manual fix.
## v3.1 sequencing
1. **E1 decomposition** — split vision into `vision`/`dialogue`/`direct`, add `reconcile` +
`direct` + `dialogue` to STAGES with stage runners. Foundation; F1 lives inside it.
- 1a. `vision` -> detect-only (+ `gender`, F2). 1b. `dialogue` stage. 1c. `direct` stage.
- 1d. `identity` candidate-flagging + `db.merge_characters`. 1e. `reconcile` stage + `/vision/same`.
2. **F3** (confirm-before-persist) — kills NPC bloat.
3. **G** (review UI) — builds on the cleaner DB; closes the residual reconcile/F4 can't.
4. **F4** (centroid/re-rank) — only if G shows auto-ID still too noisy to be worth hand-fixing.
Build order note: each of 1a1e is independently deployable/testable (workpc workers reload via
tmux; orchestrator needs a container rebuild). Do a full clean re-run after 1e before starting G.
## D#6 revisited — camera-traversal composite [DONE 2026-07-14]
the vstack composite was the wrong look; target = camera moving across panels in their REAL page
positions (motion-comic page traversal, partial-neighbor reveals). geometry already exists: panels
store `bbox` + `page_index` + `panel_order`, source page is retained. composite feeds a constant 9:16
crop-window that HOLDS on each member then quick-eases (0.5s) to the next (`traverse_cmd`,
`_pan_expr` in worker_render.py). page uri is reconstructed from `page_key(...)` (deterministic — no
schema change). vstack kept as the fallback when bbox/page_uri are missing.
orchestrator TODO: `run_stage_render` composite branch must pass each member's `bbox` + one
`page_uri` into `call_render_composite`.
## E2 — chapter roster / context pre-pass [SPEC — not built]
**problem.** every stage sees one panel at a time, so nobody has a chapter-level view. symptoms in
the wild: the MC is "the man in the yellow shirt" at panel 7 but named at 10 (name learned too late
to backfill cleanly); unnamed extras get re-described every panel; bubble-only panels (p005) have no
cast to attribute a line to; the narrator's tone/naming drifts. `_chapter_context` today is just the
DB registry accumulated *so far* — partial and late.
**idea.** one cheap gemma4 pass up front that reads the pages (text image-natively) and produces a
chapter view that grounds the per-panel stages. it does NOT replace vision/identity — it's coarse
context only, threaded back as NAME hints (never identity evidence).
**stage.** new `roster` stage, placed **after `crop`, before `vision`**. names surface in captions/
dialogue on the page images themselves (e.g. "Lim Seonho, Designer at Everyday"). one artifact per
chapter, stored on `chapters.roster_json`, resumable (skip if present), non-blocking on failure.
**method (implemented).** one multi-image `/roster` call over an even spread of ~6 chapter pages
(`ROSTER_SAMPLE_PAGES`) -> `{premise, characters:[{name,aliases,gender,species,description}]}`.
threaded into: vision detect (`known_characters` hints, registry wins), dialogue (off-panel cast for
bubble-only panels), script (`_chapter_context` premise). ceiling: sampled pages; widen the sample if
recall bites.
**output (stored per chapter, e.g. a `chapter_roster` blob):**
```
{"premise": "3-5 sentence chapter setup, present tense, no spoilers ahead",
"roster": [{"name":"Lim Seonho","aliases":["Seonho"],"gender":"m",
"appearance":"short brown hair, glasses, yellow shirt",
"role":"designer at Everyday; the MC"}]}
```
**consumers (all already have the hook, just feed them the roster instead of the thin registry):**
- `vision` (detect): pass roster as `known_characters` so detections carry a provisional name.
- `identity`/`reconcile`: seed name labels — a face gets its roster name on FIRST sighting, killing
"named at 10, described at 7". embeddings stay authoritative for who-is-who; roster only supplies
the label when appearance matches.
- `dialogue`: roster = the known cast for off-panel attribution (the p005 case now has names/handles
to point at instead of "".)
- `script`: seed `brief` from `premise` at panel 1 (no cold start); roster names -> `names_by_id` +
`introduced` from the start (stable handles, no re-description).
**caveats.** roster names are a COARSE read → HINTS, not ground truth: apply a roster name only when
a detected/identified character's appearance matches a roster entry; never override an embedding
match. one extra pass per chapter (latency/cost) — acceptable, it's once per chapter, warm session.