diff --git a/AGENTS.md b/AGENTS.md index aabf2dc..f2cf21a 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. +- **`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). - **`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. diff --git a/README.md b/README.md index 7662044..28899ce 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,11 @@ If you still need the legacy behaviour, pin a pre-removal commit `gpt-4o-mini-tts`, or xAI `/v1/tts` when `ai.provider` is `grok`. - **Word-level timestamps without Whisper** — the default `local` engine aligns the known narration text against the TTS mp3 offline (ffmpeg `silencedetect` - + proportional interpolation); no API call or transcription. Network - transcription (`timestamps.engine: whisper`) uses OpenAI `whisper-1` or xAI - `/v1/stt` when the provider is Grok. Both engines write the same - `timing.json` shape. + + proportional interpolation); no API call or transcription. Failed ffmpeg + `silencedetect` raises `AlignmentError` (empty stderr is not treated as + full-span speech). Network transcription (`timestamps.engine: whisper`) uses + OpenAI `whisper-1` or xAI `/v1/stt` when the provider is Grok. Both engines + write the same `timing.json` shape. - **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 diff --git a/src/docgen/align.py b/src/docgen/align.py index 507e973..3f06073 100644 --- a/src/docgen/align.py +++ b/src/docgen/align.py @@ -42,6 +42,19 @@ class AlignmentError(RuntimeError): """Raised when local alignment cannot run (missing ffmpeg, unreadable audio).""" +def _raise_if_nonzero( + proc: subprocess.CompletedProcess[str], + audio_path: Path, + tool: str, +) -> None: + """Fail closed on a non-zero ffmpeg/ffprobe exit, even when stderr is empty.""" + if proc.returncode == 0: + return + detail = (proc.stderr or proc.stdout or "").strip()[:200] + extra = f": {detail}" if detail else "" + raise AlignmentError(f"{tool} failed on {audio_path} (exit {proc.returncode}){extra}") + + def split_sentences(text: str) -> list[str]: """Split narration plain text into spoken sentences (paragraphs then punctuation).""" out: list[str] = [] @@ -98,12 +111,7 @@ def probe_duration(audio_path: Path) -> float: raise AlignmentError("ffprobe not found in PATH (required for local timing)") from exc except subprocess.TimeoutExpired as exc: raise AlignmentError(f"cannot probe duration of {audio_path}: {exc}") from exc - if out.returncode != 0: - detail = (out.stderr or out.stdout or "").strip()[:200] - extra = f": {detail}" if detail else "" - raise AlignmentError( - f"ffprobe failed on {audio_path} (exit {out.returncode}){extra}" - ) + _raise_if_nonzero(out, audio_path, "ffprobe") try: return float(out.stdout.strip()) except ValueError as exc: @@ -130,6 +138,7 @@ def detect_speech_intervals( raise AlignmentError("ffmpeg not found in PATH (required for local timing)") from exc except subprocess.TimeoutExpired as exc: raise AlignmentError(f"ffmpeg silencedetect timed out on {audio_path}") from exc + _raise_if_nonzero(proc, audio_path, "ffmpeg silencedetect") return parse_silencedetect_output(proc.stderr or "", duration) diff --git a/tests/test_align.py b/tests/test_align.py index f52f979..d355359 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -145,3 +145,34 @@ class _Proc: monkeypatch.setattr("docgen.align.subprocess.run", lambda *_a, **_k: _Proc()) with pytest.raises(AlignmentError, match="ffprobe failed"): probe_duration(Path("/tmp/x.mp3")) + + +def test_detect_speech_intervals_rejects_nonzero_ffmpeg_exit(monkeypatch) -> None: + from pathlib import Path + + from docgen.align import AlignmentError, detect_speech_intervals + + class _Proc: + returncode = 1 + stdout = "" + stderr = "" + + monkeypatch.setattr("docgen.align.subprocess.run", lambda *_a, **_k: _Proc()) + with pytest.raises(AlignmentError, match="ffmpeg silencedetect failed"): + detect_speech_intervals(Path("/tmp/x.mp3"), 10.0) + + +def test_detect_speech_intervals_empty_stderr_is_full_span_only_on_success( + monkeypatch, +) -> None: + from pathlib import Path + + from docgen.align import detect_speech_intervals + + class _Proc: + returncode = 0 + stdout = "" + stderr = "" + + monkeypatch.setattr("docgen.align.subprocess.run", lambda *_a, **_k: _Proc()) + assert detect_speech_intervals(Path("/tmp/x.mp3"), 10.0) == [(0.0, 10.0)]