diff --git a/AGENTS.md b/AGENTS.md index b6b19e4..9b50c08 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`**, **`av_sync`** (soft), **`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), **`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. - **`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 83a5bbb..d5c374a 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ If you still need the legacy behaviour, pin a pre-removal commit segments, with a freeze-tail guard. - **Validation** — A/V drift, freeze ratio, OCR error scan, layout, narration lint, Manim scene lint, **timing_sync** (stale `timing.json` vs regenerated mp3 — - hard fail), and **av_sync** (OCR check that spoken anchor keywords appear on + hard fail), **story_end** (paced visual story finishes long before narration — + hard fail), and **av_sync** (OCR check that scene-spec label anchors appear on screen near their spoken time — soft warning). - **GitHub Pages** — auto-generate `index.html`, deploy workflow, LFS rules, `.gitignore`. @@ -180,9 +181,14 @@ validation: enabled: true max_tail_gap_sec: 3.0 # mp3 may run this much past the last transcribed word max_end_overrun_sec: 1.0 # transcript may extend this far past the mp3 + story_end: # last paced reveal vs audio end (hard fail in --pre-push) + enabled: true + max_early_sec: 40.0 # idle after last paced box + max_early_ratio: 0.45 # and idle / audio_end (both must exceed to fail) av_sync: # OCR anchor check (soft warning in --pre-push) enabled: true tolerance_sec: 3.0 + prefer_scene_spec_labels: true # OCR anchors from paced box labels when specs exist visual_types: [manim] # only check types with on-screen text timestamps: diff --git a/src/docgen/av_sync.py b/src/docgen/av_sync.py index 31cd557..fa3849c 100644 --- a/src/docgen/av_sync.py +++ b/src/docgen/av_sync.py @@ -26,6 +26,15 @@ class AVSyncReport: passed: bool = True +def _ocr_keyword_from_label(label: str) -> str | None: + """Pick a distinctive token from a scene-spec label for OCR substring match.""" + tokens = re.findall(r"[A-Za-z][A-Za-z0-9-]{2,}", label or "") + if not tokens: + return None + # Prefer longer tokens (OCR noise is worse on short words). + return max(tokens, key=len) + + class AVSyncValidator: def __init__(self, config: Config) -> None: self.config = config @@ -116,13 +125,23 @@ def _get_anchors(self, seg_id: str, ts_data: dict[str, Any]) -> list[SyncAnchor] for a in configured ] - # Auto-extract: nouns from transcript words that are >5 chars words = ts_data.get("words", []) + if not isinstance(words, list): + words = [] + + if self.sync_cfg.get("prefer_scene_spec_labels", True): + from_spec = self._anchors_from_scene_specs(seg_id, words) + if from_spec: + return from_spec + + # Fallback: nouns from transcript words that are >5 chars seen: set[str] = set() anchors: list[SyncAnchor] = [] - min_anchors = self.sync_cfg.get("min_anchors_per_segment", 2) + min_anchors = int(self.sync_cfg.get("min_anchors_per_segment", 2)) for w in words: + if not isinstance(w, dict): + continue word = re.sub(r"[^a-zA-Z]", "", w.get("word", "")) if len(word) > 5 and word.lower() not in seen: seen.add(word.lower()) @@ -130,4 +149,41 @@ def _get_anchors(self, seg_id: str, ts_data: dict[str, Any]) -> list[SyncAnchor] if len(anchors) >= min_anchors * 2: break - return anchors[:max(min_anchors, 3)] + return anchors[: max(min_anchors, 3)] + + def _anchors_from_scene_specs( + self, seg_id: str, words: list[Any] + ) -> list[SyncAnchor]: + """Build OCR anchors from paced scene-spec labels (on-screen text).""" + if not words: + return [] + + from docgen.scene_retime import list_scene_spec_paths + from docgen.scene_spec import iter_paced_label_anchors, load_scene_spec + + paths = list_scene_spec_paths(self.config, segment_id=seg_id) + if not paths: + return [] + + min_anchors = int(self.sync_cfg.get("min_anchors_per_segment", 2)) + max_anchors = int(self.sync_cfg.get("max_anchors_per_segment", 8)) + max_anchors = max(min_anchors, max_anchors) + + seen: set[str] = set() + anchors: list[SyncAnchor] = [] + word_dicts = [w for w in words if isinstance(w, dict)] + + for path in paths: + try: + spec = load_scene_spec(path) + except Exception: + continue + for label, spoken_at in iter_paced_label_anchors(spec, word_dicts): + keyword = _ocr_keyword_from_label(label) + if not keyword or keyword.lower() in seen: + continue + seen.add(keyword.lower()) + anchors.append(SyncAnchor(keyword=keyword, spoken_at=spoken_at)) + if len(anchors) >= max_anchors: + return anchors + return anchors diff --git a/src/docgen/config.py b/src/docgen/config.py index cb4c74c..9b87d81 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -275,6 +275,9 @@ def av_sync_config(self) -> dict[str, Any]: "enabled": True, "tolerance_sec": 3.0, "min_anchors_per_segment": 2, + "max_anchors_per_segment": 8, + # Prefer scene-spec box labels as OCR anchors when specs exist. + "prefer_scene_spec_labels": True, # Only OCR-anchor these visual types (on-screen text expected). "visual_types": ["manim"], } @@ -298,6 +301,23 @@ def timing_sync_config(self) -> dict[str, Any]: defaults.update(self.raw.get("validation", {}).get("timing_sync", {})) return defaults + @property + def story_end_config(self) -> dict[str, Any]: + """Visual story finished early vs narration (``docgen validate`` ``story_end``). + + Compares the last paced scene-spec reveal (label→``wait_word`` start) to + the audio/transcript end. Fails when idle time after the last reveal + exceeds **both** ``max_early_sec`` and ``max_early_ratio`` × audio end + (hard fail in ``--pre-push``, like ``timing_sync``). + """ + defaults: dict[str, Any] = { + "enabled": True, + "max_early_sec": 40.0, + "max_early_ratio": 0.45, + } + defaults.update(self.raw.get("validation", {}).get("story_end", {})) + return defaults + @property def narration_lint_config(self) -> dict[str, Any]: defaults: dict[str, Any] = { diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index e0ba262..d965529 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -593,6 +593,65 @@ def pacing_violations(spec: dict[str, Any], *, words_present: bool) -> list[str] return issues +def iter_paced_label_anchors( + spec: dict[str, Any], + words: list[dict[str, Any]], +) -> list[tuple[str, float]]: + """Return ``(label, spoken_start)`` for paced boxes after fail-closed sync. + + Used by validate ``story_end`` and ``av_sync`` OCR anchoring. + """ + if not isinstance(words, list) or not words: + return [] + synced = sync_row_labels_to_whisper_words(spec, words, overwrite=True) + out: list[tuple[str, float]] = [] + for rows in _spec_pages_rows(synced): + for row in rows: + if not isinstance(row, dict) or _pace_none(row): + continue + boxes = row.get("boxes") + if not isinstance(boxes, list): + continue + for box in boxes: + if not isinstance(box, dict) or _pace_none(box): + continue + label = str(box.get("label", "")).strip() + ww = box.get("wait_word") + if not label or ww is None: + continue + try: + wi = int(ww) + except (TypeError, ValueError): + continue + if wi < 0 or wi >= len(words): + continue + w = words[wi] + if not isinstance(w, dict): + continue + try: + t = float(w.get("start", 0.0)) + except (TypeError, ValueError): + continue + out.append((label, t)) + return out + + +def last_paced_reveal_time( + spec: dict[str, Any], + words: list[dict[str, Any]], +) -> float | None: + """Wall-clock ``start`` of the latest paced box reveal, or ``None`` if none. + + Re-derives ``wait_word`` from labels (fail-closed sync) then takes the maximum + word ``start`` among boxes that are not ``pace: none``. Used by validate + ``story_end`` to detect boards that finish long before the narration ends. + """ + anchors = iter_paced_label_anchors(spec, words) + if not anchors: + return None + return max(t for _, t in anchors) + + def upgrade_wait_segments_to_wait_words( spec: dict[str, Any], words: list[dict[str, Any]], diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index 7970a45..7a6ffd7 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -241,17 +241,27 @@ def build_scene_spec_user_message( parts.append("") parts.append("--- FRAME / LAYOUT BUDGET (plan every page; scene-spec-generate rejects overflow) ---") + horiz_safe = FRAME_WIDTH - 1.0 + budget_default = layout_stack_budget( + {"font_size": 36}, {"first_row_title_buff": 0.5} + ) + budget_compact = layout_stack_budget( + {"font_size": 32}, {"first_row_title_buff": 0.45} + ) parts.append( - f"Dogfood Manim frame ≈ {FRAME_WIDTH} × {FRAME_HEIGHT} Manim units. " - "Per page vertical cost = sum over rows of max(box height in row) + (n_rows - 1) * row_gap. " - "That total must stay at or below the budget implied by your title.font_size and layout.first_row_title_buff. " - "Per row horizontal cost = sum(box widths) + (n_boxes - 1) * column_gap; keep ≤ ~13." + f"Frame ≈ {FRAME_WIDTH:.2f} × {FRAME_HEIGHT:.2f} Manim units. " + f"Horizontal safe width ≈ {horiz_safe:.2f} u " + "(sum of box widths + (n_boxes-1)*column_gap per row must stay ≤ this)." ) parts.append( - f"Reference max stack heights: " - f"title 36 + first_row_title_buff 0.5 → ≈ {layout_stack_budget({'font_size': 36}, {'first_row_title_buff': 0.5}):.2f} u; " - f"title 32 + buff 0.45 → ≈ {layout_stack_budget({'font_size': 32}, {'first_row_title_buff': 0.45}):.2f} u. " - "Recompute if you change those fields." + "**Vertical stack budgets** (use these numbers unless you change " + "title.font_size / layout.first_row_title_buff):\n" + f" • Default font_size=36, first_row_title_buff=0.5 → " + f"max stack height ≈ {budget_default:.2f} u\n" + f" • Compact font_size=32, first_row_title_buff=0.45 → " + f"max stack height ≈ {budget_compact:.2f} u\n" + "Per page: sum(max box height per row) + (n_rows-1)*row_gap ≤ that budget. " + "When you would exceed it, spill to another page (do not shrink/cram)." ) return "\n".join(parts) diff --git a/src/docgen/validate.py b/src/docgen/validate.py index acc9c36..9d45466 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -296,6 +296,7 @@ def validate_segment( report.checks.append(CheckResult("recording_exists", False, [f"No recording for {seg_id}"])) report.checks.append(self._check_timing_sync(seg_id)) + report.checks.append(self._check_story_end(seg_id)) report.checks.append(self._check_narration_lint(seg_id)) if self.config.visual_map.get(seg_id, {}).get("type") == "manim": report.checks.append(self._check_manim_scene_lint()) @@ -739,6 +740,94 @@ def _check_timing_sync(self, seg_id: str) -> CheckResult: ) return CheckResult("timing_sync", True, [detail]) + def _check_story_end(self, seg_id: str) -> CheckResult: + """Fail when the paced visual story finishes long before the narration ends. + + 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`` (not soft like ``av_sync``). + """ + se_cfg = self.config.story_end_config + if not se_cfg.get("enabled", True): + return CheckResult("story_end", True, ["validation.story_end disabled (skipped)"]) + + is_manim = self.config.visual_map.get(seg_id, {}).get("type") == "manim" + if not is_manim: + return CheckResult("story_end", True, ["non-manim (skipped)"]) + + from docgen.scene_retime import list_scene_spec_paths + from docgen.scene_spec import last_paced_reveal_time, load_scene_spec + + 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)"] + ) + + block = self._load_timing_block(seg_id) + if block is None: + return CheckResult( + "story_end", + True, + ["No timing.json entry (skipped) — run `docgen timestamps`"], + ) + words = block.get("words") + if not isinstance(words, list) or not words: + return CheckResult( + "story_end", True, ["No timing words (skipped)"] + ) + + last_reveal: float | None = None + for path in paths: + try: + spec = load_scene_spec(path) + except Exception as exc: + return CheckResult( + "story_end", + False, + [f"Cannot load scene spec {path.name}: {exc}"], + ) + t = last_paced_reveal_time(spec, words) + if t is not None and (last_reveal is None or t > last_reveal): + last_reveal = t + + if last_reveal is None: + return CheckResult( + "story_end", + True, + ["No paced reveals in scene spec (skipped)"], + ) + + audio = self._find_audio(seg_id) + audio_end = self._probe_media_duration(audio) if audio and not _is_lfs_pointer(audio) else None + transcript_end = self._timing_last_end(block) + # Prefer audio duration; fall back to transcript end when probe fails. + end_t = audio_end if audio_end is not None else transcript_end + if end_t is None or end_t <= 0: + return CheckResult("story_end", True, ["Cannot determine audio/transcript end (skipped)"]) + + early_idle = end_t - last_reveal + max_early_sec = float(se_cfg.get("max_early_sec", 40.0)) + max_early_ratio = float(se_cfg.get("max_early_ratio", 0.45)) + early_ratio = early_idle / end_t if end_t > 0 else 0.0 + detail = ( + f"last_paced_reveal={last_reveal:.2f}s audio_end={end_t:.2f}s " + f"early_idle={early_idle:.2f}s ({early_ratio:.0%}) " + f"(max_early_sec={max_early_sec}, max_early_ratio={max_early_ratio})" + ) + if early_idle > max_early_sec and early_ratio > max_early_ratio: + return CheckResult( + "story_end", + False, + [ + detail, + "Visual story finishes long before narration ends — boxes race then freeze. " + "Add paced labels for later narration beats, or run " + "`docgen scene-spec-generate` / `scene-compile --retime` after timestamps.", + ], + ) + return CheckResult("story_end", True, [detail]) + def _check_av_sync(self, seg_id: str, rec: Path) -> CheckResult: """OCR anchor check: spoken keywords should be visible on screen near their spoken time. diff --git a/tests/test_scene_spec.py b/tests/test_scene_spec.py index 4226ae3..4694784 100644 --- a/tests/test_scene_spec.py +++ b/tests/test_scene_spec.py @@ -14,6 +14,8 @@ compile_scene_class, cluster_subject_beats, count_spec_labels, + iter_paced_label_anchors, + last_paced_reveal_time, layout_budget_violations, layout_density_violations, layout_stack_budget, @@ -852,6 +854,78 @@ def test_sync_row_labels_never_keeps_legacy_row_wait_word_for_unmatched() -> Non assert pacing_violations(out, words_present=True) +def test_last_paced_reveal_time_uses_latest_matched_label() -> None: + spec = { + "segment_id": "1", + "class_name": "X", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + { + "run_time": 1.0, + "boxes": [ + { + "label": "Alpha", + "color": "C_GREEN", + "width": 3.0, + "height": 1.0, + "font_size": 18, + }, + { + "label": "Omega", + "color": "C_BLUE", + "width": 3.0, + "height": 1.0, + "font_size": 18, + }, + ], + } + ], + } + words = [ + {"word": "Alpha", "start": 2.0, "end": 2.4}, + {"word": "then", "start": 10.0, "end": 10.2}, + {"word": "Omega", "start": 55.0, "end": 55.5}, + ] + anchors = iter_paced_label_anchors(spec, words) + assert [a[0] for a in anchors] == ["Alpha", "Omega"] + assert last_paced_reveal_time(spec, words) == pytest.approx(55.0) + + +def test_last_paced_reveal_time_ignores_pace_none() -> None: + spec = { + "segment_id": "1", + "class_name": "X", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + { + "run_time": 1.0, + "boxes": [ + { + "label": "Early", + "color": "C_GREEN", + "width": 3.0, + "height": 1.0, + "font_size": 18, + }, + { + "label": "Late", + "color": "C_BLUE", + "width": 3.0, + "height": 1.0, + "font_size": 18, + "pace": "none", + }, + ], + } + ], + } + words = [ + {"word": "Early", "start": 5.0, "end": 5.3}, + {"word": "Late", "start": 90.0, "end": 90.4}, + ] + assert last_paced_reveal_time(spec, words) == pytest.approx(5.0) + + def test_pacing_violations_allow_pace_none_opt_out() -> None: spec = { "segment_id": "1", diff --git a/tests/test_scene_spec_generate.py b/tests/test_scene_spec_generate.py index 4d2eb7b..e5152fa 100644 --- a/tests/test_scene_spec_generate.py +++ b/tests/test_scene_spec_generate.py @@ -9,7 +9,9 @@ from docgen.config import Config from docgen.manim_scene_support import BOOTSTRAP_HEADER, SceneGenerationError +from docgen.scene_spec import layout_stack_budget from docgen.scene_spec_generate import ( + build_scene_spec_user_message, generate_scene_spec, inject_class_block_into_scenes_py, linted_class_block_from_spec, @@ -261,3 +263,27 @@ def fake_llm(**_kwargs: object) -> str: assert result.class_name == "ExtrasScene" assert result.spec["rows"] assert len(result.spec["rows"]) == 2 + + +def test_user_message_includes_computed_layout_stack_budgets() -> None: + budget_default = layout_stack_budget( + {"font_size": 36}, {"first_row_title_buff": 0.5} + ) + budget_compact = layout_stack_budget( + {"font_size": 32}, {"first_row_title_buff": 0.45} + ) + msg = build_scene_spec_user_message( + seg_id="01", + seg_name="01-x", + class_name="XScene", + narration_text="Hello world.", + timing_enrichment="(no timing)", + hints=[], + extra_hints=[], + reference_scenes="", + source_snippets=[], + ) + assert "FRAME / LAYOUT BUDGET" in msg + assert f"{budget_default:.2f}" in msg + assert f"{budget_compact:.2f}" in msg + assert "13.22" in msg # horizontal safe width (FRAME_WIDTH - 1.0) diff --git a/tests/test_validate_timing_sync.py b/tests/test_validate_timing_sync.py index 080e90e..4566350 100644 --- a/tests/test_validate_timing_sync.py +++ b/tests/test_validate_timing_sync.py @@ -132,6 +132,125 @@ def test_timing_sync_is_hard_fail_in_pre_push(self, cfg, monkeypatch) -> None: v.run_pre_push() +def _write_scene_spec(cfg: Config, *, labels: list[str]) -> None: + specs = cfg.animations_dir / "specs" + specs.mkdir(parents=True, exist_ok=True) + boxes = [ + { + "label": lab, + "color": "C_GREEN", + "width": 3.0, + "height": 1.0, + "font_size": 18, + } + for lab in labels + ] + raw = { + "segment_id": "01", + "class_name": "XScene", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [{"run_time": 1.0, "boxes": boxes}], + } + (specs / "01-x.scene.yaml").write_text(yaml.dump(raw), encoding="utf-8") + + +class TestStoryEnd: + def test_story_finishes_early_fails(self, cfg, monkeypatch) -> None: + """Board done at ~10s while audio runs ~100s → story_end hard fail.""" + words = [ + {"word": "Alpha", "start": 2.0, "end": 2.4}, + {"word": "Omega", "start": 10.0, "end": 10.5}, + {"word": "continues", "start": 50.0, "end": 50.4}, + {"word": "narrating", "start": 95.0, "end": 95.5}, + ] + (cfg.animations_dir / "timing.json").write_text( + json.dumps( + { + "01-x": { + "text": "Alpha Omega continues narrating", + "words": words, + "segments": [{"start": 0.0, "end": 96.0, "text": "x"}], + } + } + ), + encoding="utf-8", + ) + _write_scene_spec(cfg, labels=["Alpha", "Omega"]) + _patch_audio_duration(monkeypatch, 100.0) + check = Validator(cfg)._check_story_end("01") + assert not check.passed, check.details + assert any("early" in d.lower() or "finishes" in d.lower() for d in check.details) + + def test_story_spans_narration_passes(self, cfg, monkeypatch) -> None: + words = [ + {"word": "Alpha", "start": 2.0, "end": 2.4}, + {"word": "Omega", "start": 80.0, "end": 80.5}, + ] + (cfg.animations_dir / "timing.json").write_text( + json.dumps( + { + "01-x": { + "text": "Alpha Omega", + "words": words, + "segments": [{"start": 0.0, "end": 85.0, "text": "x"}], + } + } + ), + encoding="utf-8", + ) + _write_scene_spec(cfg, labels=["Alpha", "Omega"]) + _patch_audio_duration(monkeypatch, 90.0) + check = Validator(cfg)._check_story_end("01") + assert check.passed, check.details + + def test_story_end_disabled_via_config(self, tmp_path, monkeypatch) -> None: + raw = { + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-x"}, + "visual_map": {"01": {"type": "manim", "class": "XScene"}}, + "validation": {"story_end": {"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") + (cfg.audio_dir / "01-x.mp3").write_bytes(b"fake mp3 bytes") + check = Validator(cfg)._check_story_end("01") + assert check.passed + assert any("disabled" in d for d in check.details) + + def test_story_end_is_hard_fail_in_pre_push(self, cfg, monkeypatch) -> None: + words = [ + {"word": "Alpha", "start": 2.0, "end": 2.4}, + {"word": "Omega", "start": 10.0, "end": 10.5}, + ] + (cfg.animations_dir / "timing.json").write_text( + json.dumps( + { + "01-x": { + "text": "Alpha Omega", + "words": words, + "segments": [{"start": 0.0, "end": 100.0, "text": "x"}], + } + } + ), + encoding="utf-8", + ) + _write_scene_spec(cfg, labels=["Alpha", "Omega"]) + _patch_audio_duration(monkeypatch, 100.0) + # timing_sync would also fail if transcript ends early — keep transcript end close + # to audio so only story_end trips. + block = json.loads((cfg.animations_dir / "timing.json").read_text()) + block["01-x"]["words"].append({"word": "pad", "start": 98.0, "end": 99.0}) + block["01-x"]["segments"] = [{"start": 0.0, "end": 99.0, "text": "x"}] + (cfg.animations_dir / "timing.json").write_text(json.dumps(block), encoding="utf-8") + v = Validator(cfg) + # Avoid unrelated hard fails from missing recording. + monkeypatch.setattr(v, "_find_recording", lambda seg: None) + with pytest.raises(SystemExit): + v.run_pre_push() + + class TestAvSyncCheckWiring: def test_av_sync_skips_for_non_manim_type(self, tmp_path) -> None: cfg = _bundle(tmp_path, visual_type="still") @@ -151,3 +270,22 @@ def test_av_sync_disabled_via_config(self, tmp_path) -> None: check = Validator(cfg)._check_av_sync("01", tmp_path / "rec.mp4") assert check.passed assert any("disabled" in d for d in check.details) + + def test_av_sync_anchors_prefer_scene_spec_labels(self, cfg) -> None: + from docgen.av_sync import AVSyncValidator + + words = [ + {"word": "unrelatedlongword", "start": 1.0, "end": 1.5}, + {"word": "Flask", "start": 5.0, "end": 5.3}, + {"word": "orchestrator", "start": 12.0, "end": 12.6}, + {"word": "anotherlongtoken", "start": 20.0, "end": 20.5}, + ] + (cfg.animations_dir / "timing.json").write_text( + json.dumps({"01-x": {"words": words}}), encoding="utf-8" + ) + _write_scene_spec(cfg, labels=["Flask", "orchestrator"]) + anchors = AVSyncValidator(cfg)._get_anchors("01", {"words": words}) + keys = [a.keyword.lower() for a in anchors] + assert "flask" in keys or "orchestrator" in keys + assert "unrelatedlongword" not in keys + assert "anotherlongtoken" not in keys