Skip to content

Commit 17d7e78

Browse files
jmjavacursoragent
andauthored
Add Manim Text lint checks and retry flow for FREEZE GUARD (#21)
* Add manim text lint and compose retry-manim path Co-authored-by: John Menke <jmjava@gmail.com> * Add VHS tape lint and configurable render timeouts Co-authored-by: John Menke <jmjava@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 57fbe6a commit 17d7e78

13 files changed

Lines changed: 614 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ 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 tape-lint [--tape 02-quickstart.tape]` | Lint tapes for commands likely to hang in VHS |
5859
| `docgen sync-vhs [--segment 01] [--dry-run]` | Rewrite VHS `Sleep` values from `animations/timing.json` |
5960
| `docgen compose [01 02 03] [--ffmpeg-timeout 900]` | Compose segments (audio + video) |
6061
| `docgen validate [--max-drift 2.75] [--pre-push]` | Run all validation checks |
6162
| `docgen concat [--config full-demo]` | Concatenate full demo files |
6263
| `docgen pages [--force]` | Generate index.html, pages.yml, .gitattributes, .gitignore |
63-
| `docgen generate-all [--skip-tts] [--skip-manim] [--skip-vhs]` | Run full pipeline |
64+
| `docgen generate-all [--skip-tts] [--skip-manim] [--skip-vhs] [--retry-manim]` | Run full pipeline (optionally auto-retry Manim after FREEZE GUARD) |
6465
| `docgen rebuild-after-audio` | Recompose + validate + concat |
6566

6667
## Configuration
@@ -80,6 +81,7 @@ vhs:
8081
typing_ms_per_char: 55 # typing estimate used by sync-vhs
8182
max_typing_sec: 3.0 # per block cap for typing estimate
8283
min_sleep_sec: 0.05 # floor for rewritten Sleep values
84+
render_timeout_sec: 120 # per-tape timeout for `docgen vhs`
8385

8486
pipeline:
8587
sync_vhs_after_timestamps: false # opt-in: run sync-vhs automatically in generate-all/rebuild-after-audio
@@ -91,6 +93,28 @@ compose:
9193
9294
If you edit a `.tape` file, run `docgen vhs` before `docgen compose` so compose does not use stale rendered terminal video.
9395

96+
### VHS safety: avoid real long-running commands in tapes
97+
98+
VHS executes commands in a real shell session. For demos, prefer simulated output with `echo`
99+
instead of invoking real services or model inference in the tape itself.
100+
101+
Example:
102+
103+
```tape
104+
Type "echo '$ python -m myapp run --image sample.png'"
105+
Enter
106+
Sleep 1s
107+
Type "echo '[myapp] Loading model... done (2.1s)'"
108+
Enter
109+
```
110+
111+
Helpful checks:
112+
113+
```bash
114+
docgen tape-lint # flag risky commands in all tapes
115+
docgen vhs --strict # fail if VHS output includes shell/runtime errors
116+
```
117+
94118
To auto-align tape pacing with generated narration:
95119

96120
```bash
@@ -100,6 +124,12 @@ docgen sync-vhs
100124
docgen vhs
101125
docgen compose
102126
```
127+
128+
If `compose` fails with `FREEZE GUARD` after fresh timestamps, retry Manim once automatically:
129+
130+
```bash
131+
docgen generate-all --retry-manim
132+
```
103133
## System dependencies
104134

105135
- **ffmpeg** — composition and probing

‎src/docgen/cli.py‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,21 +107,61 @@ def manim(ctx: click.Context, scene: str | None) -> None:
107107
@main.command()
108108
@click.option("--tape", default=None, help="Render a single VHS tape.")
109109
@click.option("--strict", is_flag=True, help="Fail on any unexpected stderr output.")
110+
@click.option(
111+
"--timeout",
112+
"render_timeout_sec",
113+
default=None,
114+
type=int,
115+
help="Override VHS per-tape timeout seconds (default from docgen.yaml vhs.render_timeout_sec).",
116+
)
110117
@click.pass_context
111-
def vhs(ctx: click.Context, tape: str | None, strict: bool) -> None:
118+
def vhs(
119+
ctx: click.Context,
120+
tape: str | None,
121+
strict: bool,
122+
render_timeout_sec: int | None,
123+
) -> None:
112124
"""Render VHS terminal recordings."""
113125
from docgen.vhs import VHSRunner
114126

115127
cfg = ctx.obj["config"]
116128
runner = VHSRunner(cfg)
117-
results = runner.render(tape=tape, strict=strict)
129+
results = runner.render(tape=tape, strict=strict, timeout_sec=render_timeout_sec)
118130
for r in results:
119131
status = "ok" if r.success else "FAIL"
120132
click.echo(f" [{status}] {r.tape}")
121133
for e in r.errors:
122134
click.echo(f" {e}")
123135

124136

137+
@main.command("tape-lint")
138+
@click.option("--tape", default=None, help="Lint a single tape name or pattern.")
139+
@click.pass_context
140+
def tape_lint(ctx: click.Context, tape: str | None) -> None:
141+
"""Lint VHS tapes for potentially real/hanging commands."""
142+
from docgen.vhs import VHSRunner
143+
144+
cfg = ctx.obj["config"]
145+
runner = VHSRunner(cfg)
146+
reports = runner.lint_tapes(tape=tape)
147+
if not reports:
148+
click.echo("No tape files found.")
149+
return
150+
151+
total_issues = 0
152+
for report in reports:
153+
if report.issues:
154+
click.echo(f"[WARN] {report.tape}")
155+
for issue in report.issues:
156+
click.echo(f" - {issue}")
157+
total_issues += 1
158+
else:
159+
click.echo(f"[ok] {report.tape}")
160+
161+
if total_issues:
162+
raise SystemExit(1)
163+
164+
125165
@main.command("sync-vhs")
126166
@click.option("--segment", default=None, help="Sync tape(s) for one segment ID/name.")
127167
@click.option("--dry-run", is_flag=True, help="Preview updates without writing files.")
@@ -238,13 +278,19 @@ def pages(ctx: click.Context, force: bool) -> None:
238278
@click.option("--skip-manim", is_flag=True)
239279
@click.option("--skip-vhs", is_flag=True)
240280
@click.option("--skip-tape-sync", is_flag=True, help="Skip optional sync-vhs stage after timestamps.")
281+
@click.option(
282+
"--retry-manim",
283+
is_flag=True,
284+
help="If compose hits FREEZE GUARD, clear Manim cache and retry Manim + compose once.",
285+
)
241286
@click.pass_context
242287
def generate_all(
243288
ctx: click.Context,
244289
skip_tts: bool,
245290
skip_manim: bool,
246291
skip_vhs: bool,
247292
skip_tape_sync: bool,
293+
retry_manim: bool,
248294
) -> None:
249295
"""Run full pipeline: TTS -> Manim -> VHS -> compose -> validate -> concat -> pages."""
250296
from docgen.pipeline import Pipeline
@@ -256,6 +302,7 @@ def generate_all(
256302
skip_manim=skip_manim,
257303
skip_vhs=skip_vhs,
258304
skip_tape_sync=skip_tape_sync,
305+
retry_manim_on_freeze=retry_manim,
259306
)
260307

261308

‎src/docgen/compose.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ def _compose_simple(self, seg_id: str, video_path: Path, *, strict: bool = True)
8585
msg = (
8686
f" FREEZE GUARD: {seg_id} visual is {video_dur:.1f}s but audio "
8787
f"is {audio_dur:.1f}s → {freeze:.0%} frozen "
88-
f"(max {max_ratio:.0%}). Re-render the visual source to be longer."
88+
f"(max {max_ratio:.0%}). Re-render the visual source to be longer. "
89+
"If this segment uses timing-driven Manim waits, run `docgen manim` again "
90+
"after `docgen timestamps`, or use `docgen generate-all --retry-manim`."
8991
)
9092
if strict:
9193
raise ComposeError(msg)

‎src/docgen/config.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ def vhs_config(self) -> dict[str, Any]:
102102
"typing_ms_per_char": 35,
103103
"max_typing_sec": 3.0,
104104
"min_sleep_sec": 0.2,
105+
"render_timeout_sec": 120,
105106
}
106107
defaults.update(self.raw.get("vhs", {}))
107108
return defaults
@@ -128,6 +129,10 @@ def max_typing_sec(self) -> float:
128129
def min_sleep_sec(self) -> float:
129130
return float(self.vhs_config.get("min_sleep_sec", 0.2))
130131

132+
@property
133+
def vhs_render_timeout_sec(self) -> int:
134+
return int(self.vhs_config.get("render_timeout_sec", 120))
135+
131136
@property
132137
def sync_vhs_after_timestamps(self) -> bool:
133138
pipeline_cfg = self.raw.get("pipeline", {})

‎src/docgen/init.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ def generate_files(plan: InitPlan) -> list[str]:
206206
if not narr_readme.exists():
207207
created.append(_write_narration_readme(plan))
208208

209+
# terminal/README.md with safe tape authoring guidance
210+
terminal_readme = plan.demo_dir / "terminal" / "README.md"
211+
if not terminal_readme.exists():
212+
created.append(_write_terminal_readme(plan))
213+
209214
# Starter narration files (only for segments without existing files)
210215
for seg in plan.segments:
211216
narr_file = plan.demo_dir / "narration" / f"{seg['name']}.md"
@@ -261,6 +266,7 @@ def _write_config(plan: InitPlan) -> str:
261266
"typing_ms_per_char": 55,
262267
"max_typing_sec": 3.0,
263268
"min_sleep_sec": 0.2,
269+
"render_timeout_sec": 120,
264270
},
265271
"compose": {
266272
"ffmpeg_timeout_sec": 300,
@@ -424,6 +430,37 @@ def _write_narration_readme(plan: InitPlan) -> str:
424430
return str(path)
425431

426432

433+
def _write_terminal_readme(plan: InitPlan) -> str:
434+
content = textwrap.dedent("""\
435+
# Terminal tape authoring (VHS)
436+
437+
`.tape` files run in a real shell. Avoid real long-running commands in demos.
438+
439+
## Safe pattern: simulate output with `echo`
440+
441+
Prefer:
442+
443+
```tape
444+
Type "echo '$ python app.py --serve'"
445+
Enter
446+
Type "echo 'Starting server on :8080'"
447+
Enter
448+
```
449+
450+
Avoid in tapes unless you really want to execute them:
451+
- `python ...`
452+
- `curl localhost ...`
453+
- `npm start`, `docker ...`, `kubectl ...`
454+
455+
Useful checks:
456+
- `docgen tape-lint` (warn on risky command patterns)
457+
- `docgen vhs --strict` (fails on common shell error output)
458+
""")
459+
path = plan.demo_dir / "terminal" / "README.md"
460+
path.write_text(content, encoding="utf-8")
461+
return str(path)
462+
463+
427464
def _install_pre_push_hook(plan: InitPlan) -> str | None:
428465
git_root = detect_git_root(plan.demo_dir)
429466
if not git_root:

‎src/docgen/pipeline.py‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import shutil
56
from typing import TYPE_CHECKING
67

78
if TYPE_CHECKING:
@@ -18,6 +19,7 @@ def run(
1819
skip_manim: bool = False,
1920
skip_vhs: bool = False,
2021
skip_tape_sync: bool = False,
22+
retry_manim_on_freeze: bool = False,
2123
) -> None:
2224
if not skip_tts:
2325
print("\n=== Stage: TTS ===")
@@ -47,8 +49,21 @@ def run(
4749
print(f" WARNING: {r.tape} had errors: {r.errors}")
4850

4951
print("\n=== Stage: Compose ===")
50-
from docgen.compose import Composer
51-
Composer(self.config).compose_segments(self.config.segments_all)
52+
from docgen.compose import ComposeError, Composer
53+
composer = Composer(self.config)
54+
try:
55+
composer.compose_segments(self.config.segments_all)
56+
except ComposeError as exc:
57+
if self._should_retry_manim(exc, skip_manim, retry_manim_on_freeze):
58+
print("\n=== Compose FREEZE GUARD detected; retrying Manim + compose once ===")
59+
self._clear_manim_media_cache()
60+
print("\n=== Stage: Manim (retry) ===")
61+
from docgen.manim_runner import ManimRunner
62+
ManimRunner(self.config).render()
63+
print("\n=== Stage: Compose (retry) ===")
64+
composer.compose_segments(self.config.segments_all)
65+
else:
66+
raise
5267

5368
print("\n=== Stage: Validate ===")
5469
from docgen.validate import Validator
@@ -65,3 +80,19 @@ def run(
6580
PagesGenerator(self.config).generate_all(force=True)
6681

6782
print("\n=== Pipeline complete ===")
83+
84+
@staticmethod
85+
def _should_retry_manim(
86+
exc: Exception, skip_manim: bool, retry_manim_on_freeze: bool
87+
) -> bool:
88+
if skip_manim or not retry_manim_on_freeze:
89+
return False
90+
return "FREEZE GUARD" in str(exc).upper()
91+
92+
def _clear_manim_media_cache(self) -> None:
93+
media_dir = self.config.animations_dir / "media"
94+
if not media_dir.exists():
95+
print("[pipeline] Manim cache already empty")
96+
return
97+
shutil.rmtree(media_dir)
98+
print(f"[pipeline] Cleared Manim cache: {media_dir}")

0 commit comments

Comments
 (0)