diff --git a/AGENTS.md b/AGENTS.md index 04d8765..edcc215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Commands registered on the **`docgen`** CLI include: ## Implications for changes here - **Manim / `scenes.py` (marker blocks):** Fix generators under `src/docgen/**` (`manim_scene_support.py`, `scene_spec.py`, `scene_spec_generate.py`, `validate`, `yaml_generate`, tests). **Do not** patch generated classes inside a consumer's **`animations/scenes.py`** between **`BEGIN/END GENERATED SCENE`** markers; re-run **`scene-spec-generate`** / **`scene-compile --retime`** and **`manim`** instead. Preferred consumer order: narration → TTS → timestamps → scene-spec/compile → Manim → compose. The unit CI job runs ``pytest tests/``, which includes this module on a temp fixture: a hand-edited generated-region label or ``run_time`` fails ``scene_assets``. -- **Beat sync (fail-closed):** when `timing.json` has words, every story box label must match a spoken phrase (`wait_word`); unmatched labels and leftover LLM indices are rejected. Opt out with ``pace: none``. Legacy row-level ``wait_segment`` is upgraded to ``wait_word`` and written back on ``scene-compile`` (direct ``compile_scene_class`` still rejects leftover ``wait_segment``). Fuzzy containment matching is not used. **`scene-compile` clamps FadeIn / page-fade `run_time` against the next word start** so `_TimedScene._clock` cannot race past waits (issue #66 — do not emit cascading first-board dumps). After a reveal, a **dwell** slot may play `Indicate` / `Circumscribe` when the gap to the next `wait_word` is long enough (also clamped). Long holds emit additional mid-hold pulses (`timed_wait` + emphasis) so the board does not freeze after the first Indicate. Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions (`fade`, `slide`, `rise`, `zoom`, `none`) animate the previous page's revealed boxes off and, for slide / rise / zoom, bring the next page's first node in with the matching motion. They FadeOut those boxes individually, not the parent `VGroup`. Durations stay clamped like other reveals. `scene-compile` refreshes stale `_box` / `_arrow` / `_TimedScene` helpers in `scenes.py`. +- **Beat sync (fail-closed):** when `timing.json` has words, every story box label must match a spoken phrase (`wait_word`); unmatched labels and leftover LLM indices are rejected. Opt out with ``pace: none``. Legacy row-level ``wait_segment`` is upgraded to ``wait_word`` and written back on ``scene-compile`` (direct ``compile_scene_class`` still rejects leftover ``wait_segment``). Fuzzy containment matching is not used. **`scene-compile` clamps FadeIn / page-fade `run_time` against the next word start** so `_TimedScene._clock` cannot race past waits (issue #66 — do not emit cascading first-board dumps). After a reveal, a **dwell** slot may play `Indicate` / `Circumscribe` when the gap to the next `wait_word` is long enough (also clamped). Long holds emit additional mid-hold pulses (`timed_wait` + emphasis) so the board does not freeze after the first Indicate. Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions (`fade`, `slide`, `rise`, `zoom`, `none`) play the previous page's exit and the next page's first node in one `timed_play` (crossfade, slide, rise, or zoom) so the canvas does not empty between them. `none` still removes instantly. They FadeOut those boxes individually, not the parent `VGroup`. Durations stay clamped like other reveals. `scene-compile` refreshes stale `_box` / `_arrow` / `_TimedScene` helpers in `scenes.py`. - **Subject-beat coverage:** implemented in `scene_spec.layout_density_violations` / `cluster_subject_beats`; enforced by **`scene-spec-generate`** and **`validate`** (`validation.subject_beat_coverage.enabled`, default true). Not a blind label count. - **Image-prompt alignment:** implemented in `image_align.image_prompt_alignment_violations`; **`scene-spec-generate`** and **`image-generate`** reject prompts that share no documented terms; **`validate`** reports the same failures on **`scene_assets`** (`validation.image_prompt_alignment.enabled`, default true). `image-generate` also wraps the Images API prompt with narration/source (`image_generation.align_with_docs`, default true). - **Image-asset alignment:** OCR + vision review of the generated PNG (`docgen.image_align`). **`image-generate`** fails closed and retries once (`image_generation.align_review`, default true). **`validate`** folds the same pixel gate into **`scene_assets`** (`validation.image_asset_alignment`, OCR on by default, vision opt-in). diff --git a/docs/demos/hints/manim-scene-specs.md b/docs/demos/hints/manim-scene-specs.md index 83adbcb..d236529 100644 --- a/docs/demos/hints/manim-scene-specs.md +++ b/docs/demos/hints/manim-scene-specs.md @@ -3,7 +3,7 @@ Use these constraints when generating **`animations/specs/*.scene.yaml`** (via `docgen scene-spec-generate` or by hand): - **Rows of `_box`** in the spec compiler — short ASCII labels, no unicode arrows or smart punctuation in labels (use `->` or hyphen). Optional **`shape`** (`rounded` | `pill` | `diamond`), **`reveal`** (`fade` | `grow` | `slide`), and **`emphasis`** (`none` | `pulse` | `ring`) add motion without inventing labels. Optional **`edges`** (`from` / `to` box labels, optional palette `color`) draw **edge-to-edge** connectors via `_arrow` / `GrowArrow` after layout — prefer them for pipeline / flow boards. Long subject-beat holds get a clock-safe pulse (`layout.dwell_emphasis: auto`) and additional mid-hold pulses before the board would sit still, unless you set `emphasis: none`. -- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|slide|rise|zoom|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down. Between pages it plays a **page transition**: **`fade`** (default dissolve), **`slide`** (board travels left, next node enters from the right), **`rise`** (board drops, next node settles from above), **`zoom`** (board shrinks, next node scales up), or **`none`** (instant remove). Only the first node of the next page uses that entrance; later boxes stay paced to the narration. An explicit box `reveal` on that first node wins over the page entrance. Single-page specs keep top-level **`rows`** (and optional top-level **`edges`**). +- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|slide|rise|zoom|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down. Between pages it plays a **page transition** as one motion: the previous board leaves while the next page's first node arrives, so the frame does not go empty. **`fade`** (default crossfade), **`slide`** (board travels left while the next node enters from the right), **`rise`** (board drops while the next node settles from just under the title), **`zoom`** (board shrinks while the next node scales up), or **`none`** (instant remove, then the next reveal). Only the first node of the next page rides that entrance; later boxes stay paced to the narration. An explicit box `reveal` on that first node wins over the page entrance. Single-page specs keep top-level **`rows`** (and optional top-level **`edges`**). - **Frame budget:** dogfood scenes use a **14.22×8** Manim frame (`scenes.py` header). Content sits under the title — tall stacks (**many rows × box `height` + `row_gap`**) scroll past the bottom. Prefer **extra pages** or **shorter boxes** (`height` ~0.72–0.9, tighter `row_gap`) over piling 5+ full-height rows on one page. - **~3 rows per page** is a safe default (~6 when rows use compact height); match beats in **`narration/.md`** and optional **`wait_segment`** / **`wait_at`** when `timing.json` has Whisper data. - **Subject-beat coverage (library contract):** hold the board while consecutive sentences elaborate the **same** topic; when the topic shifts, add a spoken-phrase label for that beat. `scene-spec-generate` and `validate` run `layout_density_violations` — **coverage of subject beats + no invented unspoken labels**, not a blind label count. Disable with `validation.subject_beat_coverage.enabled: false` if needed. diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index 03c902c..d49c99a 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -12,13 +12,14 @@ * Lays out each **page** as a vertical stack of rows (``VGroup`` per row, then ``arrange(DOWN)``), positioned under the title — **no scaling** to cram content; use multiple **pages** when the story needs more boxes than fit. -* Between pages, runs a **page transition** so the next page appears on a - clear canvas. ``fade`` (default) dissolves the previous board. ``slide`` - travels it off to the left and brings the next page's first node in from - the right. ``rise`` drops the board and settles the next node from above. - ``zoom`` shrinks the board away and scales the next node up. ``none`` - removes the previous board instantly. Later boxes on the incoming page - stay paced — the handoff does not dump the whole next board. +* Between pages, the previous board and the next page's first node move in + **one** play, so the canvas does not go empty between them. ``fade`` + (default) crossfades. ``slide`` travels the board left while the next node + enters from the right. ``rise`` drops the board while the next node settles + from just under the title. ``zoom`` shrinks the board while the next node + scales up. ``none`` removes the previous board instantly, then reveals. + Later boxes on the incoming page stay paced — the handoff does not dump + the whole next board. * Uses the shared ``_box`` helper (text centered in the node; optional ``shape`` / ``reveal`` / ``emphasis``). After each paced reveal, a **dwell** slot may play ``Indicate`` / ``Circumscribe`` when the gap to the next @@ -2929,26 +2930,121 @@ def _opening_page_transition(page: dict[str, Any]) -> str: return str(page.get("transition") or "fade") -def _append_incoming_exit( - lines: list[str], - *, +def _page_exit_targets( page_index: int, row_index: int, box_index: int, page: dict[str, Any], page_box_vars: dict[int, list[str]], page_edge_vars: dict[int, list[str]], - page_fade_rt: float, -) -> str | None: +) -> tuple[str, list[str]] | None: + """Exit kind and revealed names for the first box of a later page.""" if page_index <= 0 or row_index != 0 or box_index != 0: return None + transition = _opening_page_transition(page) targets = list(page_box_vars.get(page_index - 1) or []) targets.extend(page_edge_vars.get(page_index - 1) or []) - return _append_page_exit( + if transition == "none": + return "none", targets + kind = transition if transition in ANIMATED_PAGE_TRANSITIONS else "fade" + return kind, targets + + +def _cross_handoff(kind: str, targets: list[str], box: dict[str, Any]) -> bool: + """True when the exit and the first incoming node should share one play.""" + if kind == "none" or not targets or _author_reveal(box) is not None: + return False + return True + + +def _append_cross_handoff( + lines: list[str], + targets: list[str], + kind: str, + enter_call: str, + edge_anims: list[tuple[str, str]], + run_time: float, +) -> None: + """Previous board leaves while the next node arrives. One clock step.""" + parts = [_page_exit_call(target, kind) for target in targets] + parts.append(enter_call) + for evar, edge_kind in edge_anims: + parts.append(_edge_play_part(evar, edge_kind)) + anims = ", ".join(parts) + lines.append(f" self.timed_play({anims}, run_time={run_time})") + + +def _append_plain_reveal( + lines: list[str], + bx: str, + box: dict[str, Any], + ev: RevealEvent | None, + *, + page_has_edges: bool, + first_box_seen: bool, + edge_anims: list[tuple[str, str]], + run_time: float, +) -> None: + reveal_part = _reveal_statement( + bx, + box, + ev, + page_has_edges=page_has_edges, + first_box_seen=first_box_seen, + page_enter_trans=None, + ) + _append_reveal_play(lines, bx, reveal_part, edge_anims, run_time) + + +def _append_box_entrance( + lines: list[str], + *, + page_index: int, + row_index: int, + box_index: int, + box: dict[str, Any], + page: dict[str, Any], + page_has_edges: bool, + first_box_seen: bool, + ev: RevealEvent | None, + run_time: float, + page_fade_rt: float, + page_box_vars: dict[int, list[str]], + page_edge_vars: dict[int, list[str]], + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], +) -> None: + bx = f"_bx_{page_index}_{row_index}_{box_index}" + edges = edges_with_target.get((page_index, bx), []) + exit_info = _page_exit_targets( + page_index, + row_index, + box_index, + page, + page_box_vars, + page_edge_vars, + ) + if exit_info is not None: + kind, targets = exit_info + if _cross_handoff(kind, targets, box): + _append_cross_handoff( + lines, + targets, + kind, + _page_enter_call(bx, kind), + edges, + round(page_fade_rt + run_time, 3), + ) + return + _append_page_exit(lines, kind, targets, page_fade_rt) + _append_plain_reveal( lines, - _opening_page_transition(page), - targets, - page_fade_rt, + bx, + box, + ev, + page_has_edges=page_has_edges, + first_box_seen=first_box_seen, + edge_anims=edges, + run_time=run_time, ) @@ -3050,32 +3146,23 @@ def _append_one_box_playback( ww = _resolved_wait_word(box, box_index, row_wait) if ww is not None: lines.append(f" self.wait_until_word(timing_words, {int(ww)})") - page_enter_trans = _append_incoming_exit( + _append_box_entrance( lines, page_index=page_index, row_index=row_index, box_index=box_index, + box=box, page=page, + page_has_edges=page_has_edges, + first_box_seen=first_box_seen, + ev=ev, + run_time=run_time, + page_fade_rt=page_fade_rt, page_box_vars=page_box_vars, page_edge_vars=page_edge_vars, - page_fade_rt=page_fade_rt, + edges_with_target=edges_with_target, ) bx = f"_bx_{page_index}_{row_index}_{box_index}" - reveal_part = _reveal_statement( - bx, - box, - ev, - page_has_edges=page_has_edges, - first_box_seen=first_box_seen, - page_enter_trans=page_enter_trans, - ) - _append_reveal_play( - lines, - bx, - reveal_part, - edges_with_target.get((page_index, bx), []), - run_time, - ) _append_hold_pulses(lines, bx, ev) diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index a090b4c..976ffce 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -99,12 +99,12 @@ Prefer edges for pipeline / flow diagrams (A → B → C); omit when boxes are unrelated topics. Use either **rows** (single page) OR **pages** (list of {{ rows: [...], transition?: {_PAGE_TRANSITION_CHOICES}, edges?: [...] }} — transition on pages after the first overrides layout.page_transition for exiting the previous page; first page has no transition in). -Between pages, pick a handoff (only the **first** node of the next page plays it; later boxes stay paced): -- ``slide`` — previous board travels left, first node enters from the right. Use when the next page continues the same pipeline. -- ``rise`` — previous board drops away, first node settles down from above. Use for a deeper step. -- ``zoom`` — previous board shrinks out, first node scales up. Use when the next page focuses a detail. -- ``fade`` — dissolve (default). Use for a hard topic change. -- ``none`` — instant remove. Rare. +Between pages, pick a handoff. The previous board and the **first** node of the next page move in the same play (the frame does not go empty). Later boxes stay paced: +- ``slide`` — previous board travels left while the first node enters from the right. Use when the next page continues the same pipeline. +- ``rise`` — previous board drops while the first node settles down from just under the title. Use for a deeper step. +- ``zoom`` — previous board shrinks while the first node scales up. Use when the next page focuses a detail. +- ``fade`` — crossfade (default). Use for a hard topic change. +- ``none`` — instant remove, then the next reveal. Rare. Palette tokens (exact spelling): {", ".join(sorted(ALLOWED_COLORS))} diff --git a/tests/test_scene_motion.py b/tests/test_scene_motion.py index 94d122a..b2fd66f 100644 --- a/tests/test_scene_motion.py +++ b/tests/test_scene_motion.py @@ -360,18 +360,26 @@ def _paged_spec(transition: str, *, second_reveal: str | None = None) -> dict: } +def _shared_handoff_line(source: str, outgoing: str, incoming: str) -> str: + for line in source.splitlines(): + if "timed_play" in line and outgoing in line and incoming in line: + return line + raise AssertionError("exit and entrance are not in the same timed_play") + + def test_compile_slide_page_transition() -> None: spec = _paged_spec("slide") validate_scene_spec(spec) out = compile_scene_class(spec) - assert f"FadeOut(_bx_0_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" in out - assert f"FadeOut(_ar_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" in out - # FadeIn(shift=LEFT) starts on the right and travels left into place. - assert f"FadeIn(_bx_1_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" in out + outgoing = f"FadeOut(_bx_0_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" + incoming = f"FadeIn(_bx_1_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" + handoff = _shared_handoff_line(out, outgoing, incoming) + assert f"FadeOut(_ar_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" in handoff # The next box and its arrow stay paced; they do not ride the page handoff. assert "FadeIn(_bx_1_0_1)" in out assert f"FadeIn(_bx_1_0_1, shift=LEFT * {PAGE_SLIDE_SHIFT})" not in out assert "GrowArrow(_ar_1_0)" in out + assert "GrowArrow(_ar_1_0)" not in handoff plan = motion_plan_from_source(out) assert "page_slide" in plan assert "reveal:slide:_bx_1_0_0" in plan @@ -380,14 +388,20 @@ def test_compile_slide_page_transition() -> None: def test_compile_rise_and_zoom_page_transitions() -> None: rise = compile_scene_class(_paged_spec("rise")) - assert f"FadeOut(_bx_0_0_0, shift=DOWN * {PAGE_RISE_SHIFT})" in rise - assert f"FadeIn(_bx_1_0_0, shift=DOWN * {PAGE_RISE_ENTER})" in rise + _shared_handoff_line( + rise, + f"FadeOut(_bx_0_0_0, shift=DOWN * {PAGE_RISE_SHIFT})", + f"FadeIn(_bx_1_0_0, shift=DOWN * {PAGE_RISE_ENTER})", + ) assert "page_rise" in motion_plan_from_source(rise) assert "reveal:rise:_bx_1_0_0" in motion_plan_from_source(rise) zoom = compile_scene_class(_paged_spec("zoom")) - assert f"FadeOut(_bx_0_0_0, scale={PAGE_ZOOM_SCALE})" in zoom - assert f"FadeIn(_bx_1_0_0, scale={PAGE_ZOOM_SCALE})" in zoom + _shared_handoff_line( + zoom, + f"FadeOut(_bx_0_0_0, scale={PAGE_ZOOM_SCALE})", + f"FadeIn(_bx_1_0_0, scale={PAGE_ZOOM_SCALE})", + ) assert "GrowArrow(_ar_1_0)" in zoom assert "page_zoom" in motion_plan_from_source(zoom) assert "reveal:zoom:_bx_1_0_0" in motion_plan_from_source(zoom) @@ -395,9 +409,13 @@ def test_compile_rise_and_zoom_page_transitions() -> None: def test_explicit_reveal_wins_over_page_entrance() -> None: out = compile_scene_class(_paged_spec("slide", second_reveal="grow")) - assert f"FadeOut(_bx_0_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" in out + outgoing = f"FadeOut(_bx_0_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" + assert outgoing in out assert "GrowFromCenter(_bx_1_0_0)" in out assert f"FadeIn(_bx_1_0_0, shift=LEFT * {PAGE_SLIDE_SHIFT})" not in out + for line in out.splitlines(): + if outgoing in line: + assert "GrowFromCenter" not in line def test_animated_page_transitions_stay_on_the_clock() -> None: @@ -445,7 +463,11 @@ def test_animated_page_transitions_stay_on_the_clock() -> None: events = simulate_reveal_timeline(spec, words) assert events[1].page_fade_out > 0 assert events[1].page_fade_out < 0.45 - assert f"run_time={round(events[1].page_fade_out, 3)}" in out + # Exit and entrance share one play whose duration is the old two steps. + handoff_rt = round( + round(events[1].page_fade_out, 3) + round(events[1].run_time, 3), 3 + ) + assert f"run_time={handoff_rt}" in out trace = run_compiled_scene_clock(out, words) assert trace.skipped_waits() == [] assert simulator_exec_drift_violations(events, trace) == []