Skip to content

Commit babc4bd

Browse files
jmjavacursoragent
andauthored
fix: pipeline correctness for quality, stale-vhs, timeout, and binary paths (#18)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 23f39b6 commit babc4bd

12 files changed

Lines changed: 489 additions & 25 deletions

File tree

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ docgen validate --pre-push # validate all outputs before committing
5555
| `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio |
5656
| `docgen manim [--scene StackDAGScene]` | Render Manim animations |
5757
| `docgen vhs [--tape 02-quickstart.tape] [--strict]` | Render VHS terminal recordings |
58-
| `docgen compose [01 02 03]` | Compose segments (audio + video) |
58+
| `docgen compose [01 02 03] [--ffmpeg-timeout 900]` | Compose segments (audio + video) |
5959
| `docgen validate [--max-drift 2.75] [--pre-push]` | Run all validation checks |
6060
| `docgen concat [--config full-demo]` | Concatenate full demo files |
6161
| `docgen pages [--force]` | Generate index.html, pages.yml, .gitattributes, .gitignore |
@@ -66,6 +66,23 @@ docgen validate --pre-push # validate all outputs before committing
6666

6767
Create a `docgen.yaml` in your demos directory. See [examples/minimal-bundle/docgen.yaml](examples/minimal-bundle/docgen.yaml) for a starting point.
6868

69+
Useful pipeline options:
70+
71+
```yaml
72+
manim:
73+
quality: 1080p30 # supports 480p15, 720p30, 1080p30, 1080p60, 1440p30, 1440p60, 2160p60
74+
manim_path: "" # optional explicit binary path (relative to docgen.yaml or absolute)
75+
76+
vhs:
77+
vhs_path: "" # optional explicit binary path (relative to docgen.yaml or absolute)
78+
79+
compose:
80+
ffmpeg_timeout_sec: 300 # can also be overridden with: docgen compose --ffmpeg-timeout N
81+
warn_stale_vhs: true # warns if terminal/*.tape is newer than terminal/rendered/*.mp4
82+
```
83+
84+
If you edit a `.tape` file, run `docgen vhs` before `docgen compose` so compose does not use stale rendered terminal video.
85+
6986
## System dependencies
7087

7188
- **ffmpeg** — composition and probing

docs/demos/docgen.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,18 @@ visual_map:
6565

6666
manim:
6767
quality: 720p30
68+
manim_path: ""
6869
scenes:
6970
- DocgenOverviewScene
7071
- WizardGUIScene
7172

73+
vhs:
74+
vhs_path: ""
75+
76+
compose:
77+
ffmpeg_timeout_sec: 300
78+
warn_stale_vhs: true
79+
7280
tts:
7381
model: gpt-4o-mini-tts
7482
voice: coral

src/docgen/binaries.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Helpers for resolving external binary paths."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import sys
8+
from dataclasses import dataclass, field
9+
from pathlib import Path
10+
11+
12+
@dataclass
13+
class BinaryResolution:
14+
name: str
15+
path: str | None
16+
tried: list[str] = field(default_factory=list)
17+
18+
19+
def resolve_binary(
20+
name: str,
21+
*,
22+
configured_path: str | None = None,
23+
extra_candidates: list[str | Path] | None = None,
24+
) -> BinaryResolution:
25+
"""Resolve an executable path using config overrides + sensible defaults."""
26+
candidates: list[str] = []
27+
28+
def _add(candidate: str | Path | None) -> None:
29+
if not candidate:
30+
return
31+
p = str(candidate).strip()
32+
if not p:
33+
return
34+
p = os.path.expanduser(os.path.expandvars(p))
35+
if p not in candidates:
36+
candidates.append(p)
37+
38+
_add(configured_path)
39+
40+
# If docgen is running from a virtualenv, prefer binaries next to this python.
41+
_add(Path(sys.executable).resolve().parent / name)
42+
43+
for candidate in extra_candidates or []:
44+
_add(candidate)
45+
46+
which_hit = shutil.which(name)
47+
if which_hit:
48+
_add(which_hit)
49+
50+
tried: list[str] = []
51+
for candidate in candidates:
52+
tried.append(candidate)
53+
cpath = Path(candidate)
54+
if cpath.exists() and os.access(cpath, os.X_OK):
55+
return BinaryResolution(name=name, path=str(cpath), tried=tried)
56+
57+
return BinaryResolution(name=name, path=None, tried=tried)

src/docgen/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,22 @@ def vhs(ctx: click.Context, tape: str | None, strict: bool) -> None:
124124

125125
@main.command()
126126
@click.argument("segments", nargs=-1)
127+
@click.option(
128+
"--ffmpeg-timeout",
129+
default=None,
130+
type=int,
131+
help="Override ffmpeg timeout in seconds (default from docgen.yaml compose.ffmpeg_timeout_sec).",
132+
)
127133
@click.pass_context
128-
def compose(ctx: click.Context, segments: tuple[str, ...]) -> None:
134+
def compose(ctx: click.Context, segments: tuple[str, ...], ffmpeg_timeout: int | None) -> None:
129135
"""Compose segments (audio + video via ffmpeg).
130136
131137
Pass segment IDs to compose specific ones, or omit for the default set.
132138
"""
133139
from docgen.compose import Composer
134140

135141
cfg = ctx.obj["config"]
136-
comp = Composer(cfg)
142+
comp = Composer(cfg, ffmpeg_timeout_sec=ffmpeg_timeout)
137143
target = list(segments) if segments else cfg.segments_default
138144
click.echo(f"=== Composing {len(target)} segments ===")
139145
comp.compose_segments(target)

src/docgen/compose.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ class ComposeError(RuntimeError):
1515

1616

1717
class Composer:
18-
def __init__(self, config: Config) -> None:
18+
def __init__(self, config: Config, ffmpeg_timeout_sec: int | None = None) -> None:
1919
self.config = config
20+
self.ffmpeg_timeout_sec = (
21+
int(ffmpeg_timeout_sec)
22+
if ffmpeg_timeout_sec is not None
23+
else int(self.config.ffmpeg_timeout_sec)
24+
)
2025

2126
def compose_segments(self, segment_ids: list[str], *, strict: bool = True) -> int:
2227
composed = 0
@@ -30,7 +35,9 @@ def compose_segments(self, segment_ids: list[str], *, strict: bool = True) -> in
3035
if vtype == "manim":
3136
ok = self._compose_simple(seg_id, self._manim_path(vmap), strict=strict)
3237
elif vtype == "vhs":
33-
ok = self._compose_simple(seg_id, self._vhs_path(vmap), strict=strict)
38+
video_path = self._vhs_path(vmap)
39+
self._warn_if_stale_vhs(vmap, video_path)
40+
ok = self._compose_simple(seg_id, video_path, strict=strict)
3441
elif vtype == "mixed":
3542
sources = [self._resolve_source(s) for s in vmap.get("sources", [])]
3643
ok = self._compose_mixed(seg_id, sources)
@@ -223,16 +230,24 @@ def _find_audio(self, seg_id: str) -> Path | None:
223230

224231
def _manim_path(self, vmap: dict[str, Any]) -> Path:
225232
src = vmap.get("source", "")
226-
return self.config.animations_dir / "media" / "videos" / "scenes" / "720p30" / src
233+
if not src:
234+
return self.config.animations_dir / "media" / "videos" / "scenes" / "720p30"
235+
236+
for base in self._manim_video_dirs():
237+
candidate = base / src
238+
if candidate.exists():
239+
return candidate
240+
return self._manim_video_dirs()[0] / src
227241

228242
def _vhs_path(self, vmap: dict[str, Any]) -> Path:
229243
src = vmap.get("source", "")
230244
return self.config.terminal_dir / "rendered" / src
231245

232246
def _resolve_source(self, source: str) -> Path:
233-
manim_path = self.config.animations_dir / "media" / "videos" / "scenes" / "720p30" / source
234-
if manim_path.exists():
235-
return manim_path
247+
for base in self._manim_video_dirs():
248+
manim_path = base / source
249+
if manim_path.exists():
250+
return manim_path
236251
vhs_path = self.config.terminal_dir / "rendered" / source
237252
if vhs_path.exists():
238253
return vhs_path
@@ -254,13 +269,69 @@ def _probe_duration(path: Path) -> float | None:
254269
except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
255270
return None
256271

257-
@staticmethod
258-
def _run_ffmpeg(cmd: list[str]) -> None:
272+
def _run_ffmpeg(self, cmd: list[str]) -> None:
273+
timeout_sec = max(1, int(self.ffmpeg_timeout_sec))
274+
out_path = Path(cmd[-1]) if cmd else None
259275
try:
260-
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=300)
276+
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout_sec)
261277
except FileNotFoundError:
262-
print(" ERROR: ffmpeg not found in PATH")
278+
raise ComposeError("ffmpeg not found in PATH")
263279
except subprocess.CalledProcessError as exc:
264-
print(f" ERROR: ffmpeg failed: {exc.stderr[:300]}")
280+
detail = (exc.stderr or exc.stdout or "")[:400]
281+
raise ComposeError(f"ffmpeg failed: {detail}")
265282
except subprocess.TimeoutExpired:
266-
print(" ERROR: ffmpeg timed out")
283+
if out_path and out_path.exists() and out_path.stat().st_size > 0:
284+
print(
285+
f" WARNING: ffmpeg timed out after {timeout_sec}s, "
286+
f"but output exists at {out_path}."
287+
)
288+
return
289+
raise ComposeError(f"ffmpeg timed out after {timeout_sec}s")
290+
291+
def _manim_video_dirs(self) -> list[Path]:
292+
root = self.config.animations_dir / "media" / "videos"
293+
quality = str(self.config.manim_quality).strip().lower()
294+
fallback_qualities = [
295+
quality,
296+
"1080p30",
297+
"1080p60",
298+
"1440p30",
299+
"1440p60",
300+
"720p30",
301+
"480p15",
302+
"2160p60",
303+
]
304+
305+
ordered_qualities: list[str] = []
306+
for q in fallback_qualities:
307+
if q and q not in ordered_qualities:
308+
ordered_qualities.append(q)
309+
310+
dirs: list[Path] = []
311+
for q in ordered_qualities:
312+
dirs.append(root / "scenes" / q)
313+
dirs.append(root / q)
314+
return dirs
315+
316+
def _warn_if_stale_vhs(self, vmap: dict[str, Any], video_path: Path) -> None:
317+
if not self.config.warn_stale_vhs:
318+
return
319+
320+
tape_name = str(vmap.get("tape", "")).strip()
321+
if not tape_name:
322+
source_name = str(vmap.get("source", "")).strip()
323+
if source_name:
324+
tape_name = f"{Path(source_name).stem}.tape"
325+
if not tape_name:
326+
return
327+
328+
tape_path = self.config.terminal_dir / tape_name
329+
if not tape_path.exists() or not video_path.exists():
330+
return
331+
332+
if tape_path.stat().st_mtime > (video_path.stat().st_mtime + 1):
333+
print(
334+
" WARNING: tape is newer than rendered video "
335+
f"({tape_path.name} > {video_path.name}). "
336+
"Run `docgen vhs` before `docgen compose` to avoid stale output."
337+
)

src/docgen/config.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,38 @@ def manim_scenes(self) -> list[str]:
8888
def manim_quality(self) -> str:
8989
return self.raw.get("manim", {}).get("quality", "720p30")
9090

91+
@property
92+
def manim_path(self) -> str | None:
93+
"""Optional absolute/relative path to the Manim executable."""
94+
value = self.raw.get("manim", {}).get("manim_path")
95+
return str(value) if value else None
96+
97+
@property
98+
def vhs_path(self) -> str | None:
99+
"""Optional absolute/relative path to the VHS executable."""
100+
value = self.raw.get("vhs", {}).get("vhs_path")
101+
return str(value) if value else None
102+
103+
# -- Compose ----------------------------------------------------------------
104+
105+
@property
106+
def compose_config(self) -> dict[str, Any]:
107+
defaults: dict[str, Any] = {
108+
"ffmpeg_timeout_sec": 300,
109+
"warn_stale_vhs": True,
110+
}
111+
defaults.update(self.raw.get("compose", {}))
112+
return defaults
113+
114+
@property
115+
def ffmpeg_timeout_sec(self) -> int:
116+
value = self.compose_config.get("ffmpeg_timeout_sec", 300)
117+
return int(value)
118+
119+
@property
120+
def warn_stale_vhs(self) -> bool:
121+
return bool(self.compose_config.get("warn_stale_vhs", True))
122+
91123
# -- Validation ------------------------------------------------------------
92124

93125
@property

src/docgen/init.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,14 @@ def _write_config(plan: InitPlan) -> str:
253253
"manim": {
254254
"quality": "720p30",
255255
"scenes": [f"Scene{s['id']}" for s in plan.segments],
256+
"manim_path": "",
257+
},
258+
"vhs": {
259+
"vhs_path": "",
260+
},
261+
"compose": {
262+
"ffmpeg_timeout_sec": 300,
263+
"warn_stale_vhs": True,
256264
},
257265
"tts": {
258266
"model": plan.tts_model,

0 commit comments

Comments
 (0)