From 33fd0bbac1ecb73a706ac48da98573ee39ff9058 Mon Sep 17 00:00:00 2001 From: John Menke Date: Fri, 11 Sep 2026 15:08:54 -0400 Subject: [PATCH 1/2] Fail closed when audio is missing or media is an LFS pointer. timing_sync and recording stream/drift/ocr/av_sync no longer skip-PASS on omitted mp3 or Git LFS pointer files. Co-authored-by: Cursor --- AGENTS.md | 2 +- README.md | 2 ++ src/docgen/validate.py | 36 ++++++++++++++++----- tests/test_validate_timing_sync.py | 52 ++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c31df3e..c137912 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). +- **`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). - **`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 00785f8..e290f48 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ If you still need the legacy behaviour, pin a pre-removal commit hard fail), and **av_sync** (OCR check that scene-spec label anchors appear on screen near their spoken time — hard fail on `--pre-push` / `generate-all`). 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. - **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 9a0660d..8a42ce5 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -2,7 +2,9 @@ Core checks (freeze_ratio, blank_frames) use only cv2 — always available. OCR / av_sync / layout need pytesseract plus the tesseract binary. Missing -either fails those checks (not skip-PASS). Other cv2 checks still run. +either fails those checks (not skip-PASS). Missing audio or a Git LFS pointer +fails ``timing_sync`` and recording media gates (not skip-PASS). Other cv2 +checks still run. """ from __future__ import annotations @@ -101,6 +103,26 @@ def _is_lfs_pointer(path: Path) -> bool: return False +def _unusable_audio_detail(seg_id: str, audio: Path | None) -> str | None: + """Return a fail-closed detail when the segment mp3 is missing or an LFS pointer.""" + if audio is None: + return ( + f"No audio for {seg_id} — timing_sync cannot compare the mp3 to timing.json" + ) + if _is_lfs_pointer(audio): + return f"Audio is an LFS pointer ({audio.name}) — not actual media" + return None + + +_LFS_MEDIA_GATES = ("stream_presence", "av_drift", "ocr_scan", "av_sync") + + +def _lfs_pointer_media_fails(seg_id: str, rec: Path) -> list[CheckResult]: + """Fail recording media gates when the file is a Git LFS pointer, not video.""" + detail = f"LFS pointer — media checks cannot run for {seg_id} ({rec.name})" + return [CheckResult(name, False, [detail]) for name in _LFS_MEDIA_GATES] + + def _is_text_call(node: ast.Call) -> bool: func = node.func if isinstance(func, ast.Name): @@ -293,9 +315,7 @@ def validate_segment( rec = self._find_recording(seg_id) if rec and _is_lfs_pointer(rec): - report.checks.append( - CheckResult("lfs_pointer", True, [f"LFS pointer — skipping media checks for {seg_id}"]) - ) + report.checks.extend(_lfs_pointer_media_fails(seg_id, rec)) elif rec: vmap0 = self.config.visual_map.get(seg_id, {}) vt0 = vmap0.get("type") if isinstance(vmap0, dict) else None @@ -767,10 +787,10 @@ def _check_timing_sync(self, seg_id: str) -> CheckResult: return CheckResult("timing_sync", True, ["validation.timing_sync disabled (skipped)"]) audio = self._find_audio(seg_id) - if not audio: - return CheckResult("timing_sync", True, [f"No audio for {seg_id} (skipped)"]) - if _is_lfs_pointer(audio): - return CheckResult("timing_sync", True, ["Audio is an LFS pointer (skipped)"]) + unusable = _unusable_audio_detail(seg_id, audio) + if unusable: + return CheckResult("timing_sync", False, [unusable]) + assert audio is not None is_manim = self.config.visual_map.get(seg_id, {}).get("type") == "manim" from docgen.timestamps import TimestampError diff --git a/tests/test_validate_timing_sync.py b/tests/test_validate_timing_sync.py index 04f38d6..55861a2 100644 --- a/tests/test_validate_timing_sync.py +++ b/tests/test_validate_timing_sync.py @@ -145,11 +145,12 @@ def test_missing_timing_entry_skips_for_non_manim(self, tmp_path, monkeypatch) - check = Validator(cfg)._check_timing_sync("01") assert check.passed - def test_no_audio_skips(self, tmp_path) -> None: + def test_no_audio_fails(self, tmp_path) -> None: cfg = _bundle(tmp_path) check = Validator(cfg)._check_timing_sync("01") - assert check.passed - assert any("skipped" in d.lower() for d in check.details) + assert check.passed is False + assert any("No audio" in d for d in check.details) + assert not any("skipped" in d.lower() for d in check.details) def test_disabled_via_config(self, tmp_path, monkeypatch) -> None: cfg = _bundle(tmp_path) @@ -560,3 +561,48 @@ def test_tesseract_missing_does_not_skip_pass( assert check.passed is False assert any("tesseract unavailable" in d for d in check.details) assert not any("skipped" in d.lower() for d in check.details) + + +_LFS_POINTER_BYTES = ( + b"version https://git-lfs.github.com/spec/v1\n" + b"oid sha256:" + (b"ab" * 32) + b"\n" + b"size 123\n" +) + + +def _write_lfs_pointer(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(_LFS_POINTER_BYTES) + + +class TestMissingMediaFails: + """Pointer-only or omitted audio must fail media gates, not skip-PASS.""" + + def test_omitted_audio_timing_sync_fails(self, tmp_path) -> None: + cfg = _bundle(tmp_path) + check = Validator(cfg)._check_timing_sync("01") + assert check.name == "timing_sync" + assert check.passed is False + assert any("No audio" in d for d in check.details) + assert not any("skipped" in d.lower() for d in check.details) + + def test_lfs_pointer_audio_timing_sync_fails(self, tmp_path) -> None: + cfg = _bundle(tmp_path) + _write_lfs_pointer(cfg.audio_dir / "01-x.mp3") + check = Validator(cfg)._check_timing_sync("01") + assert check.name == "timing_sync" + assert check.passed is False + assert any("LFS pointer" in d for d in check.details) + assert not any("skipped" in d.lower() for d in check.details) + + def test_lfs_pointer_recording_does_not_skip_pass(self, tmp_path) -> None: + cfg = _bundle(tmp_path) + _write_lfs_pointer(cfg.recordings_dir / "01-x.mp4") + report = Validator(cfg).validate_segment("01") + assert report["passed"] is False + by_name = {c["name"]: c for c in report["checks"]} + for name in ("stream_presence", "av_drift", "ocr_scan", "av_sync"): + assert name in by_name, name + assert by_name[name]["passed"] is False, name + assert any("LFS pointer" in d for d in by_name[name]["details"]) + assert not any("skipped" in d.lower() for d in by_name[name]["details"]) From 0c04149a43a377fa8e8fb7dc0c654f4eadbd76ae Mon Sep 17 00:00:00 2001 From: John Menke Date: Fri, 11 Sep 2026 15:11:23 -0400 Subject: [PATCH 2/2] Keep missing-audio timing_sync soft on --pre-push. Ungenerated bundles stay pushable; LFS pointer and stale timing remain hard fails. Co-authored-by: Cursor --- src/docgen/validate.py | 15 +++++++++++---- tests/test_cli_env.py | 2 ++ tests/test_validate_timing_sync.py | 6 ++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/docgen/validate.py b/src/docgen/validate.py index 8a42ce5..c2951d7 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -123,6 +123,16 @@ def _lfs_pointer_media_fails(seg_id: str, rec: Path) -> list[CheckResult]: return [CheckResult(name, False, [detail]) for name in _LFS_MEDIA_GATES] +def _is_pre_push_soft(check: dict[str, Any]) -> bool: + """True for not-generated-yet gaps. LFS pointers and stale timing stay hard.""" + name = check.get("name") + if name == "recording_exists": + return True + if name != "timing_sync": + return False + return any("No audio for " in str(d) for d in check.get("details") or []) + + def _is_text_call(node: ast.Call) -> bool: func = node.func if isinstance(func, ast.Name): @@ -362,10 +372,7 @@ def run_pre_push(self) -> None: if not c.get("passed", True): # Only "not generated yet" stays soft. Visual-sync FAILs # used to warn here, which let generate-all finish. - soft_checks = { - "recording_exists", - } - if c.get("name") in soft_checks: + if _is_pre_push_soft(c): print(f"WARN [{r.get('segment')}] {c.get('name')}: {c.get('details')}") else: hard_fail = True diff --git a/tests/test_cli_env.py b/tests/test_cli_env.py index c85f4e1..f8ed206 100644 --- a/tests/test_cli_env.py +++ b/tests/test_cli_env.py @@ -284,6 +284,8 @@ def test_cli_validate_pre_push_missing_recording_is_soft(tmp_path: Path) -> None assert result.exit_code == 0, combined assert "All checks passed" in combined assert "WARN" in combined and "recording_exists" in combined + assert "WARN" in combined and "timing_sync" in combined + assert "FAIL" not in combined assert "AttributeError" not in combined assert "Traceback" not in combined diff --git a/tests/test_validate_timing_sync.py b/tests/test_validate_timing_sync.py index 55861a2..23e26a5 100644 --- a/tests/test_validate_timing_sync.py +++ b/tests/test_validate_timing_sync.py @@ -606,3 +606,9 @@ def test_lfs_pointer_recording_does_not_skip_pass(self, tmp_path) -> None: assert by_name[name]["passed"] is False, name assert any("LFS pointer" in d for d in by_name[name]["details"]) assert not any("skipped" in d.lower() for d in by_name[name]["details"]) + + def test_lfs_pointer_audio_pre_push_is_hard(self, tmp_path) -> None: + cfg = _bundle(tmp_path) + _write_lfs_pointer(cfg.audio_dir / "01-x.mp3") + with pytest.raises(SystemExit): + Validator(cfg).run_pre_push()