diff --git a/AGENTS.md b/AGENTS.md index dde5ed9..d29d34c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Commands registered on the **`docgen`** CLI include: - **`gui`** — desktop window over the same Vue/Flask UI (`pip install 'docgen[gui]'` for pywebview). ``--smoke`` is a headless HTTP check. PyInstaller spec: ``packaging/docgen-gui.spec``. Frozen apps resolve templates/static/benchmark JSON via ``docgen.resources``. - **`freeze`** — ``docgen freeze`` builds the **`docgen-gui`** onedir (`pip install 'docgen[packaging]'`). Optional ``--smoke`` runs the binary headless. Do not run a full freeze in routine pytest; set ``DOCGEN_FREEZE_SMOKE=1`` for the optional test. - **`tts`** — text-to-speech for segment files (OpenAI or xAI `/v1/tts`). -- **`timestamps`** — word/segment timing (`timing.json`). Default engine **`local`** aligns the known narration text against the mp3 offline (ffmpeg silencedetect, no API); **`--engine whisper`** uses OpenAI whisper-1 or xAI `/v1/stt` when `ai.provider` is grok. Both emit the same Whisper-shaped blocks. Failed ffmpeg silencedetect raises `AlignmentError` (empty stderr is not treated as full-span speech). OpenAI whisper-1 word/segment `start`/`end` must be finite JSON numbers (bool/NaN raise `AIError`). Grok `/v1/stt` rejects empty word tokens and inverted `end < start` intervals (`AIError`). +- **`timestamps`** — word/segment timing (`timing.json`). Default engine **`local`** aligns the known narration text against the mp3 offline (ffmpeg silencedetect, no API); **`--engine whisper`** uses OpenAI whisper-1 or xAI `/v1/stt` when `ai.provider` is grok. Both emit the same Whisper-shaped blocks. Failed ffmpeg silencedetect raises `AlignmentError` (empty stderr is not treated as full-span speech). OpenAI whisper-1 word/segment `start`/`end` must be finite JSON numbers (bool/NaN raise `AIError`). Grok `/v1/stt` rejects empty word tokens and inverted `end < start` intervals (`AIError`). Empty ``segments.all`` raises ``TimestampError`` (same as TTS) and does not leave a stale ``timing.json`` as success. - **`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. A ``type: mixed`` row raises ``ComposeError`` if any listed source is missing (no silent subset mux). diff --git a/README.md b/README.md index 7bdaba5..cff5127 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ If you still need the legacy behaviour, pin a pre-removal commit OpenAI `whisper-1` or xAI `/v1/stt` when the provider is Grok. Both engines write the same `timing.json` shape. OpenAI whisper-1 word/segment `start`/`end` must be finite JSON numbers (bool/NaN raise `AIError`). Grok `/v1/stt` rejects - empty word tokens and inverted `end < start` intervals (`AIError`). + empty word tokens and inverted `end < start` intervals (`AIError`). Empty + `segments.all` raises `TimestampError` (same as TTS) and does not leave a + stale `timing.json` as success. - **Manim animations (default: declarative scene specs)** — primary visual surface. Prefer **`animations/specs/*.scene.yaml`** via **`docgen scene-spec-generate`** + **`scene-compile`**. On **`generate-all`**, if no specs exist yet, the pipeline @@ -238,7 +240,7 @@ docgen --repo /path/to/your-project generate-all | `docgen gui [--view benchmark] [--browser] [--smoke]` | Desktop GUI (Vue + Flask). Install `docgen[gui]` for a pywebview window; `--browser` uses the system browser; `--smoke` is a headless HTTP check | | `docgen freeze [--dist DIR] [--smoke]` | PyInstaller onedir for **`docgen-gui` only** (`pip install 'docgen[packaging]'`). Not the full Manim CLI | | `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio | -| `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription) | +| `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription). Empty `segments.all` is `TimestampError` (stale `timing.json` is not success) | | `docgen image-generate [--segment 01 \| --all \| --spec PATH] [--force] [--dry-run] [--model …] [--size …]` | Generate scene-spec image assets (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle | | `docgen manim [--scene StackDAGScene]` | Render Manim animations | | `docgen compose [01 02 03] [--ffmpeg-timeout 900]` | Compose segments (audio + video) | diff --git a/src/docgen/timestamps.py b/src/docgen/timestamps.py index 88f4626..d50dd67 100644 --- a/src/docgen/timestamps.py +++ b/src/docgen/timestamps.py @@ -229,8 +229,9 @@ def extract_all(self, engine: str | None = None) -> None: """Extract timestamps for ``segments.all`` and write timing.json. Walks configured segment ids via :meth:`Config.find_segment_asset` (no - ``*.mp3`` glob). Missing audio for a listed segment is an error. With - no ``segments.all`` entries, existing ``timing.json`` is left unchanged. + ``*.mp3`` glob). Missing audio for a listed segment is an error. An + empty ``segments.all`` raises :class:`TimestampError` (same as TTS) + and does not treat an existing ``timing.json`` as success. Successful runs **merge** stems into the existing file (same as the wizard per-segment timestamps step) so extra keys not in @@ -254,8 +255,10 @@ def extract_all(self, engine: str | None = None) -> None: seg_ids = [str(s) for s in self.config.segments_all] if not seg_ids: - print("[timestamps] segments.all is empty; leaving timing.json unchanged") - return + raise TimestampError( + "segments.all is empty — add segment ids in docgen.yaml " + "(or hints + yaml-generate) before timestamps" + ) missing: list[str] = [] jobs: list[tuple[str, Path]] = [] diff --git a/tests/test_timestamps_local.py b/tests/test_timestamps_local.py index b1388d0..8279d73 100644 --- a/tests/test_timestamps_local.py +++ b/tests/test_timestamps_local.py @@ -107,16 +107,20 @@ def test_no_mp3s_fails_when_segments_listed(self, cfg) -> None: TimestampExtractor(cfg).extract_all() assert json.loads(out.read_text(encoding="utf-8")) == {"keep": True} - def test_empty_segments_all_leaves_existing_timing_json(self, tmp_path) -> None: + def test_empty_segments_all_stale_timing_json_raises(self, tmp_path) -> None: (tmp_path / "docgen.yaml").write_text( yaml.dump({"segments": {"all": []}}), encoding="utf-8" ) cfg = Config.from_yaml(tmp_path / "docgen.yaml") out = cfg.animations_dir / "timing.json" out.parent.mkdir(parents=True, exist_ok=True) - out.write_text('{"keep": true}\n', encoding="utf-8") - TimestampExtractor(cfg).extract_all() - assert json.loads(out.read_text(encoding="utf-8")) == {"keep": True} + stale = {"keep": True, "legacy-stem": {"text": "stale"}} + out.write_text(json.dumps(stale) + "\n", encoding="utf-8") + from docgen.timestamps import TimestampError + + with pytest.raises(TimestampError, match="segments.all is empty"): + TimestampExtractor(cfg).extract_all() + assert json.loads(out.read_text(encoding="utf-8")) == stale def test_heading_only_narration_fails_loud(self, cfg, monkeypatch) -> None: _fake_audio_env(monkeypatch)