diff --git a/AGENTS.md b/AGENTS.md index c137912..9a01e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Commands registered on the **`docgen`** CLI include: - **`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`). - **`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), **`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). +- **`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). - **`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/README.md b/README.md index e290f48..8888cd2 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ If you still need the legacy behaviour, pin a pre-removal commit Missing tesseract fails `ocr_scan` / `av_sync` / `layout` instead of skip-PASS. Missing audio or an LFS pointer fails `timing_sync` and recording media gates (`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) instead of skip-PASS. + Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for + `type: manim` instead of skip-PASS. - **GitHub Pages** — auto-generate `index.html`, deploy workflow, LFS rules, `.gitignore`. - **Wizard** — local web GUI to bootstrap narration scripts from existing project diff --git a/src/docgen/validate.py b/src/docgen/validate.py index c2951d7..e79031b 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -62,6 +62,18 @@ def _tesseract_unavailable_detail() -> str | None: return None +def _missing_manim_spec_result(check_name: str, spec_label: str) -> CheckResult: + """Fail-closed when a manim segment has no declarative ``*.scene.yaml``.""" + return CheckResult( + check_name, + False, + [ + f"No {spec_label} — {check_name} cannot score a manim segment " + "without a declarative spec (hand-authored scenes.py is not skip-PASS)" + ], + ) + + def _sample_frames(path: Path, interval_sec: float = 2.0) -> list[tuple[float, np.ndarray]]: """Read frames at *interval_sec* across the entire video. Returns (timestamp, frame) pairs.""" cap = cv2.VideoCapture(str(path)) @@ -592,24 +604,17 @@ def _check_subject_beat_coverage(self, seg_id: str) -> CheckResult: ["validation.subject_beat_coverage disabled in config (skipped)"], ) - narr_path = self._find_narration(seg_id) - if narr_path is None or not narr_path.is_file(): - return CheckResult( - "subject_beat_coverage", - True, - ["No narration file (skipped)"], - ) - seg_name = self.config.resolve_segment_name(seg_id) spec_path = self.config.animations_dir / "specs" / f"{seg_name}.scene.yaml" if not spec_path.is_file(): + return _missing_manim_spec_result("subject_beat_coverage", spec_path.name) + + narr_path = self._find_narration(seg_id) + if narr_path is None or not narr_path.is_file(): return CheckResult( "subject_beat_coverage", True, - [ - f"No {spec_path.name} (skipped — hand-authored scenes.py " - "without declarative spec)" - ], + ["No narration file (skipped)"], ) import yaml @@ -872,7 +877,9 @@ def _check_story_end(self, seg_id: str) -> CheckResult: Muxed recordings can still match mp3 length (compose freezes the last frame) while the diagram finished early. Uses scene-spec label→``wait_word`` starts - vs audio/transcript end. Hard fail in ``--pre-push`` (same as ``av_sync``). + vs audio/transcript end. A manim segment with no ``*.scene.yaml`` fails + (hand-authored ``scenes.py`` is not skip-PASS). Hard fail in + ``--pre-push`` (same as ``av_sync``). """ se_cfg = self.config.story_end_config if not se_cfg.get("enabled", True): @@ -887,8 +894,8 @@ def _check_story_end(self, seg_id: str) -> CheckResult: paths = list_scene_spec_paths(self.config, segment_id=seg_id) if not paths: - return CheckResult( - "story_end", True, ["No animations/specs/*.scene.yaml (skipped)"] + return _missing_manim_spec_result( + "story_end", "animations/specs/*.scene.yaml" ) from docgen.timestamps import TimestampError diff --git a/tests/test_validate_timing_sync.py b/tests/test_validate_timing_sync.py index 23e26a5..2775ca4 100644 --- a/tests/test_validate_timing_sync.py +++ b/tests/test_validate_timing_sync.py @@ -424,6 +424,36 @@ def test_narration_lint_fails_when_file_empty(tmp_path: Path) -> None: assert any("empty after markdown stripping" in d for d in check.details) +def _write_paced_hand_authored_scenes(cfg: Config) -> None: + """Hand-authored scenes.py with paced timed_play and no declarative spec.""" + (cfg.animations_dir / "scenes.py").write_text( + "class XScene:\n" + " def construct(self):\n" + " self.wait_until_word([], 0)\n" + " self.timed_play(FadeIn(box), run_time=0.4)\n", + encoding="utf-8", + ) + + +@pytest.mark.parametrize("check_name", ("story_end", "subject_beat_coverage")) +def test_hand_authored_paced_manim_without_spec_fails(cfg, check_name: str) -> None: + """Manim + paced timed_play + no *.scene.yaml must fail, not skip-PASS.""" + _write_paced_hand_authored_scenes(cfg) + (cfg.narration_dir / "01-x.md").write_text( + "The bootstrap pipeline seeds the cluster.\n", + encoding="utf-8", + ) + v = Validator(cfg) + if check_name == "story_end": + check = v._check_story_end("01") + else: + check = v._check_subject_beat_coverage("01") + assert check.name == check_name + assert check.passed is False + assert not any("skipped" in d.lower() for d in check.details) + assert any("hand-authored" in d.lower() or "declarative spec" in d.lower() for d in check.details) + + def test_story_end_fails_when_paced_spec_has_no_timing_words(cfg) -> None: _write_scene_spec(cfg, labels=["Alpha"]) check = Validator(cfg)._check_story_end("01")