Skip to content

Commit 02427f5

Browse files
jmjavacursoragent
andcommitted
Fail closed when a generated scenes.py label or run_time drifts from a fresh compile.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 59cb452 commit 02427f5

4 files changed

Lines changed: 130 additions & 5 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Commands registered on the **`docgen`** CLI include:
7373
- **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via OpenAI Images or xAI Imagine into the bundle (also runs for missing assets inside `generate-all`).
7474
- **`manim`** — render Manim scenes declared in config.
7575
- **`compose`** — mux narration audio with visual sources via ffmpeg.
76-
- **`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`** (hard fail on `--pre-push` / `generate-all`; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related visual-sync checks (`ocr_scan`, `layout`, `freeze_ratio` — hard fail on `--pre-push` / `generate-all`). Missing tesseract fails `ocr_scan` / `av_sync` / `layout` (not skip-PASS). Missing audio or an LFS pointer fails `timing_sync` and recording media gates (`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) (not skip-PASS). Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for `type: manim` (not skip-PASS).
76+
- **`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 including hand-edited generated-region labels and `run_time` — hard fail; also a `generate-all` gate before Manim), **`av_sync`** (hard fail on `--pre-push` / `generate-all`; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related visual-sync checks (`ocr_scan`, `layout`, `freeze_ratio` — hard fail on `--pre-push` / `generate-all`). Missing tesseract fails `ocr_scan` / `av_sync` / `layout` (not skip-PASS). Missing audio or an LFS pointer fails `timing_sync` and recording media gates (`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) (not skip-PASS). Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for `type: manim` (not skip-PASS).
7777
- **`lint`** — narration lint helper.
7878
- **`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).
7979
- **`scene-spec-generate`** — LLM emits declarative **`*.scene.yaml`**; enforces frame budget + **subject-beat coverage** (dwell OK; cover topic shifts; reject invented labels).

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ If you still need the legacy behaviour, pin a pre-removal commit
6767
Missing audio or an LFS pointer fails `timing_sync` and recording media gates
6868
(`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) instead of skip-PASS.
6969
Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for
70-
`type: manim` instead of skip-PASS.
70+
`type: manim` instead of skip-PASS. A hand-edited generated-region label
71+
or `run_time` in `scenes.py` fails `scene_assets` compile_sync (clock /
72+
benchmark still execute a fresh `compile_scene_class`, not the on-disk file).
7173
- **GitHub Pages** — auto-generate `index.html`, deploy workflow, LFS rules,
7274
`.gitignore`.
7375
- **Wizard** — local web GUI to bootstrap narration scripts from existing project

‎src/docgen/scene_asset_validate.py‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
``MANIM_FONT``.
1515
* **Stale helpers / stale compile** — ``scenes.py`` still has center-to-center
1616
arrows or a ``_box`` that cannot take ``shape=``, or the generated class
17-
no longer matches ``compile_scene_class`` (missing Indicate, old FadeIn).
17+
no longer matches a fresh ``compile_scene_class`` (missing Indicate, old
18+
FadeIn, hand-edited box/title label, or ``run_time``). Clock / benchmark
19+
still execute that fresh compile, not the on-disk ``scenes.py``.
1820
"""
1921

2022
from __future__ import annotations
@@ -117,6 +119,63 @@ def _first_arg_id(call: ast.Call) -> str | None:
117119
return None
118120

119121

122+
def _const_value(node: ast.AST) -> object | None:
123+
if isinstance(node, ast.Constant):
124+
return node.value
125+
return None
126+
127+
128+
def _construct_calls(source: str) -> list[ast.Call]:
129+
try:
130+
tree = ast.parse(source)
131+
except SyntaxError:
132+
return []
133+
for node in tree.body:
134+
if not isinstance(node, ast.ClassDef):
135+
continue
136+
for item in node.body:
137+
if isinstance(item, ast.FunctionDef) and item.name == "construct":
138+
return [c for c in ast.walk(item) if isinstance(c, ast.Call)]
139+
return []
140+
141+
142+
def generated_labels_from_source(source: str) -> list[str]:
143+
"""Ordered ``_box`` / ``Text`` string labels from a compiled ``construct``."""
144+
labels: list[str] = []
145+
for call in _construct_calls(source):
146+
if _call_name(call) not in {"_box", "Text"} or not call.args:
147+
continue
148+
val = _const_value(call.args[0])
149+
if isinstance(val, str):
150+
labels.append(val)
151+
return labels
152+
153+
154+
def play_run_times_from_source(source: str) -> list[object]:
155+
"""Ordered ``timed_play(..., run_time=)`` constants from ``construct``."""
156+
times: list[object] = []
157+
for call in _construct_calls(source):
158+
if _call_name(call) != "timed_play":
159+
continue
160+
for kw in call.keywords:
161+
if kw.arg != "run_time":
162+
continue
163+
val = _const_value(kw.value)
164+
if val is not None:
165+
times.append(val)
166+
break
167+
return times
168+
169+
170+
def _sync_fingerprint(source: str) -> tuple[list[str], list[str], list[object]]:
171+
"""Motion tokens plus the label / run_time text ``motion_plan`` ignores."""
172+
return (
173+
motion_plan_from_source(source),
174+
generated_labels_from_source(source),
175+
play_run_times_from_source(source),
176+
)
177+
178+
120179
def motion_plan_from_source(source: str) -> list[str]:
121180
"""Ordered reveal / dwell / wait tokens from a compiled ``construct`` body."""
122181
try:
@@ -242,8 +301,8 @@ def compiled_scene_sync_violations(
242301
expected_src = compile_scene_class(spec, words=words or None)
243302
except SceneSpecError as exc:
244303
return [f"compile_sync: cannot compile spec ({exc})"]
245-
expected = motion_plan_from_source(expected_src)
246-
got = motion_plan_from_source(actual)
304+
expected = _sync_fingerprint(expected_src)
305+
got = _sync_fingerprint(actual)
247306
if expected == got:
248307
return []
249308
return [

‎tests/test_scene_asset_validate.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import re
67
from pathlib import Path
78

89
import pytest
@@ -15,9 +16,11 @@
1516
bundle_scene_asset_violations,
1617
compiled_scene_sync_violations,
1718
dwell_overshoot_violations,
19+
generated_labels_from_source,
1820
helper_api_violations,
1921
hold_idle_violations,
2022
motion_plan_from_source,
23+
play_run_times_from_source,
2124
scene_asset_violations_for_segment,
2225
)
2326
from docgen.scene_spec import (
@@ -285,6 +288,67 @@ def test_compiled_sync_fails_when_class_missing() -> None:
285288
assert any("not in scenes.py" in i for i in issues)
286289

287290

291+
def test_compiled_sync_fails_when_generated_label_changed() -> None:
292+
"""Leftover #11: motion_plan ignores label text; compile_sync must not."""
293+
spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)])
294+
words = _wide_words()
295+
class_src = compile_scene_class(spec, words=words)
296+
tampered = class_src.replace("'Alpha'", "'Hacked'", 1)
297+
assert "'Hacked'" in tampered
298+
assert motion_plan_from_source(class_src) == motion_plan_from_source(tampered)
299+
assert generated_labels_from_source(class_src) != generated_labels_from_source(tampered)
300+
issues = compiled_scene_sync_violations(spec, words, BOOTSTRAP_HEADER + "\n" + tampered)
301+
assert issues
302+
assert any("compile_sync" in i and "stale" in i for i in issues)
303+
304+
305+
def test_compiled_sync_fails_when_run_time_changed() -> None:
306+
"""Leftover #11: motion_plan ignores timed_play run_time; compile_sync must not."""
307+
spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)])
308+
words = _wide_words()
309+
class_src = compile_scene_class(spec, words=words)
310+
tampered, n = re.subn(r"run_time=\d+(?:\.\d+)?", "run_time=9.99", class_src, count=1)
311+
assert n == 1
312+
assert motion_plan_from_source(class_src) == motion_plan_from_source(tampered)
313+
assert play_run_times_from_source(class_src) != play_run_times_from_source(tampered)
314+
issues = compiled_scene_sync_violations(spec, words, BOOTSTRAP_HEADER + "\n" + tampered)
315+
assert issues
316+
assert any("compile_sync" in i and "stale" in i for i in issues)
317+
318+
319+
def test_scene_assets_fails_when_generated_label_changed(tmp_path: Path) -> None:
320+
"""Change a generated-region label in scenes.py; scene_assets must fail."""
321+
cfg = _bundle(tmp_path)
322+
specs = cfg.animations_dir / "specs"
323+
specs.mkdir(parents=True)
324+
spec = _spec([_box("Alpha", wait_word=0), _box("Beta", wait_word=1)])
325+
(specs / "01-x.scene.yaml").write_text(yaml.dump(spec), encoding="utf-8")
326+
words = _wide_words()
327+
(cfg.animations_dir / "timing.json").write_text(
328+
json.dumps(
329+
{
330+
"01-x": {
331+
"text": "Alpha Beta tail",
332+
"words": words,
333+
"segments": [{"start": 1.2, "end": 16.4, "text": "Alpha Beta tail"}],
334+
}
335+
}
336+
)
337+
+ "\n",
338+
encoding="utf-8",
339+
)
340+
class_src = compile_scene_class({**spec, "timing_key": "01-x"}, words=words)
341+
tampered = class_src.replace("'Alpha'", "'Hacked'", 1)
342+
(cfg.animations_dir / "scenes.py").write_text(
343+
BOOTSTRAP_HEADER + "\n" + tampered, encoding="utf-8"
344+
)
345+
issues = scene_asset_violations_for_segment(cfg, "01")
346+
assert any("compile_sync" in i and "stale" in i for i in issues)
347+
check = Validator(cfg)._check_scene_assets("01")
348+
assert not check.passed
349+
assert any("compile_sync" in d for d in check.details)
350+
351+
288352
def test_layout_budget_is_reported_as_overlap(tmp_path: Path) -> None:
289353
cfg = _bundle(tmp_path)
290354
specs = cfg.animations_dir / "specs"

0 commit comments

Comments
 (0)