From b1567f526d4298962ed807cb0ecc3b1f1a999e4e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 17:22:21 +0000 Subject: [PATCH] Play page exits and entrances in one motion. The old board finished leaving before the next node arrived, so the frame went empty. Fade, slide, rise, and zoom now share one timed_play that lasts as long as those two steps did. Co-authored-by: John Menke --- AGENTS.md | 2 +- docs/demos/hints/manim-scene-specs.md | 2 +- src/docgen/scene_spec.py | 153 ++++++++++++++++++++------ src/docgen/scene_spec_generate.py | 12 +- tests/test_scene_motion.py | 42 +++++-- 5 files changed, 160 insertions(+), 51 deletions(-) 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) == []