Skip to content

Commit 27b20bb

Browse files
authored
Merge pull request #89 from jmjava/cursor/timing-json-parse-2ccd
Fail closed when timing.json is corrupt at compile and validate
2 parents 9f30603 + 76f8402 commit 27b20bb

11 files changed

Lines changed: 189 additions & 67 deletions

‎milestones/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ repositories that install `docgen` and maintain their own demo bundle. The
55
library no longer ships an in-repo dogfood; consumers are the integration test
66
of record.
77

8-
**Active:** **[lint-empty-narration.md](lint-empty-narration.md)** —
9-
lint / validate must fail when narration has no spoken prose after markdown
10-
stripping.
8+
**Active:** **[timing-json-parse.md](timing-json-parse.md)** —
9+
corrupt `timing.json` must not be treated as empty words at compile/validate.
1110

1211
**Shipped:**
12+
- **[lint-empty-narration.md](lint-empty-narration.md)** — lint / validate fail
13+
when narration has no spoken prose after markdown stripping (#88).
1314
- **[segment-id-strings.md](segment-id-strings.md)** — unquoted YAML `01`
1415
must not become integer segment ids (#87).
1516
- **[visual-map-row-types.md](visual-map-row-types.md)** — `visual_map` rows

‎milestones/lint-empty-narration.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Milestone: lint empty narration
22

3-
**Status:** Active
4-
**PR:** pending
3+
**Status:** Shipped
4+
**PR:** #88
55
**Depends on:** `milestones/timestamps-empty-words.md` (PR #85)
66

77
## Problem
@@ -19,9 +19,9 @@ Same contract as TTS: empty spoken text is a lint failure.
1919

2020
- [x] `lint_pre_tts` fails when `markdown_to_tts_plain` is empty.
2121
- [x] Tests: empty, whitespace-only, heading-only, CLI `docgen lint`.
22-
- [ ] `ruff check src/ tests/`
23-
- [ ] `pytest tests/`
24-
- [ ] `docgen benchmark` (no clock change)
22+
- [x] `ruff check src/ tests/`
23+
- [x] `pytest tests/`
24+
- [x] `docgen benchmark` (no clock change)
2525

2626
## Out of scope
2727

‎milestones/timing-json-parse.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Milestone: corrupt `timing.json` must not look like empty words
2+
3+
**Status:** Active
4+
**PR:** pending
5+
**Depends on:** `milestones/wizard-narration-paths.md` (PR #81)
6+
7+
## Problem
8+
9+
Wizard timestamps already refuse to overwrite a corrupt `timing.json`. Compile
10+
and validate still swallowed `JSONDecodeError` and treated the file as missing
11+
words. Paced compile then failed with a “run timestamps” message; `pace: none`
12+
specs compiled as if there were no timing file.
13+
14+
## Goal
15+
16+
One loader: missing file → `{}`; garbage JSON or a non-object root → error.
17+
18+
## Done when
19+
20+
- [x] `load_bundle_timing` in `timestamps.py`
21+
- [x] scene-compile / `linted_class_block_from_spec` raise `SceneGenerationError`
22+
- [x] validate `timing_sync` / `story_end` / `av_sync` fail with the parse error
23+
- [x] `scene_asset_validate` and wizard timestamps reuse the loader
24+
- [x] Tests: corrupt JSON and list-root, including `pace: none`
25+
- [ ] `ruff check src/ tests/`
26+
- [ ] `pytest tests/`
27+
- [ ] `docgen benchmark` (no clock change)
28+
29+
## Out of scope
30+
31+
- Empty `segments.all` still leaves `timing.json` unchanged (#78)
32+
- Missing `timing.json` still means empty words (paced compile already fails)

‎src/docgen/manim_scene_support.py‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -595,13 +595,12 @@ def format_pacing_schedule_markdown(segments: list[dict], pace_indices: list[int
595595

596596
def _load_timing_words_from_cfg(cfg: "Config", seg_name: str) -> list[dict]:
597597
"""Return the ``words`` list from ``animations/timing.json`` for ``seg_name``."""
598-
timing_path = cfg.animations_dir / "timing.json"
599-
if not timing_path.is_file():
600-
return []
598+
from docgen.timestamps import TimestampError, load_bundle_timing
599+
601600
try:
602-
data = json.loads(timing_path.read_text(encoding="utf-8"))
603-
except (OSError, json.JSONDecodeError):
604-
return []
601+
data = load_bundle_timing(cfg)
602+
except TimestampError as exc:
603+
raise SceneGenerationError(str(exc)) from exc
605604
block = data.get(seg_name)
606605
if not isinstance(block, dict):
607606
return []
@@ -1140,10 +1139,12 @@ def sync_audio_tail_waits_in_scenes(cfg: "Config") -> list[str]:
11401139
timing_path = cfg.animations_dir / "timing.json"
11411140
if not scenes_path.is_file() or not timing_path.is_file():
11421141
return []
1142+
from docgen.timestamps import TimestampError, load_bundle_timing
1143+
11431144
try:
1144-
timing = json.loads(timing_path.read_text(encoding="utf-8"))
1145-
except (OSError, json.JSONDecodeError):
1146-
return []
1145+
timing = load_bundle_timing(cfg)
1146+
except TimestampError as exc:
1147+
raise SceneGenerationError(str(exc)) from exc
11471148

11481149
text = scenes_path.read_text(encoding="utf-8")
11491150
changes: list[str] = []
@@ -1350,11 +1351,14 @@ def _load_narration(cfg: "Config", seg_id: str, seg_name: str) -> str:
13501351

13511352

13521353
def _load_timing_segments(cfg: "Config", seg_name: str) -> list[dict]:
1353-
timing_path = cfg.animations_dir / "timing.json"
1354-
if not timing_path.exists():
1355-
return []
1354+
from docgen.timestamps import TimestampError, load_bundle_timing
1355+
13561356
try:
1357-
data = json.loads(timing_path.read_text(encoding="utf-8"))
1358-
except (OSError, ValueError):
1357+
data = load_bundle_timing(cfg)
1358+
except TimestampError as exc:
1359+
raise SceneGenerationError(str(exc)) from exc
1360+
block = data.get(seg_name)
1361+
if not isinstance(block, dict):
13591362
return []
1360-
return list(data.get(seg_name, {}).get("segments", []))
1363+
segs = block.get("segments")
1364+
return list(segs) if isinstance(segs, list) else []

‎src/docgen/scene_asset_validate.py‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -275,22 +275,14 @@ def scene_asset_violations_for_segment(cfg: "Config", seg_id: str) -> list[str]:
275275
return issues
276276

277277
block: dict[str, Any] = {}
278-
timing_path = cfg.animations_dir / "timing.json"
279278
stem = cfg.resolve_segment_name(seg_id)
280-
if timing_path.is_file():
281-
import json
279+
if cfg.animations_dir.joinpath("timing.json").is_file():
280+
from docgen.timestamps import TimestampError, load_bundle_timing
282281

283282
try:
284-
data = json.loads(timing_path.read_text(encoding="utf-8"))
285-
except (OSError, json.JSONDecodeError) as exc:
286-
issues.append(
287-
f"timing.json is not valid JSON ({exc}) — run `docgen timestamps`"
288-
)
289-
data = None
290-
if data is not None and not isinstance(data, dict):
291-
issues.append(
292-
f"timing.json root must be a JSON object, not {type(data).__name__}"
293-
)
283+
data = load_bundle_timing(cfg)
284+
except TimestampError as exc:
285+
issues.append(str(exc))
294286
data = None
295287
raw_block = data.get(stem) if isinstance(data, dict) else None
296288
if isinstance(raw_block, dict):

‎src/docgen/scene_spec_generate.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -323,16 +323,17 @@ def spec_to_yaml_text(spec: dict[str, Any]) -> str:
323323

324324

325325
def _load_timing_words(cfg: Config, timing_key: str) -> list[dict[str, Any]]:
326-
"""Return the ``words`` list from ``animations/timing.json`` for ``timing_key`` (best effort)."""
327-
timing_path = cfg.animations_dir / "timing.json"
328-
if not timing_path.exists():
329-
return []
326+
"""Return the ``words`` list from ``animations/timing.json`` for ``timing_key``."""
327+
from docgen.timestamps import TimestampError, load_bundle_timing
328+
330329
try:
331-
data = json.loads(timing_path.read_text(encoding="utf-8"))
332-
except (OSError, ValueError):
330+
data = load_bundle_timing(cfg)
331+
except TimestampError as exc:
332+
raise SceneGenerationError(str(exc)) from exc
333+
block = data.get(timing_key)
334+
if not isinstance(block, dict):
333335
return []
334-
block = data.get(timing_key) or {}
335-
words = block.get("words") if isinstance(block, dict) else None
336+
words = block.get("words")
336337
return list(words) if isinstance(words, list) else []
337338

338339

‎src/docgen/timestamps.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,32 @@ class TimestampError(RuntimeError):
2828
"""Raised when timestamps cannot be extracted for required segments."""
2929

3030

31+
def load_bundle_timing(config: "Config") -> dict[str, Any]:
32+
"""Load ``animations/timing.json``.
33+
34+
A missing file is ``{}``. Corrupt JSON or a non-object root raises
35+
:class:`TimestampError` so compile/validate cannot treat garbage as empty
36+
``words``.
37+
"""
38+
path = config.animations_dir / "timing.json"
39+
if not path.is_file():
40+
return {}
41+
try:
42+
data = json.loads(path.read_text(encoding="utf-8"))
43+
except json.JSONDecodeError as exc:
44+
raise TimestampError(
45+
f"{path.name} is not valid JSON ({exc}) — fix or delete it before "
46+
"timestamps/compile"
47+
) from exc
48+
except OSError as exc:
49+
raise TimestampError(f"could not read {path}: {exc}") from exc
50+
if not isinstance(data, dict):
51+
raise TimestampError(
52+
f"{path.name} root must be a JSON object, not {type(data).__name__}"
53+
)
54+
return data
55+
56+
3157
class TimestampExtractor:
3258
def __init__(self, config: Config) -> None:
3359
self.config = config

‎src/docgen/validate.py‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,12 @@ def _check_timing_sync(self, seg_id: str) -> CheckResult:
732732
return CheckResult("timing_sync", True, ["Audio is an LFS pointer (skipped)"])
733733

734734
is_manim = self.config.visual_map.get(seg_id, {}).get("type") == "manim"
735-
block = self._load_timing_block(seg_id)
735+
from docgen.timestamps import TimestampError
736+
737+
try:
738+
block = self._load_timing_block(seg_id)
739+
except TimestampError as exc:
740+
return CheckResult("timing_sync", False, [str(exc)])
736741
if block is None:
737742
if is_manim:
738743
return CheckResult(
@@ -811,7 +816,12 @@ def _check_story_end(self, seg_id: str) -> CheckResult:
811816
"story_end", True, ["No animations/specs/*.scene.yaml (skipped)"]
812817
)
813818

814-
block = self._load_timing_block(seg_id)
819+
from docgen.timestamps import TimestampError
820+
821+
try:
822+
block = self._load_timing_block(seg_id)
823+
except TimestampError as exc:
824+
return CheckResult("story_end", False, [str(exc)])
815825
words = block.get("words") if isinstance(block, dict) else None
816826
words_ok = isinstance(words, list) and bool(words)
817827

@@ -905,7 +915,12 @@ def _check_av_sync(self, seg_id: str, rec: Path) -> CheckResult:
905915
except Exception:
906916
return CheckResult("av_sync", True, ["tesseract binary not installed (skipped)"])
907917

908-
block = self._load_timing_block(seg_id)
918+
from docgen.timestamps import TimestampError
919+
920+
try:
921+
block = self._load_timing_block(seg_id)
922+
except TimestampError as exc:
923+
return CheckResult("av_sync", False, [str(exc)])
909924
if block is None:
910925
return CheckResult(
911926
"av_sync", True, ["No timing.json entry (skipped) — run `docgen timestamps`"]
@@ -927,13 +942,9 @@ def _check_av_sync(self, seg_id: str, rec: Path) -> CheckResult:
927942

928943
def _load_timing_block(self, seg_id: str) -> dict[str, Any] | None:
929944
"""One segment's block from ``animations/timing.json`` (keyed by narration stem)."""
930-
timing_path = self.config.animations_dir / "timing.json"
931-
if not timing_path.is_file():
932-
return None
933-
try:
934-
data = json.loads(timing_path.read_text(encoding="utf-8"))
935-
except (OSError, json.JSONDecodeError):
936-
return None
945+
from docgen.timestamps import load_bundle_timing
946+
947+
data = load_bundle_timing(self.config)
937948
stem = self.config.resolve_segment_name(seg_id)
938949
block = data.get(stem)
939950
if not isinstance(block, dict):

‎src/docgen/wizard.py‎

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -861,20 +861,14 @@ def _run_segment_step(cfg: Any, step: str, segment_id: str) -> dict[str, Any]:
861861
block = (
862862
ts.extract(mp3) if engine == "whisper" else ts.extract_local(mp3)
863863
)
864-
out = cfg.animations_dir / "timing.json"
865-
timing: dict = {}
866-
if out.is_file():
867-
try:
868-
timing = _json.loads(out.read_text(encoding="utf-8"))
869-
except _json.JSONDecodeError as exc:
870-
raise RuntimeError(
871-
f"{out.name} is not valid JSON; fix or delete it before timestamps"
872-
) from exc
873-
if not isinstance(timing, dict):
874-
raise RuntimeError(
875-
f"{out.name} root must be a JSON object, not {type(timing).__name__}"
876-
)
864+
from docgen.timestamps import TimestampError, load_bundle_timing
865+
866+
try:
867+
timing = dict(load_bundle_timing(cfg))
868+
except TimestampError as exc:
869+
raise RuntimeError(str(exc)) from exc
877870
timing[mp3.stem] = block
871+
out = cfg.animations_dir / "timing.json"
878872
out.parent.mkdir(parents=True, exist_ok=True)
879873
out.write_text(
880874
_json.dumps(timing, indent=2, ensure_ascii=False) + "\n",

‎tests/test_scene_retime.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,60 @@ def test_linted_class_block_allows_pace_none_without_words(tmp_path: Path) -> No
229229
assert merged["rows"][0]["boxes"][0].get("pace") == "none"
230230

231231

232+
def test_linted_class_block_fails_on_corrupt_timing_json(tmp_path: Path) -> None:
233+
cfg = _cfg(tmp_path)
234+
(tmp_path / "animations" / "timing.json").write_text("{not json", encoding="utf-8")
235+
spec = {
236+
"segment_id": "01",
237+
"class_name": "DemoScene",
238+
"title": {"text": "Demo", "font_size": 36, "color": "C_WHITE"},
239+
"rows": [
240+
{
241+
"run_time": 1.0,
242+
"boxes": [
243+
{
244+
"label": "Hello",
245+
"color": "C_GREEN",
246+
"width": 3.0,
247+
"height": 0.9,
248+
"font_size": 18,
249+
"pace": "none",
250+
}
251+
],
252+
}
253+
],
254+
}
255+
with pytest.raises(SceneGenerationError, match="not valid JSON"):
256+
linted_class_block_from_spec(cfg, spec, timing_key="01-demo")
257+
258+
259+
def test_linted_class_block_fails_on_list_root_timing_json(tmp_path: Path) -> None:
260+
cfg = _cfg(tmp_path)
261+
(tmp_path / "animations" / "timing.json").write_text("[]\n", encoding="utf-8")
262+
spec = {
263+
"segment_id": "01",
264+
"class_name": "DemoScene",
265+
"title": {"text": "Demo", "font_size": 36, "color": "C_WHITE"},
266+
"rows": [
267+
{
268+
"run_time": 1.0,
269+
"boxes": [
270+
{
271+
"label": "Hello",
272+
"color": "C_GREEN",
273+
"width": 3.0,
274+
"height": 0.9,
275+
"font_size": 18,
276+
"pace": "none",
277+
}
278+
],
279+
}
280+
],
281+
}
282+
with pytest.raises(SceneGenerationError, match="JSON object"):
283+
linted_class_block_from_spec(cfg, spec, timing_key="01-demo")
284+
285+
232286
def test_retime_compile_spec_rewrites_scenes_py(tmp_path: Path) -> None:
233287
cfg = _cfg(tmp_path)
234288
path = _write_spec(tmp_path, label="Hello")

0 commit comments

Comments
 (0)