From 2025f9db01ded973f069128589c22ec8479c64e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 02:44:42 +0000 Subject: [PATCH 1/3] feat(scene-spec): clock-safe dwell and richer box motion fields Add a dwell slot to the reveal timeline so long subject-beat holds can play Indicate/Circumscribe without racing the next wait_word. Boxes may declare shape, reveal, and emphasis; edges compile as mobject-to-mobject connectors instead of center-to-center points. Co-authored-by: jmjava --- src/docgen/manim_primitives.py | 101 ++++++++++++ src/docgen/scene_spec.py | 151 ++++++++++++++---- tests/test_scene_motion.py | 272 +++++++++++++++++++++++++++++++++ tests/test_scene_spec.py | 5 +- 4 files changed, 496 insertions(+), 33 deletions(-) create mode 100644 src/docgen/manim_primitives.py create mode 100644 tests/test_scene_motion.py diff --git a/src/docgen/manim_primitives.py b/src/docgen/manim_primitives.py new file mode 100644 index 0000000..cdc5513 --- /dev/null +++ b/src/docgen/manim_primitives.py @@ -0,0 +1,101 @@ +"""Clock-safe motion math shared by scene-spec compile and tests. + +Manim-facing copies of ``_box`` / ``_arrow`` live in ``BOOTSTRAP_HEADER``. +This module stays importable without Manim so unit tests can lock geometry +and dwell budgets independently of a render. +""" + +from __future__ import annotations + +import math + +# Floor when allocating Indicate / Circumscribe after a reveal. +MIN_DWELL_RUN_TIME = 0.35 +DEFAULT_DWELL_RUN_TIME = 0.5 +# Leave this much clock before the next wait_word so dwell cannot skip it. +DWELL_CLOCK_MARGIN = 0.12 + +ALLOWED_SHAPES = frozenset({"rounded", "pill", "diamond"}) +ALLOWED_REVEALS = frozenset({"fade", "grow", "slide"}) +ALLOWED_EMPHASIS = frozenset({"none", "pulse", "ring"}) +ALLOWED_DWELL_EMPHASIS = frozenset({"auto", "none"}) + + +def connector_endpoints( + c1: tuple[float, float], + half1: tuple[float, float], + c2: tuple[float, float], + half2: tuple[float, float], + buff: float = 0.2, +) -> tuple[tuple[float, float], tuple[float, float]]: + """Axis-aligned bbox edge points facing each other, plus ``buff`` along the ray. + + ``half1`` / ``half2`` are ``(half_width, half_height)`` of each box. + Used to lock edge-to-edge arrow geometry without importing Manim. + """ + x1, y1 = c1 + x2, y2 = c2 + dx = x2 - x1 + dy = y2 - y1 + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + return c1, c2 + + def _hit(hw: float, hh: float, sx: float, sy: float) -> tuple[float, float]: + tx = hw / abs(sx) if abs(sx) > 1e-9 else float("inf") + ty = hh / abs(sy) if abs(sy) > 1e-9 else float("inf") + t = min(tx, ty) + return (t * sx, t * sy) + + start_off = _hit(half1[0], half1[1], dx, dy) + end_off = _hit(half2[0], half2[1], -dx, -dy) + length = math.hypot(dx, dy) + ux, uy = dx / length, dy / length + start = (x1 + start_off[0] + ux * buff, y1 + start_off[1] + uy * buff) + end = (x2 + end_off[0] - ux * buff, y2 + end_off[1] - uy * buff) + return start, end + + +def resolve_box_emphasis(box: dict, layout: dict | None) -> str: + """Return ``none`` | ``pulse`` | ``ring``. Omitted box field inherits layout. + + ``layout.dwell_emphasis`` is ``auto`` (default → pulse when budget allows) + or ``none``. A box-level ``emphasis`` always wins. + """ + raw = box.get("emphasis") if isinstance(box, dict) else None + if raw is not None: + val = str(raw).strip().lower() + if val == "auto": + return "pulse" + return val + mode = str((layout or {}).get("dwell_emphasis") or "auto").strip().lower() + if mode == "none": + return "none" + return "pulse" + + +def compute_dwell_run_time( + clock: float, + next_target: float | None, + *, + requested: str, + default_rt: float = DEFAULT_DWELL_RUN_TIME, +) -> float: + """Seconds of emphasis after a reveal, or 0 if it would race the next wait. + + ``next_target`` is the next paced ``wait_word`` start. ``None`` means this + is the last reveal — use ``default_rt`` (audio tail still waits after). + """ + if requested == "none": + return 0.0 + try: + default = float(default_rt) + except (TypeError, ValueError): + default = DEFAULT_DWELL_RUN_TIME + if default <= 0: + return 0.0 + if next_target is None: + return default + usable = float(next_target) - float(clock) - DWELL_CLOCK_MARGIN + if usable < MIN_DWELL_RUN_TIME: + return 0.0 + return min(default, usable) diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index f6a6092..14fdee3 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -14,7 +14,10 @@ content; use multiple **pages** when the story needs more boxes than fit. * Between pages, runs a **page transition** (default ``fade`` out the previous page's stack) so the next page appears on a clear canvas. -* Uses the shared ``_box`` helper (text centered in the rounded rect). +* 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 + ``wait_word`` is long enough — clamped so ``_clock`` cannot race. Typical workflow: @@ -39,6 +42,17 @@ import yaml +from docgen.manim_primitives import ( + ALLOWED_DWELL_EMPHASIS, + ALLOWED_EMPHASIS, + ALLOWED_REVEALS, + ALLOWED_SHAPES, + DEFAULT_DWELL_RUN_TIME, + MIN_DWELL_RUN_TIME, + compute_dwell_run_time, + resolve_box_emphasis, +) + _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") # Compiled scenes Write the title then pace boxes with wait_until_word against @@ -726,6 +740,9 @@ class RevealEvent: wait_skipped: bool run_time: float page_fade_out: float + emphasis: str = "none" + dwell_run_time: float = 0.0 + reveal: str = "fade" def _box_wait_word(row: dict[str, Any], box: dict[str, Any], box_index: int) -> int | None: @@ -811,6 +828,8 @@ def iter_reveal_slots( "run_time": row_rt, "page_transition": str(trans or default_tr), "page_transition_run_time": default_tr_rt, + "emphasis": resolve_box_emphasis(box, layout), + "reveal": str(box.get("reveal") or "fade").strip().lower(), } ) return slots @@ -883,6 +902,19 @@ def simulate_reveal_timeline( if page_fade_out > 0: clock += page_fade_out + clock_after_reveal = clock + rt + requested = str(slot.get("emphasis") or "none") + default_dwell = float( + (spec.get("layout") or {}).get("dwell_run_time", DEFAULT_DWELL_RUN_TIME) + ) + dwell_rt = compute_dwell_run_time( + clock_after_reveal, + next_target, + requested=requested, + default_rt=default_dwell, + ) + emphasis = requested if dwell_rt > 0 else "none" + events.append( RevealEvent( label=str(slot["label"]), @@ -895,9 +927,12 @@ def simulate_reveal_timeline( wait_skipped=wait_skipped, run_time=float(rt), page_fade_out=float(page_fade_out), + emphasis=emphasis, + dwell_run_time=float(dwell_rt), + reveal=str(slot.get("reveal") or "fade"), ) ) - clock += rt + clock = clock_after_reveal + dwell_rt return events @@ -1477,6 +1512,21 @@ def _validate_row_list(rows: list[Any], *, path_label: str, prefix: str) -> None raise SceneSpecError(f"{bp}: subtitle must be a string if set") if len(bsub.strip()) > 60: raise SceneSpecError(f"{bp}: subtitle must be at most 60 characters") + shape = box.get("shape") + if shape is not None and str(shape).strip().lower() not in ALLOWED_SHAPES: + raise SceneSpecError( + f"{bp}: shape must be one of {sorted(ALLOWED_SHAPES)} if set" + ) + reveal = box.get("reveal") + if reveal is not None and str(reveal).strip().lower() not in ALLOWED_REVEALS: + raise SceneSpecError( + f"{bp}: reveal must be one of {sorted(ALLOWED_REVEALS)} if set" + ) + emphasis = box.get("emphasis") + if emphasis is not None and str(emphasis).strip().lower() not in ALLOWED_EMPHASIS: + raise SceneSpecError( + f"{bp}: emphasis must be one of {sorted(ALLOWED_EMPHASIS)} if set" + ) has_row_pacing = row.get("wait_word") is not None or row.get("wait_segment") is not None if has_row_pacing and box_pacing: @@ -1605,6 +1655,18 @@ def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> No raise SceneSpecError( f"{path_label}: layout.page_transition_run_time must be a number in (0, 5] if set" ) + dwell_mode = layout.get("dwell_emphasis", "auto") + if str(dwell_mode).strip().lower() not in ALLOWED_DWELL_EMPHASIS: + raise SceneSpecError( + f"{path_label}: layout.dwell_emphasis must be one of " + f"{sorted(ALLOWED_DWELL_EMPHASIS)} if set" + ) + if "dwell_run_time" in layout: + drt = layout.get("dwell_run_time") + if not isinstance(drt, (int, float)) or not (0 < float(drt) <= 3.0): + raise SceneSpecError( + f"{path_label}: layout.dwell_run_time must be a number in (0, 3] if set" + ) if has_rows: rows = data["rows"] @@ -1693,6 +1755,41 @@ def _any_wait_segment_in_pages(pages: list[dict[str, Any]]) -> bool: return False +def _box_ctor_line(var: str, box: dict[str, Any]) -> str: + """Emit ``_box(...)``; omit default ``shape='rounded'`` so stale helpers still work.""" + lab = str(box["label"]) + col = str(box["color"]) + w = float(box["width"]) + h = float(box["height"]) + fs = int(box["font_size"]) + extras: list[str] = [] + bsub = str(box.get("subtitle") or "").strip() + if bsub: + extras.append(f"subtitle={bsub!r}") + shape = str(box.get("shape") or "rounded").strip().lower() + if shape != "rounded": + extras.append(f"shape={shape!r}") + extra = (", " + ", ".join(extras)) if extras else "" + return f" {var} = _box({lab!r}, {col}, {w}, {h}, {fs}{extra})" + + +def _reveal_anim(bx: str, reveal: str) -> str: + kind = str(reveal or "fade").strip().lower() + if kind == "grow": + return f"GrowFromCenter({bx})" + if kind == "slide": + return f"FadeIn({bx}, shift=UP * 0.22)" + return f"FadeIn({bx})" + + +def _emphasis_anim(bx: str, emphasis: str) -> str | None: + if emphasis == "ring": + return f"Circumscribe({bx})" + if emphasis == "pulse": + return f"Indicate({bx})" + return None + + def compile_scene_class( spec: dict[str, Any], *, @@ -1797,18 +1894,7 @@ def compile_scene_class( f" {var} = _image({rel!r}, {w}, {h})" ) continue - lab = str(box["label"]) - col = str(box["color"]) - fs = int(box["font_size"]) - bsub = str(box.get("subtitle") or "").strip() - if bsub: - lines.append( - f" {var} = _box({lab!r}, {col}, {w}, {h}, {fs}, subtitle={bsub!r})" - ) - else: - lines.append( - f" {var} = _box({lab!r}, {col}, {w}, {h}, {fs})" - ) + lines.append(_box_ctor_line(var, box)) for r, row in enumerate(rows): boxes_raw = row["boxes"] @@ -1851,8 +1937,7 @@ def compile_scene_class( estyle = str(edge.get("style") or "solid").strip().lower() or "solid" elabel = str(edge.get("label") or "").strip() lines.append( - f" {evar} = _arrow({src_var}.get_center(), {dst_var}.get_center(), " - f"{ecol}, style={estyle!r})" + f" {evar} = _arrow({src_var}, {dst_var}, {ecol}, style={estyle!r})" ) page_edge_vars.setdefault(p, []).append(evar) # Reveal with the later endpoint (second in box creation order). @@ -1931,21 +2016,29 @@ def compile_scene_class( lines.append(f" self.remove({t})") lines.append(" self.timed_wait(0.05)") bx = f"_bx_{p}_{r}_{b_idx}" + reveal = ( + str(ev.reveal) + if ev is not None + else str(box.get("reveal") or "fade").strip().lower() + ) + reveal_part = _reveal_anim(bx, reveal) edge_anims = edges_with_target.get((p, bx), []) - if edge_anims: - parts = [f"FadeIn({bx})"] - for evar, kind in edge_anims: - if kind == "grow": - parts.append(f"GrowArrow({evar})") - else: - parts.append(f"FadeIn({evar})") - anims = ", ".join(parts) - lines.append( - f" self.timed_play({anims}, run_time={run_time})" - ) - else: + parts = [reveal_part] + for evar, kind in edge_anims: + if kind == "grow": + parts.append(f"GrowArrow({evar})") + else: + parts.append(f"FadeIn({evar})") + anims = ", ".join(parts) + lines.append( + f" self.timed_play({anims}, run_time={run_time})" + ) + dwell_rt = float(ev.dwell_run_time) if ev is not None else 0.0 + emphasis = str(ev.emphasis) if ev is not None else "none" + emph = _emphasis_anim(bx, emphasis) + if emph and dwell_rt >= MIN_DWELL_RUN_TIME: lines.append( - f" self.timed_play(FadeIn({bx}), run_time={run_time})" + f" self.timed_play({emph}, run_time={round(dwell_rt, 3)})" ) lines.extend( diff --git a/tests/test_scene_motion.py b/tests/test_scene_motion.py new file mode 100644 index 0000000..1f2f573 --- /dev/null +++ b/tests/test_scene_motion.py @@ -0,0 +1,272 @@ +"""Clock-safe dwell, box motion fields, and connector geometry.""" + +from __future__ import annotations + +import pytest + +from docgen.manim_primitives import connector_endpoints +from docgen.scene_spec import ( + DEFAULT_DWELL_RUN_TIME, + MIN_DWELL_RUN_TIME, + MIN_REVEAL_RUN_TIME, + SceneSpecError, + compile_scene_class, + compute_dwell_run_time, + resolve_box_emphasis, + simulate_reveal_timeline, + validate_scene_spec, +) + + +def _box( + label: str, + *, + wait_word: int | None = None, + emphasis: str | None = None, + reveal: str | None = None, + shape: str | None = None, + subtitle: str | None = None, +) -> dict: + out: dict = { + "label": label, + "color": "C_GREEN", + "width": 3.0, + "height": 0.8, + "font_size": 18, + } + if wait_word is not None: + out["wait_word"] = wait_word + if emphasis is not None: + out["emphasis"] = emphasis + if reveal is not None: + out["reveal"] = reveal + if shape is not None: + out["shape"] = shape + if subtitle is not None: + out["subtitle"] = subtitle + return out + + +def _spec(boxes: list[dict], *, layout: dict | None = None) -> dict: + spec: dict = { + "segment_id": "01", + "class_name": "MotionScene", + "timing_key": "01-motion", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [{"run_time": 1.5, "boxes": boxes}], + } + if layout: + spec["layout"] = layout + return spec + + +def _wide_words() -> list[dict]: + """Long holds between spoken labels — dwell should fire.""" + return [ + {"word": "Alpha", "start": 1.2, "end": 1.4}, + {"word": "Beta", "start": 8.0, "end": 8.3}, + {"word": "Gamma", "start": 16.0, "end": 16.3}, + {"word": "tail", "start": 24.0, "end": 24.4}, + ] + + +def _tight_words() -> list[dict]: + return [ + {"word": "Alpha", "start": 1.2, "end": 1.4}, + {"word": "Beta", "start": 1.6, "end": 1.8}, + {"word": "Gamma", "start": 2.0, "end": 2.2}, + {"word": "tail", "start": 40.0, "end": 40.5}, + ] + + +def test_compute_dwell_zero_when_emphasis_none() -> None: + assert compute_dwell_run_time(1.0, 10.0, requested="none") == 0.0 + + +def test_compute_dwell_zero_when_gap_too_tight() -> None: + # clock=1.45, next=1.6 → usable < MIN_DWELL_RUN_TIME + assert compute_dwell_run_time(1.45, 1.6, requested="pulse") == 0.0 + + +def test_compute_dwell_clamps_to_default_on_wide_gap() -> None: + rt = compute_dwell_run_time(2.0, 12.0, requested="pulse") + assert rt == pytest.approx(DEFAULT_DWELL_RUN_TIME) + assert rt >= MIN_DWELL_RUN_TIME + + +def test_compute_dwell_last_box_uses_default() -> None: + rt = compute_dwell_run_time(20.0, None, requested="ring") + assert rt == pytest.approx(DEFAULT_DWELL_RUN_TIME) + + +def test_compute_dwell_shrinks_when_next_beat_is_close_but_usable() -> None: + # clock=5.0, next=5.7, margin 0.12 → usable 0.58, default 0.5 → 0.5 + rt = compute_dwell_run_time(5.0, 5.7, requested="pulse") + assert MIN_DWELL_RUN_TIME <= rt <= 0.58 + + +def test_resolve_box_emphasis_inherit_auto_is_pulse() -> None: + assert resolve_box_emphasis({}, {}) == "pulse" + assert resolve_box_emphasis({}, {"dwell_emphasis": "auto"}) == "pulse" + + +def test_resolve_box_emphasis_layout_none() -> None: + assert resolve_box_emphasis({}, {"dwell_emphasis": "none"}) == "none" + + +def test_resolve_box_emphasis_box_overrides_layout() -> None: + assert resolve_box_emphasis({"emphasis": "ring"}, {"dwell_emphasis": "none"}) == "ring" + assert resolve_box_emphasis({"emphasis": "none"}, {"dwell_emphasis": "auto"}) == "none" + + +def test_wide_holds_get_pulse_dwell_without_skipping_waits() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1), _box("Gamma", wait_word=2)]) + events = simulate_reveal_timeline(spec, _wide_words(), clamp_run_times=True) + assert len(events) == 3 + assert all(not e.wait_skipped for e in events[1:]) + # First two have a long gap to the next word; last box still dwells (audio tail). + assert events[0].dwell_run_time >= MIN_DWELL_RUN_TIME + assert events[1].dwell_run_time >= MIN_DWELL_RUN_TIME + assert events[2].dwell_run_time >= MIN_DWELL_RUN_TIME + assert all(e.emphasis == "pulse" for e in events) + # Clock after fade+dwell must stay behind the next spoken start. + assert events[0].effective_at + events[0].run_time + events[0].dwell_run_time < 8.0 + assert events[1].effective_at + events[1].run_time + events[1].dwell_run_time < 16.0 + + +def test_tight_cascade_gets_no_dwell() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1), _box("Gamma", wait_word=2)]) + events = simulate_reveal_timeline(spec, _tight_words(), clamp_run_times=True) + assert all(e.dwell_run_time == 0.0 for e in events[:-1]) + assert all(e.run_time >= MIN_REVEAL_RUN_TIME for e in events) + assert not events[1].wait_skipped + assert not events[2].wait_skipped + + +def test_emphasis_none_suppresses_dwell_on_wide_holds() -> None: + spec = _spec( + [ + _box("Alpha", wait_word=0, emphasis="none"), + _box("Beta", wait_word=1, emphasis="none"), + ] + ) + events = simulate_reveal_timeline(spec, _wide_words(), clamp_run_times=True) + assert all(e.dwell_run_time == 0.0 for e in events) + assert all(e.emphasis == "none" for e in events) + + +def test_layout_dwell_emphasis_none_disables_auto() -> None: + spec = _spec( + [_box("Alpha", wait_word=0), _box("Beta", wait_word=1)], + layout={"dwell_emphasis": "none"}, + ) + events = simulate_reveal_timeline(spec, _wide_words(), clamp_run_times=True) + assert all(e.dwell_run_time == 0.0 for e in events) + + +def test_compile_wide_holds_emits_indicate() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)]) + out = compile_scene_class(spec, words=_wide_words()) + assert "Indicate(_bx_0_0_0)" in out + assert "wait_until_word(timing_words, 1)" in out + fade_at = out.index("FadeIn(_bx_0_0_0)") + pulse_at = out.index("Indicate(_bx_0_0_0)") + next_wait = out.index("wait_until_word(timing_words, 1)") + assert fade_at < pulse_at < next_wait + + +def test_compile_ring_emits_circumscribe() -> None: + spec = _spec([_box("Alpha", wait_word=0, emphasis="ring"), _box("Beta", wait_word=1)]) + out = compile_scene_class(spec, words=_wide_words()) + assert "Circumscribe(_bx_0_0_0)" in out + assert "Indicate(_bx_0_0_0)" not in out + + +def test_compile_without_words_emits_no_dwell() -> None: + spec = _spec([_box("Alpha"), _box("Beta")]) + out = compile_scene_class(spec) + assert "Indicate(" not in out + assert "Circumscribe(" not in out + + +def test_compile_reveal_grow_and_slide() -> None: + spec = _spec( + [ + _box("Alpha", wait_word=0, reveal="grow"), + _box("Beta", wait_word=1, reveal="slide"), + ] + ) + out = compile_scene_class(spec, words=_wide_words()) + assert "GrowFromCenter(_bx_0_0_0)" in out + assert "FadeIn(_bx_0_0_1, shift=UP * 0.22)" in out + + +def test_compile_shape_and_default_omitted() -> None: + spec = _spec( + [ + _box("Alpha", shape="diamond"), + _box("Beta", shape="pill"), + _box("Gamma"), + ] + ) + out = compile_scene_class(spec) + assert "shape='diamond'" in out + assert "shape='pill'" in out + assert "shape='rounded'" not in out + assert "_box('Gamma', C_GREEN, 3.0, 0.8, 18)" in out + + +def test_compile_edges_pass_mobjects_not_centers() -> None: + spec = { + "segment_id": "01", + "class_name": "FlowScene", + "timing_key": "01-flow", + "title": {"text": "Flow", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + { + "run_time": 0.8, + "boxes": [ + _box("Hints", wait_word=0), + _box("YAML", wait_word=1), + ], + } + ], + "edges": [{"from": "Hints", "to": "YAML", "color": "C_ACCENT"}], + } + spec["rows"][0]["boxes"][1]["color"] = "C_BLUE" + out = compile_scene_class(spec) + assert "_arrow(_bx_0_0_0, _bx_0_0_1, C_ACCENT, style='solid')" in out + assert ".get_center()" not in out + + +def test_validate_rejects_unknown_shape_reveal_emphasis() -> None: + with pytest.raises(SceneSpecError, match="shape"): + validate_scene_spec(_spec([_box("A", shape="hexagon")])) + with pytest.raises(SceneSpecError, match="reveal"): + validate_scene_spec(_spec([_box("A", reveal="explode")])) + with pytest.raises(SceneSpecError, match="emphasis"): + validate_scene_spec(_spec([_box("A", emphasis="sparkle")])) + + +def test_validate_rejects_bad_layout_dwell_fields() -> None: + with pytest.raises(SceneSpecError, match="dwell_emphasis"): + validate_scene_spec(_spec([_box("A")], layout={"dwell_emphasis": "loud"})) + with pytest.raises(SceneSpecError, match="dwell_run_time"): + validate_scene_spec(_spec([_box("A")], layout={"dwell_run_time": 0})) + + +def test_connector_endpoints_are_on_facing_edges() -> None: + start, end = connector_endpoints((0.0, 0.0), (1.0, 0.4), (4.0, 0.0), (1.0, 0.4), buff=0.2) + # Source right edge is x=1.0; dest left edge is x=3.0; buff pushes inward-to-gap. + assert start[0] == pytest.approx(1.2) + assert end[0] == pytest.approx(2.8) + assert start[1] == pytest.approx(0.0) + assert end[1] == pytest.approx(0.0) + + +def test_connector_endpoints_vertical_stack() -> None: + start, end = connector_endpoints((0.0, 2.0), (1.0, 0.5), (0.0, -2.0), (1.0, 0.5), buff=0.1) + assert start[0] == pytest.approx(0.0) + assert end[0] == pytest.approx(0.0) + assert start[1] == pytest.approx(1.4) # 2.0 - 0.5 - 0.1 + assert end[1] == pytest.approx(-1.4) diff --git a/tests/test_scene_spec.py b/tests/test_scene_spec.py index 6bc64d0..5c4e374 100644 --- a/tests/test_scene_spec.py +++ b/tests/test_scene_spec.py @@ -1109,10 +1109,7 @@ def test_compile_edges_emits_arrows_and_grow() -> None: "edges": [{"from": "Hints", "to": "YAML", "color": "C_ACCENT"}], } out = compile_scene_class(spec) - assert ( - "_ar_0_0 = _arrow(_bx_0_0_0.get_center(), _bx_0_0_1.get_center(), " - "C_ACCENT, style='solid')" - ) in out + assert "_ar_0_0 = _arrow(_bx_0_0_0, _bx_0_0_1, C_ACCENT, style='solid')" in out assert "GrowArrow(_ar_0_0)" in out assert "FadeIn(_bx_0_0_1)" in out From 21544b7cfb6bb7805b677b89abed8ce076343a95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 02:44:45 +0000 Subject: [PATCH 2/3] feat(manim): refresh stale helpers and teach scene-spec motion vocabulary Upgrade _box (pill/diamond) and _arrow (edge-to-edge, still accepts legacy points). scene-compile refreshes stale helper bodies in scenes.py without touching generated classes. The scene-spec LLM prompt and dogfood hint now describe shape, reveal, emphasis, and clock-safe dwell. Co-authored-by: jmjava --- AGENTS.md | 2 +- docs/demos/hints/manim-scene-specs.md | 2 +- src/docgen/manim_scene_support.py | 139 +++++++++++++++++++++++--- src/docgen/scene_spec_generate.py | 19 +++- tests/test_manim_scene_support.py | 63 ++++++++++++ tests/test_scene_spec_generate.py | 4 + 6 files changed, 211 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4213a07..67125f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,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. -- **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``. 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). Page transitions FadeOut revealed boxes, not the parent `VGroup`. +- **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``. 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). Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions FadeOut revealed boxes, not the parent `VGroup`. `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. - Prefer **stable CLI / library contracts** and **documented exit codes** so CI can depend on them. - **`narration_from_source`:** hints in config + **`docgen narration-generate`** — owner-supplied context paths, not opaque bulk edits to outputs. diff --git a/docs/demos/hints/manim-scene-specs.md b/docs/demos/hints/manim-scene-specs.md index 5ad56b7..69c96ab 100644 --- a/docs/demos/hints/manim-scene-specs.md +++ b/docs/demos/hints/manim-scene-specs.md @@ -2,7 +2,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 **`edges`** (`from` / `to` box labels, optional palette `color`) draw directed connectors via `_arrow` / `GrowArrow` after layout — prefer them for pipeline / flow boards. +- **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`) unless you set `emphasis: none`. - **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down; it **fade**s out the previous page’s stack (or **none** for an instant remove) before animating the next page. 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. diff --git a/src/docgen/manim_scene_support.py b/src/docgen/manim_scene_support.py index bb8663e..ca84553 100644 --- a/src/docgen/manim_scene_support.py +++ b/src/docgen/manim_scene_support.py @@ -161,17 +161,40 @@ def _load_timing_words(segment_key: str) -> list[dict]: return list(words) if isinstance(words, list) else [] -def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=""): - """Labeled rounded box - slightly stronger fill/stroke for readable diagram boards. +def _box(label, color, w=2.2, h=0.75, fs=18, subtitle="", shape="rounded"): + """Labeled diagram node. ``shape`` is rounded (default), pill, or diamond. Optional ``subtitle`` is a second, smaller line under the primary label (decorative; not used for wait_word beat matching). """ - r = RoundedRectangle( - corner_radius=0.18, width=w, height=h, - stroke_color=color, stroke_width=2.5, - fill_color=color, fill_opacity=0.28, - ) + kind = str(shape or "rounded").strip().lower() + if kind == "diamond": + r = Polygon( + [0, h / 2, 0], + [w / 2, 0, 0], + [0, -h / 2, 0], + [-w / 2, 0, 0], + stroke_color=color, + stroke_width=2.5, + fill_color=color, + fill_opacity=0.28, + ) + elif kind == "pill": + r = RoundedRectangle( + corner_radius=max(h / 2, 0.08), + width=w, + height=h, + stroke_color=color, + stroke_width=2.5, + fill_color=color, + fill_opacity=0.28, + ) + else: + r = RoundedRectangle( + corner_radius=0.18, width=w, height=h, + stroke_color=color, stroke_width=2.5, + fill_color=color, fill_opacity=0.28, + ) t = Text(str(label), font_size=fs, color=C_WHITE) # Prefer white label text for contrast; fall back to the accent color when # the palette token is already near-white. @@ -198,15 +221,24 @@ def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=""): def _arrow(start, end, color="#cdd6f4", style="solid"): - """Connector between box centers (used by scene-spec ``edges``). + """Connector for scene-spec ``edges``. - ``style`` is ``solid`` (default) or ``dashed``. Dashed edges should be - revealed with ``FadeIn`` (not ``GrowArrow``). + Accepts two mobjects (edge-to-edge via ``get_critical_point``) or two + points (legacy ``.get_center()`` compile output). ``style`` is ``solid`` + (default) or ``dashed``. Dashed edges should be revealed with ``FadeIn``. """ - # Allow palette token names that compile_scene_class emits as bare identifiers. + def _is_mob(x): + return hasattr(x, "get_center") and hasattr(x, "get_critical_point") + + if _is_mob(start) and _is_mob(end): + direction = end.get_center() - start.get_center() + p0 = start.get_critical_point(direction) + p1 = end.get_critical_point(-direction) + else: + p0, p1 = start, end arr = Arrow( - start, - end, + p0, + p1, color=color, stroke_width=3, buff=0.2, @@ -1155,13 +1187,91 @@ def _bootstrap_helper_source(name: str) -> str: """Extract one top-level helper definition from :data:`BOOTSTRAP_HEADER` by name.""" tree = ast.parse(BOOTSTRAP_HEADER) for node in tree.body: - if isinstance(node, ast.FunctionDef) and node.name == name: + if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name == name: segment = ast.get_source_segment(BOOTSTRAP_HEADER, node) if segment: return segment raise SceneGenerationError(f"bootstrap helper {name!r} not found in BOOTSTRAP_HEADER") +def _fn_arg_names(node: ast.FunctionDef) -> set[str]: + names = {a.arg for a in node.args.args} + names.update(a.arg for a in node.args.kwonlyargs) + return names + + +def _class_method(node: ast.ClassDef, name: str) -> ast.FunctionDef | None: + for child in node.body: + if isinstance(child, ast.FunctionDef) and child.name == name: + return child + return None + + +def helper_needs_refresh(tree: ast.AST, name: str) -> bool: + """True when a present helper is missing the current motion/clock API.""" + for node in tree.body: + if name == "_box" and isinstance(node, ast.FunctionDef) and node.name == "_box": + return "shape" not in _fn_arg_names(node) + if name == "_arrow" and isinstance(node, ast.FunctionDef) and node.name == "_arrow": + return "get_critical_point" not in ast.unparse(node) + if name == "_TimedScene" and isinstance(node, ast.ClassDef) and node.name == "_TimedScene": + timed = _class_method(node, "timed_play") + if timed is None: + return True + return "not_past" not in _fn_arg_names(timed) + if name == "_image" and isinstance(node, ast.FunctionDef) and node.name == "_image": + return False + return False + + +def _replace_top_level_def(text: str, node: ast.AST, new_src: str) -> str: + if getattr(node, "lineno", None) is None or getattr(node, "end_lineno", None) is None: + raise SceneGenerationError("cannot refresh helper without line numbers") + start = node.lineno - 1 + decos = getattr(node, "decorator_list", None) or [] + if decos: + start = min(d.lineno for d in decos) - 1 + end = node.end_lineno + lines = text.splitlines(keepends=True) + replacement = new_src if new_src.endswith("\n") else new_src + "\n" + return "".join(lines[:start]) + replacement + "".join(lines[end:]) + + +def refresh_bootstrap_helpers(scenes_path: Path) -> list[str]: + """Replace stale ``_box`` / ``_arrow`` / ``_TimedScene`` with canonical bodies. + + Does not touch generated scene classes. Missing ``_image`` is still handled + by :func:`ensure_image_helper`. Returns the names that were rewritten. + """ + if not scenes_path.is_file(): + return [] + text = scenes_path.read_text(encoding="utf-8") + try: + tree = ast.parse(text) + except SyntaxError as exc: + raise SceneGenerationError( + f"{scenes_path} did not parse as Python ({exc.msg} at line {exc.lineno}); " + "fix the file before refreshing helpers." + ) from exc + + refreshed: list[str] = [] + # Replace from the bottom of the file so earlier line numbers stay valid. + nodes: list[tuple[int, str, ast.AST]] = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in {"_box", "_arrow"}: + if helper_needs_refresh(tree, node.name): + nodes.append((node.lineno, node.name, node)) + elif isinstance(node, ast.ClassDef) and node.name == "_TimedScene": + if helper_needs_refresh(tree, "_TimedScene"): + nodes.append((node.lineno, node.name, node)) + for _lineno, name, node in sorted(nodes, key=lambda item: item[0], reverse=True): + text = _replace_top_level_def(text, node, _bootstrap_helper_source(name)) + refreshed.append(name) + if refreshed: + scenes_path.write_text(text, encoding="utf-8") + return list(reversed(refreshed)) + + def ensure_image_helper(scenes_path: Path) -> bool: """Append the canonical ``_image`` helper to an existing ``scenes.py`` when missing. @@ -1228,6 +1338,7 @@ def ensure_scenes_bootstrap(scenes_path: Path) -> None: "Either restore the helpers (palette + _box + _arrow + _load_timing + _load_timing_words + _TimedScene) " f"or delete {scenes_path.name} so scene-spec-generate / scene-compile can write a fresh bootstrap." ) + refresh_bootstrap_helpers(scenes_path) # ── Narration / timing loaders (scene-spec-generate) ─────────────────────── diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index e58d1e8..8272729 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -24,6 +24,7 @@ ) from docgen.manim_scene_support import _load_narration as load_narration_for_scene from docgen.manim_scene_support import _load_timing_segments as load_timing_for_scene +from docgen.manim_primitives import ALLOWED_EMPHASIS, ALLOWED_REVEALS, ALLOWED_SHAPES from docgen.scene_spec import ( ALLOWED_COLORS, FRAME_HEIGHT, @@ -82,6 +83,10 @@ - height: positive number (typical 0.65–1.1; **smaller when a page has many rows**) - font_size: int >= 14 - subtitle: optional second line ≤60 chars (decorative; not used for beat matching) + - shape: optional rounded (default) | pill | diamond — use diamond for decisions, pill for states + - reveal: optional fade (default) | grow | slide — grow for the first node of a flow; slide for a new row + - emphasis: optional none | pulse | ring — omit to inherit layout.dwell_emphasis (auto = pulse when the + hold until the next wait_word is long enough). The compiler clamps emphasis so it cannot race the clock. Optional **image elements** (only when project-owner hints ask for generated imagery): a ``boxes`` entry may instead be an image element with: @@ -103,7 +108,8 @@ Optional top-level: - layout: optional first_row_title_buff, row_gap, column_gap (positive numbers); - for multi-page specs also page_transition: fade | none (default fade), page_transition_run_time (default 0.45, max 5). + for multi-page specs also page_transition: fade | none (default fade), page_transition_run_time (default 0.45, max 5); + dwell_emphasis: auto (default; pulse during long holds) | none; dwell_run_time: seconds for that pulse (default 0.5, max 3). - edges: optional list of connectors for **single-page** ``rows`` specs (see below). Optional per-page (when using ``pages``): @@ -123,7 +129,14 @@ - **Edges / arrows:** when narration describes a flow or pipeline, add ``edges`` so the board shows directed connections (not only isolated boxes). Keep edge endpoints as spoken labels. Use ``style: dashed`` for optional/secondary paths and a short ``label`` on the arrow when - the narration names the relationship (keep edge captions terse). + the narration names the relationship (keep edge captions terse). Arrows attach to box **edges**, + not centers. +- **Motion (keep labels spoken):** vary ``shape`` / ``reveal`` / ``emphasis`` instead of inventing + extra labels. Prefer ``reveal: grow`` on the first node of a pipeline and ``emphasis: ring`` on + a decision diamond. Do **not** add boxes just to fill time — the toolchain pulses a revealed + box during a long subject-beat hold. + Allowed shapes: {", ".join(sorted(ALLOWED_SHAPES))}; reveals: {", ".join(sorted(ALLOWED_REVEALS))}; + emphasis: {", ".join(sorted(ALLOWED_EMPHASIS))}. - **Subject-beat coverage (mandatory):** consecutive sentences on the same topic are one beat — **hold the board**. When the topic shifts, reveal a new spoken-phrase label for that beat. Do **not** invent a box per sentence, and do **not** leave a new topic without a matching label. @@ -395,11 +408,13 @@ def inject_class_block_into_scenes_py( ensure_image_helper, ensure_scenes_bootstrap, inject_or_replace, + refresh_bootstrap_helpers, ) scenes_path = cfg.animations_dir / "scenes.py" try: ensure_scenes_bootstrap(scenes_path) + refresh_bootstrap_helpers(scenes_path) if "_image(" in class_block: ensure_image_helper(scenes_path) except SceneGenerationError as exc: diff --git a/tests/test_manim_scene_support.py b/tests/test_manim_scene_support.py index ee32bd2..5a7f7e1 100644 --- a/tests/test_manim_scene_support.py +++ b/tests/test_manim_scene_support.py @@ -28,6 +28,7 @@ inject_or_replace, lint_generated_block, merged_scene_generation_settings, + refresh_bootstrap_helpers, sync_audio_tail_waits_in_scenes, ) @@ -505,3 +506,65 @@ def test_lint_returns_partial_issues_when_unparsable() -> None: code = f"Text('x {arrow} y', font_size=12,\n# unbalanced" issues = lint_generated_block(code, min_font_size=14, unsafe_unicode=["\u2192"]) assert any("U+2192" in i for i in issues) + + +_STALE_HELPERS = ''' +def _box(label, color, w=2.2, h=0.75, fs=18): + return None + +def _arrow(start, end, color="#cdd6f4"): + return Arrow(start, end, color=color) + +class _TimedScene(Scene): + def setup(self): + self._clock = 0.0 + + def timed_play(self, *animations, run_time=1.0, **kwargs): + self.play(*animations, run_time=run_time, **kwargs) + self._clock += run_time +''' + + +def test_refresh_bootstrap_helpers_upgrades_stale_box_arrow_clock(tmp_path: Path) -> None: + p = tmp_path / "scenes.py" + p.write_text( + "from manim import *\n\n" + + _STALE_HELPERS + + "\n# ── BEGIN GENERATED SCENE: 01 (DemoScene) ──\n" + "class DemoScene(_TimedScene):\n" + " def construct(self):\n" + " pass\n" + "# ── END GENERATED SCENE: 01 ──\n", + encoding="utf-8", + ) + changed = refresh_bootstrap_helpers(p) + assert set(changed) == {"_box", "_arrow", "_TimedScene"} + text = p.read_text(encoding="utf-8") + assert "shape=" in text + assert "get_critical_point" in text + assert "not_past" in text + assert "BEGIN GENERATED SCENE: 01 (DemoScene)" in text + assert "class DemoScene(_TimedScene):" in text + + +def test_refresh_bootstrap_helpers_noop_when_current(tmp_path: Path) -> None: + p = tmp_path / "scenes.py" + p.write_text(BOOTSTRAP_HEADER, encoding="utf-8") + before = p.read_text(encoding="utf-8") + assert refresh_bootstrap_helpers(p) == [] + assert p.read_text(encoding="utf-8") == before + + +def test_ensure_bootstrap_refreshes_stale_helpers(tmp_path: Path) -> None: + p = tmp_path / "scenes.py" + p.write_text( + BOOTSTRAP_HEADER.replace( + "def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=\"\", shape=\"rounded\"):", + "def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=\"\"):", + ), + encoding="utf-8", + ) + ensure_scenes_bootstrap(p) + text = p.read_text(encoding="utf-8") + assert "shape=" in text + assert "def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=\"\", shape=\"rounded\"):" in text diff --git a/tests/test_scene_spec_generate.py b/tests/test_scene_spec_generate.py index e5152fa..1b07f0f 100644 --- a/tests/test_scene_spec_generate.py +++ b/tests/test_scene_spec_generate.py @@ -112,6 +112,10 @@ def test_generate_scene_spec_dry_run_no_llm(tmp_path: Path) -> None: assert "Hello world" in result.prompt assert "SUBJECT BEATS" in result.prompt assert "--- system ---" in result.prompt + assert "shape:" in result.prompt + assert "reveal:" in result.prompt + assert "emphasis:" in result.prompt + assert "dwell_emphasis" in result.prompt assert result.yaml_text == "" From 8e35413e3f4f6cb912e7a1a067d65e10b7e0b8af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 02:49:45 +0000 Subject: [PATCH 3/3] feat(validate): pre-render gate for stuck boards, overlaps, and fonts Add scene_assets so we fail before Manim when a spec would dump-and-freeze, overflow the frame, use a machine-default Pango font, or when scenes.py is stale vs compile. generate-all runs the same check as a preflight. Compiled Text() now sets font=MANIM_FONT; helpers refresh if that constant is missing. Co-authored-by: jmjava --- AGENTS.md | 2 +- src/docgen/config.py | 11 ++ src/docgen/manim_scene_support.py | 16 +- src/docgen/pipeline.py | 19 ++ src/docgen/scene_asset_validate.py | 286 +++++++++++++++++++++++++++++ src/docgen/scene_spec.py | 11 +- src/docgen/validate.py | 32 ++++ tests/test_manim_scene_support.py | 12 +- tests/test_scene_asset_validate.py | 272 +++++++++++++++++++++++++++ tests/test_scene_spec.py | 2 +- tests/test_validate.py | 6 +- 11 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 src/docgen/scene_asset_validate.py create mode 100644 tests/test_scene_asset_validate.py diff --git a/AGENTS.md b/AGENTS.md index 67125f9..c63e0f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Commands registered on the **`docgen`** CLI include: - **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle (also runs for missing assets inside `generate-all`). - **`manim`** — render Manim scenes declared in config. - **`compose`** — mux narration audio with visual sources via ffmpeg. -- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`av_sync`** (soft; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related checks. +- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`scene_assets`** (pre-render: stuck-board cadence, frame-budget overlaps, `MANIM_FONT` consistency, stale helpers / stale compiled class — hard fail; also a `generate-all` gate before Manim), **`av_sync`** (soft; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related checks. - **`lint`** — narration lint helper. - **`narration-generate`** — LLM-assisted narration from hints and repo context; optional **`--revise --revision-notes`** for in-place edits (same contract as the wizard Revise button). - **`scene-spec-generate`** — LLM emits declarative **`*.scene.yaml`**; enforces frame budget + **subject-beat coverage** (dwell OK; cover topic shifts; reject invented labels). diff --git a/src/docgen/config.py b/src/docgen/config.py index 9b87d81..3e29ee3 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -301,6 +301,17 @@ def timing_sync_config(self) -> dict[str, Any]: defaults.update(self.raw.get("validation", {}).get("timing_sync", {})) return defaults + @property + def scene_assets_config(self) -> dict[str, Any]: + """Pre-render checks for stuck boards, overlaps, fonts, and compile sync. + + Runs in ``docgen validate`` (hard fail in ``--pre-push``) and as a + ``generate-all`` gate before Manim so a stale spec cannot burn a render. + """ + defaults: dict[str, Any] = {"enabled": True} + defaults.update(self.raw.get("validation", {}).get("scene_assets", {})) + return defaults + @property def story_end_config(self) -> dict[str, Any]: """Visual story finished early vs narration (``docgen validate`` ``story_end``). diff --git a/src/docgen/manim_scene_support.py b/src/docgen/manim_scene_support.py index ca84553..e681e19 100644 --- a/src/docgen/manim_scene_support.py +++ b/src/docgen/manim_scene_support.py @@ -103,6 +103,8 @@ def lint_manim_title_down_row_collision_risk(code: str) -> list[str]: C_TEAL = "#26c6da" C_PURPLE = "#ce93d8" C_WHITE = "#cdd6f4" +# Single family for every Text() — do not rely on Pango's machine default. +MANIM_FONT = "Liberation Sans" # Layout: default Manim 16:9 frame ~14.22 x 8. Leave margin so nothing touches edges. SAFE_CONTENT_WIDTH = 12.85 @@ -195,15 +197,15 @@ def _box(label, color, w=2.2, h=0.75, fs=18, subtitle="", shape="rounded"): stroke_color=color, stroke_width=2.5, fill_color=color, fill_opacity=0.28, ) - t = Text(str(label), font_size=fs, color=C_WHITE) + t = Text(str(label), font_size=fs, color=C_WHITE, font=MANIM_FONT) # Prefer white label text for contrast; fall back to the accent color when # the palette token is already near-white. if str(color) in (C_WHITE, "C_WHITE", "#cdd6f4"): t.set_color(color) sub = str(subtitle or "").strip() if sub: - sub_fs = max(10, int(fs * 0.72)) - s = Text(sub, font_size=sub_fs, color=C_WHITE) + sub_fs = max(14, int(fs * 0.72)) + s = Text(sub, font_size=sub_fs, color=C_WHITE, font=MANIM_FONT) if str(color) in (C_WHITE, "C_WHITE", "#cdd6f4"): s.set_color(color) s.set_opacity(0.85) @@ -1021,6 +1023,12 @@ def lint_generated_block( if not is_text: continue + kw_names = {kw.arg for kw in node.keywords if kw.arg} + if "font" not in kw_names: + issues.append( + f"line {node.lineno}: Text() is missing font=MANIM_FONT — " + "machine Pango defaults drift (font consistency)" + ) for kw in node.keywords: if kw.arg == "weight" and isinstance(kw.value, ast.Name) and kw.value.id == "BOLD": issues.append( @@ -1211,7 +1219,7 @@ def helper_needs_refresh(tree: ast.AST, name: str) -> bool: """True when a present helper is missing the current motion/clock API.""" for node in tree.body: if name == "_box" and isinstance(node, ast.FunctionDef) and node.name == "_box": - return "shape" not in _fn_arg_names(node) + return "shape" not in _fn_arg_names(node) or "MANIM_FONT" not in ast.unparse(node) if name == "_arrow" and isinstance(node, ast.FunctionDef) and node.name == "_arrow": return "get_critical_point" not in ast.unparse(node) if name == "_TimedScene" and isinstance(node, ast.ClassDef) and node.name == "_TimedScene": diff --git a/src/docgen/pipeline.py b/src/docgen/pipeline.py index c71788f..5840b95 100644 --- a/src/docgen/pipeline.py +++ b/src/docgen/pipeline.py @@ -71,6 +71,7 @@ def run( scene_list = self.config.pipeline_manim_scene_names() if scene_list: + self._preflight_scene_assets() print("\n=== Stage: Manim ===") from docgen.manim_runner import ManimRunner ManimRunner(self.config).render(scenes=scene_list) @@ -112,6 +113,24 @@ def run( print("\n=== Pipeline complete ===") + def _preflight_scene_assets(self) -> None: + """Fail before Manim when specs / helpers / compile would produce a stuck or overlapping board.""" + from docgen.config import Config + from docgen.scene_asset_validate import bundle_scene_asset_violations + + if not isinstance(self.config, Config): + return + print("\n=== Stage: Scene asset preflight ===") + issues = bundle_scene_asset_violations(self.config) + if issues: + shown = "\n ".join(issues[:20]) + more = f"\n (+{len(issues) - 20} more)" if len(issues) > 20 else "" + raise RuntimeError( + "scene asset preflight failed — fix specs/helpers or run " + f"`docgen scene-compile --retime` before Manim:\n {shown}{more}" + ) + print("[pipeline] scene assets ok (stuck / overlap / font / compile sync)") + def _manim_segment_ids(self) -> list[str]: ids: list[str] = [] for seg_id in self.config.segments_all: diff --git a/src/docgen/scene_asset_validate.py b/src/docgen/scene_asset_validate.py new file mode 100644 index 0000000..2b46e4f --- /dev/null +++ b/src/docgen/scene_asset_validate.py @@ -0,0 +1,286 @@ +"""Pre-render checks so bad scene assets fail before Manim / compose. + +Historical failure modes this module is meant to catch **offline**: + +* **Stuck boards** — FadeIn run_times race ``_clock`` (issue #66), then the + diagram dumps and freezes while narration continues. Also dwell that + overshoots the next ``wait_word``. +* **Overlaps** — a page stack that exceeds the Manim frame budget (boxes + clip or collide). ``layout_budget_violations`` already exists at generate + time; validate re-runs it so a stale spec cannot sneak into a render. +* **Font consistency** — ``Text()`` without an explicit ``font=`` picks up + whatever Pango default the machine has. Compiled scenes must use + ``MANIM_FONT``. +* **Stale helpers / stale compile** — ``scenes.py`` still has center-to-center + arrows or a ``_box`` that cannot take ``shape=``, or the generated class + no longer matches ``compile_scene_class`` (missing Indicate, old FadeIn). +""" + +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from docgen.config import Config + + +def dwell_overshoot_violations( + events: list[Any], + *, + slack: float = 0.05, +) -> list[str]: + """Fail when fade + dwell would push ``_clock`` past the next spoken start.""" + issues: list[str] = [] + for i, ev in enumerate(events): + nxt_start = None + for later in events[i + 1 :]: + if getattr(later, "word_start", None) is not None: + nxt_start = float(later.word_start) + break + if nxt_start is None: + continue + end = float(ev.effective_at) + float(ev.run_time) + float(ev.dwell_run_time) + if end > nxt_start + slack: + issues.append( + f"stuck: dwell/reveal overshoots next wait_word " + f"(label={ev.label!r} ends at {end:.2f}s, next start {nxt_start:.2f}s)" + ) + return issues + + +def _call_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Call): + node = node.func + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _first_arg_id(call: ast.Call) -> str | None: + if call.args and isinstance(call.args[0], ast.Name): + return call.args[0].id + return None + + +def motion_plan_from_source(source: str) -> list[str]: + """Ordered reveal / dwell / wait tokens from a compiled ``construct`` body.""" + try: + tree = ast.parse(source) + except SyntaxError: + return [] + body: list[ast.stmt] = [] + for node in tree.body: + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == "construct": + body = list(item.body) + break + plan: list[str] = [] + for stmt in body: + if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call): + if _call_name(stmt.value) == "_arrow" and stmt.value.args: + first = stmt.value.args[0] + if isinstance(first, ast.Call) and _call_name(first) == "get_center": + plan.append("arrow:center") + elif isinstance(first, ast.Name): + plan.append("arrow:edge") + continue + if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call): + continue + call = stmt.value + attr = _call_name(call) + if attr == "wait_until_word" and len(call.args) >= 2: + idx = call.args[1] + if isinstance(idx, ast.Constant): + plan.append(f"wait_word:{idx.value}") + continue + if attr != "timed_play": + continue + for arg in call.args: + if not isinstance(arg, ast.Call): + continue + name = _call_name(arg) + target = _first_arg_id(arg) + if name == "FadeIn": + has_shift = any(k.arg == "shift" for k in arg.keywords) + if target and str(target).startswith("_bx_"): + plan.append(f"reveal:{'slide' if has_shift else 'fade'}:{target}") + elif target and str(target).startswith("_ar_"): + plan.append(f"edge:fade:{target}") + elif name == "GrowFromCenter" and target: + plan.append(f"reveal:grow:{target}") + elif name == "Indicate" and target: + plan.append(f"dwell:pulse:{target}") + elif name == "Circumscribe" and target: + plan.append(f"dwell:ring:{target}") + elif name == "GrowArrow" and target: + plan.append(f"edge:grow:{target}") + elif name == "FadeOut": + plan.append("page_fade") + return plan + + +def extract_class_source(scenes_text: str, class_name: str) -> str | None: + try: + tree = ast.parse(scenes_text) + except SyntaxError: + return None + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return ast.get_source_segment(scenes_text, node) + return None + + +def helper_api_violations(scenes_text: str) -> list[str]: + """Stale ``_box`` / ``_arrow`` / ``_TimedScene`` that will mis-render new specs.""" + from docgen.manim_scene_support import helper_needs_refresh + + try: + tree = ast.parse(scenes_text) + except SyntaxError as exc: + return [f"helpers: scenes.py did not parse ({exc.msg})"] + defined: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.ClassDef)): + defined.add(node.name) + if not defined.intersection({"_box", "_arrow", "_TimedScene"}): + return [] + issues: list[str] = [] + if "MANIM_FONT" not in scenes_text: + issues.append( + "font: scenes.py is missing MANIM_FONT — run `docgen scene-compile` " + "to refresh helpers (Pango default fonts drift across machines)" + ) + for name in ("_box", "_arrow", "_TimedScene"): + if name in defined and helper_needs_refresh(tree, name): + issues.append( + f"helpers: {name} is stale (missing shape / edge-to-edge / not_past) — " + "run `docgen scene-compile` to refresh helper bodies" + ) + return issues + + +def compiled_scene_sync_violations( + spec: dict[str, Any], + words: list[dict[str, Any]] | None, + scenes_text: str, +) -> list[str]: + """Fail when ``scenes.py`` does not match a fresh compile of the spec.""" + from docgen.scene_spec import SceneSpecError, compile_scene_class + + class_name = str(spec.get("class_name") or "").strip() + if not class_name: + return ["compile_sync: spec is missing class_name"] + actual = extract_class_source(scenes_text, class_name) + if actual is None: + return [ + f"compile_sync: {class_name} is not in scenes.py — " + "run `docgen scene-compile` before `docgen manim`" + ] + try: + expected_src = compile_scene_class(spec, words=words or None) + except SceneSpecError as exc: + return [f"compile_sync: cannot compile spec ({exc})"] + expected = motion_plan_from_source(expected_src) + got = motion_plan_from_source(actual) + if expected == got: + return [] + return [ + "compile_sync: generated class is stale vs spec + timing.json " + f"(expected {expected} got {got}) — run `docgen scene-compile --retime`" + ] + + +def scene_asset_violations_for_segment(cfg: "Config", seg_id: str) -> list[str]: + """All pre-render issues for one manim segment (empty if nothing to check).""" + from docgen.scene_retime import list_scene_spec_paths + from docgen.scene_spec import ( + SceneSpecError, + layout_budget_violations, + load_scene_spec, + reveal_cadence_violations, + simulate_reveal_timeline, + validate_scene_spec, + ) + + issues: list[str] = [] + scenes_path = cfg.animations_dir / "scenes.py" + scenes_text = "" + if scenes_path.is_file(): + scenes_text = scenes_path.read_text(encoding="utf-8") + issues.extend(helper_api_violations(scenes_text)) + + paths = list_scene_spec_paths(cfg, segment_id=seg_id) + if not paths: + return issues + + block: dict[str, Any] = {} + timing_path = cfg.animations_dir / "timing.json" + stem = cfg.resolve_segment_name(seg_id) + if timing_path.is_file(): + import json + + try: + data = json.loads(timing_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + raw_block = data.get(stem) if isinstance(data, dict) else None + if isinstance(raw_block, dict): + block = raw_block + words = block.get("words") if isinstance(block.get("words"), list) else [] + + audio_end = 0.0 + for w in words: + if isinstance(w, dict): + try: + audio_end = max(audio_end, float(w.get("end", 0.0))) + except (TypeError, ValueError): + pass + + for path in paths: + try: + spec = load_scene_spec(path) + except SceneSpecError as exc: + issues.append(f"overlap: {path.name}: {exc}") + continue + try: + validate_scene_spec(spec, path_label=path.name) + except SceneSpecError as exc: + issues.append(f"overlap: {exc}") + continue + for msg in layout_budget_violations(spec): + issues.append(f"overlap: {path.name}: {msg}") + if words: + events = simulate_reveal_timeline(spec, words, clamp_run_times=True) + issues.extend( + f"stuck: {m}" for m in reveal_cadence_violations(events, audio_end=audio_end) + ) + issues.extend(dwell_overshoot_violations(events)) + if scenes_text: + merged = dict(spec) + if not merged.get("timing_key"): + merged["timing_key"] = stem + issues.extend( + compiled_scene_sync_violations(merged, words or None, scenes_text) + ) + return issues + + +def bundle_scene_asset_violations(cfg: "Config") -> list[str]: + """Preflight every manim segment. Used by ``generate-all`` before Manim.""" + issues: list[str] = [] + for seg_id in cfg.segments_all: + vm = cfg.visual_map.get(seg_id) + if not isinstance(vm, dict): + continue + vt = str(vm.get("type", "")).strip().lower() + if vt and vt != "manim": + continue + if not vt and not (vm.get("scene") or vm.get("class")): + continue + for msg in scene_asset_violations_for_segment(cfg, str(seg_id)): + issues.append(f"[{seg_id}] {msg}") + return issues diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index 14fdee3..76a1be9 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -18,6 +18,9 @@ ``shape`` / ``reveal`` / ``emphasis``). After each paced reveal, a **dwell** slot may play ``Indicate`` / ``Circumscribe`` when the gap to the next ``wait_word`` is long enough — clamped so ``_clock`` cannot race. + Every compiled ``Text()`` sets ``font=MANIM_FONT``. ``docgen validate`` + ``scene_assets`` re-checks cadence, frame budget, helpers, and compile sync + before a render. Typical workflow: @@ -1854,8 +1857,8 @@ def compile_scene_class( sub_fs = max(14, title_fs - 10) lines.extend( [ - f" _title_main = Text({title_text!r}, font_size={title_fs}, color={title_color})", - f" _title_sub = Text({title_subtitle!r}, font_size={sub_fs}, color={title_color})", + f" _title_main = Text({title_text!r}, font_size={title_fs}, color={title_color}, font=MANIM_FONT)", + f" _title_sub = Text({title_subtitle!r}, font_size={sub_fs}, color={title_color}, font=MANIM_FONT)", " _title_sub.set_opacity(0.85)", " title = VGroup(_title_main, _title_sub).arrange(DOWN, buff=0.12).to_edge(UP)", f" self.timed_play(Write(title), run_time={title_rt})", @@ -1865,7 +1868,7 @@ def compile_scene_class( else: lines.extend( [ - f" title = Text({title_text!r}, font_size={title_fs}, color={title_color}).to_edge(UP)", + f" title = Text({title_text!r}, font_size={title_fs}, color={title_color}, font=MANIM_FONT).to_edge(UP)", f" self.timed_play(Write(title), run_time={title_rt})", "", ] @@ -1949,7 +1952,7 @@ def compile_scene_class( if elabel: lvar = f"{evar}_lbl" lines.append( - f" {lvar} = Text({elabel!r}, font_size=16, color={ecol})" + f" {lvar} = Text({elabel!r}, font_size=16, color={ecol}, font=MANIM_FONT)" ) lines.append( f" {lvar}.move_to({evar}.get_center()).shift(UP * 0.22)" diff --git a/src/docgen/validate.py b/src/docgen/validate.py index 9d45466..e684071 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -236,6 +236,13 @@ def _lint_manim_text_usage( "use keyword form `Text(..., color=...)`." ) + kw_names = {kw.arg for kw in node.keywords if kw.arg} + if "font" not in kw_names: + issues.append( + f"{path}:{node.lineno} Text() is missing font=MANIM_FONT; " + "machine Pango defaults drift (font consistency)." + ) + for kw in node.keywords: if kw.arg == "weight" and kw.value is not None and _is_bold_weight(kw.value): issues.append( @@ -301,6 +308,7 @@ def validate_segment( if self.config.visual_map.get(seg_id, {}).get("type") == "manim": report.checks.append(self._check_manim_scene_lint()) report.checks.append(self._check_subject_beat_coverage(seg_id)) + report.checks.append(self._check_scene_assets(seg_id)) return report.to_dict() @@ -584,6 +592,30 @@ def _check_subject_beat_coverage(self, seg_id: str) -> CheckResult: ["Subject beats covered; no invented unspoken labels"], ) + def _check_scene_assets(self, seg_id: str) -> CheckResult: + """Pre-render stuck / overlap / font / compile-sync gate (no video required).""" + sa_cfg = self.config.scene_assets_config + if not sa_cfg.get("enabled", True): + return CheckResult( + "scene_assets", + True, + ["validation.scene_assets disabled (skipped)"], + ) + is_manim = self.config.visual_map.get(seg_id, {}).get("type") == "manim" + if not is_manim: + return CheckResult("scene_assets", True, ["non-manim (skipped)"]) + + from docgen.scene_asset_validate import scene_asset_violations_for_segment + + issues = scene_asset_violations_for_segment(self.config, seg_id) + if issues: + return CheckResult("scene_assets", False, issues[:20]) + return CheckResult( + "scene_assets", + True, + ["Spec layout, reveal cadence, helpers, and compiled class are consistent"], + ) + def _check_layout(self, path: Path) -> CheckResult: """Run overlap/spacing/edge layout checks on a Manim video recording.""" try: diff --git a/tests/test_manim_scene_support.py b/tests/test_manim_scene_support.py index 5a7f7e1..41c6452 100644 --- a/tests/test_manim_scene_support.py +++ b/tests/test_manim_scene_support.py @@ -260,7 +260,7 @@ def construct(self): "class DemoFunctionScene(_TimedScene):\n" " def construct(self):\n" " self.camera.background_color = C_BG\n" - " title = Text('demo', font_size=42, color=C_ACCENT)\n" + " title = Text('demo', font_size=42, color=C_ACCENT, font=MANIM_FONT)\n" " self.timed_play(Write(title), run_time=1.0)\n" " self.timed_play(*[FadeOut(m) for m in self.mobjects], run_time=1.0)\n" " self.timed_wait(0.5)\n" @@ -384,6 +384,16 @@ def test_lint_passes_vgroup_row_without_title_down_collision() -> None: assert issues == [] +def test_lint_flags_missing_font_keyword() -> None: + code = ( + "class A(_TimedScene):\n" + " def construct(self):\n" + " Text('hi', font_size=20, color=C_WHITE)\n" + ) + issues = lint_generated_block(code, min_font_size=14, unsafe_unicode=[]) + assert any("font=MANIM_FONT" in i for i in issues) + + def test_lint_flags_small_font_size() -> None: code = ( "class A(_TimedScene):\n" diff --git a/tests/test_scene_asset_validate.py b/tests/test_scene_asset_validate.py new file mode 100644 index 0000000..eaf49df --- /dev/null +++ b/tests/test_scene_asset_validate.py @@ -0,0 +1,272 @@ +"""Pre-render scene asset checks: stuck boards, overlaps, fonts, compile sync.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml + +from docgen.config import Config +from docgen.manim_scene_support import BOOTSTRAP_HEADER +from docgen.scene_asset_validate import ( + bundle_scene_asset_violations, + compiled_scene_sync_violations, + dwell_overshoot_violations, + helper_api_violations, + motion_plan_from_source, + scene_asset_violations_for_segment, +) +from docgen.scene_spec import ( + RevealEvent, + compile_scene_class, + simulate_reveal_timeline, +) +from docgen.validate import Validator + + +def _box(label: str, **extra: object) -> dict: + out: dict = { + "label": label, + "color": "C_GREEN", + "width": 3.0, + "height": 0.8, + "font_size": 18, + } + out.update(extra) + return out + + +def _spec(boxes: list[dict], *, class_name: str = "MotionScene") -> dict: + return { + "segment_id": "01", + "class_name": class_name, + "timing_key": "01-x", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [{"run_time": 1.5, "boxes": boxes}], + } + + +def _wide_words() -> list[dict]: + return [ + {"word": "Alpha", "start": 1.2, "end": 1.4}, + {"word": "Beta", "start": 8.0, "end": 8.3}, + {"word": "tail", "start": 16.0, "end": 16.4}, + ] + + +def _bundle(tmp_path: Path) -> Config: + raw = { + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-x"}, + "visual_map": {"01": {"type": "manim", "scene": "MotionScene"}}, + } + (tmp_path / "docgen.yaml").write_text(yaml.dump(raw), encoding="utf-8") + for d in ("narration", "audio", "recordings", "animations"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) + return Config.from_yaml(tmp_path / "docgen.yaml") + + +def test_dwell_overshoot_flags_clock_past_next_word() -> None: + events = [ + RevealEvent( + label="Alpha", + page=0, + row=0, + box=0, + wait_word=0, + word_start=1.0, + effective_at=1.0, + wait_skipped=False, + run_time=0.5, + page_fade_out=0.0, + emphasis="pulse", + dwell_run_time=2.0, + ), + RevealEvent( + label="Beta", + page=0, + row=0, + box=1, + wait_word=1, + word_start=1.6, + effective_at=3.5, + wait_skipped=True, + run_time=0.25, + page_fade_out=0.0, + ), + ] + issues = dwell_overshoot_violations(events) + assert issues + assert any("overshoots" in i for i in issues) + + +def test_dwell_overshoot_clean_when_clamped() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)]) + events = simulate_reveal_timeline(spec, _wide_words(), clamp_run_times=True) + assert dwell_overshoot_violations(events) == [] + + +def test_motion_plan_reads_reveal_dwell_and_edge_arrows() -> None: + src = """ +class X(_TimedScene): + def construct(self): + _ar_0_0 = _arrow(_bx_0_0_0, _bx_0_0_1, C_ACCENT, style='solid') + self.wait_until_word(timing_words, 0) + self.timed_play(GrowFromCenter(_bx_0_0_0), run_time=0.4) + self.timed_play(Indicate(_bx_0_0_0), run_time=0.5) + self.wait_until_word(timing_words, 1) + self.timed_play(FadeIn(_bx_0_0_1), GrowArrow(_ar_0_0), run_time=0.4) +""" + plan = motion_plan_from_source(src) + assert plan == [ + "arrow:edge", + "wait_word:0", + "reveal:grow:_bx_0_0_0", + "dwell:pulse:_bx_0_0_0", + "wait_word:1", + "reveal:fade:_bx_0_0_1", + "edge:grow:_ar_0_0", + ] + + +def test_motion_plan_flags_center_arrows() -> None: + src = """ +class X(_TimedScene): + def construct(self): + _ar_0_0 = _arrow(_bx_0_0_0.get_center(), _bx_0_0_1.get_center(), C_ACCENT) + self.timed_play(FadeIn(_bx_0_0_0), run_time=0.4) +""" + assert "arrow:center" in motion_plan_from_source(src) + + +def test_helper_api_flags_stale_box_and_missing_font() -> None: + stale = """ +def _box(label, color, w=2.2, h=0.75, fs=18): + return None +def _arrow(start, end, color="#fff"): + return start +class _TimedScene: + def timed_play(self, *a, run_time=1.0): + pass +""" + issues = helper_api_violations(stale) + assert any(i.startswith("font:") for i in issues) + assert any("stale" in i for i in issues) + + +def test_helper_api_clean_for_current_bootstrap() -> None: + assert helper_api_violations(BOOTSTRAP_HEADER) == [] + + +def test_compiled_sync_passes_when_scenes_match_compile() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)]) + words = _wide_words() + class_src = compile_scene_class(spec, words=words) + scenes = BOOTSTRAP_HEADER + "\n" + class_src + assert compiled_scene_sync_violations(spec, words, scenes) == [] + + +def test_compiled_sync_fails_when_indicate_stripped() -> None: + spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)]) + words = _wide_words() + class_src = compile_scene_class(spec, words=words) + stripped = class_src.replace("Indicate", "FadeIn") + scenes = BOOTSTRAP_HEADER + "\n" + stripped + issues = compiled_scene_sync_violations(spec, words, scenes) + assert issues + assert any("stale" in i for i in issues) + + +def test_compiled_sync_fails_when_class_missing() -> None: + spec = _spec([_box("Alpha")]) + issues = compiled_scene_sync_violations(spec, None, BOOTSTRAP_HEADER) + assert any("not in scenes.py" in i for i in issues) + + +def test_layout_budget_is_reported_as_overlap(tmp_path: Path) -> None: + cfg = _bundle(tmp_path) + specs = cfg.animations_dir / "specs" + specs.mkdir(parents=True) + tall = { + "segment_id": "01", + "class_name": "MotionScene", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + {"run_time": 0.5, "boxes": [_box("A", height=3.0)]}, + {"run_time": 0.5, "boxes": [_box("B", height=3.0)]}, + {"run_time": 0.5, "boxes": [_box("C", height=3.0)]}, + ], + } + (specs / "01-x.scene.yaml").write_text(yaml.dump(tall), encoding="utf-8") + issues = scene_asset_violations_for_segment(cfg, "01") + assert any(i.startswith("overlap:") for i in issues) + + +def test_validator_scene_assets_hard_fails_stale_helpers(tmp_path: Path) -> None: + cfg = _bundle(tmp_path) + (cfg.animations_dir / "scenes.py").write_text( + "def _box(label, color, w=1, h=1, fs=18):\n return None\n" + "def _arrow(start, end, color='#fff'):\n return start\n" + "class _TimedScene:\n def timed_play(self, *a, run_time=1.0):\n pass\n", + encoding="utf-8", + ) + check = Validator(cfg)._check_scene_assets("01") + assert not check.passed + assert any("helpers" in d or "font" in d for d in check.details) + + +def test_validator_scene_assets_disabled(tmp_path: Path) -> None: + raw = { + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-x"}, + "visual_map": {"01": {"type": "manim", "scene": "X"}}, + "validation": {"scene_assets": {"enabled": False}}, + } + (tmp_path / "docgen.yaml").write_text(yaml.dump(raw), encoding="utf-8") + for d in ("narration", "audio", "recordings", "animations"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) + cfg = Config.from_yaml(tmp_path / "docgen.yaml") + check = Validator(cfg)._check_scene_assets("01") + assert check.passed + assert any("disabled" in d for d in check.details) + + +def test_pre_push_scene_assets_is_hard_fail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _bundle(tmp_path) + (cfg.audio_dir / "01-x.mp3").write_bytes(b"fake") + (cfg.animations_dir / "scenes.py").write_text( + "def _box(label, color, w=1, h=1, fs=18):\n return None\n" + "def _arrow(start, end, color='#fff'):\n return start\n" + "class _TimedScene:\n def timed_play(self, *a, run_time=1.0):\n pass\n", + encoding="utf-8", + ) + monkeypatch.setattr(Validator, "_probe_media_duration", staticmethod(lambda p: 10.0)) + (cfg.animations_dir / "timing.json").write_text( + json.dumps( + { + "01-x": { + "text": "hello", + "words": [{"word": "hello", "start": 0.0, "end": 9.5}], + "segments": [{"start": 0.0, "end": 9.5, "text": "hello"}], + } + } + ), + encoding="utf-8", + ) + with pytest.raises(SystemExit): + Validator(cfg).run_pre_push() + + +def test_bundle_preflight_returns_segment_prefixed_issues(tmp_path: Path) -> None: + cfg = _bundle(tmp_path) + (cfg.animations_dir / "scenes.py").write_text( + "def _box(label, color, w=1, h=1, fs=18):\n return None\n" + "def _arrow(start, end, color='#fff'):\n return start\n" + "class _TimedScene:\n def timed_play(self, *a, run_time=1.0):\n pass\n", + encoding="utf-8", + ) + issues = bundle_scene_asset_violations(cfg) + assert issues + assert all(i.startswith("[01]") for i in issues) diff --git a/tests/test_scene_spec.py b/tests/test_scene_spec.py index 5c4e374..1b91d20 100644 --- a/tests/test_scene_spec.py +++ b/tests/test_scene_spec.py @@ -45,7 +45,7 @@ def test_load_and_compile_fixture() -> None: assert "def construct(self):" in out assert "timing_words = _load_timing_words('99-overview')" in out assert "_docgen_segs = _load_timing('99-overview')" in out - assert "title = Text('Test declarative', font_size=40, color=C_WHITE).to_edge(UP)" in out + assert "title = Text('Test declarative', font_size=40, color=C_WHITE, font=MANIM_FONT).to_edge(UP)" in out assert "_bx_0_0_0 = _box('Alpha', C_ORANGE, 5.0, 1.2, 28)" in out assert "_bx_0_1_0 = _box('Beta', C_BLUE, 3.5, 1.2, 24)" in out assert "_bx_0_1_1 = _box('Gamma', C_TEAL, 3.5, 1.2, 24)" in out diff --git a/tests/test_validate.py b/tests/test_validate.py index d2b7885..4116168 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -397,8 +397,8 @@ def test_clean_text_usage_passes(self, cfg_dir): class Demo(Scene): def construct(self): - Text("Some label", font_size=16, color=C_BLUE) - Text("Heading", font_size=36, color=WHITE) + Text("Some label", font_size=16, color=C_BLUE, font=MANIM_FONT) + Text("Heading", font_size=36, color=WHITE, font=MANIM_FONT) """.strip(), ) v = Validator(config) @@ -496,7 +496,7 @@ def test_no_unsafe_unicode_when_disabled(self, cfg_dir): class Demo(Scene): def construct(self): - Text("arrow \u2192 here", font_size=16) + Text("arrow \u2192 here", font_size=16, font=MANIM_FONT) """.strip(), extra_cfg={"manim": {"unsafe_unicode": []}}, )