Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,13 @@ docgen validate --pre-push # validate all outputs before committing
| `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio |
| `docgen manim [--scene StackDAGScene]` | Render Manim animations |
| `docgen vhs [--tape 02-quickstart.tape] [--strict]` | Render VHS terminal recordings |
| `docgen tape-lint [--tape 02-quickstart.tape]` | Lint tapes for commands likely to hang in VHS |
| `docgen sync-vhs [--segment 01] [--dry-run]` | Rewrite VHS `Sleep` values from `animations/timing.json` |
| `docgen compose [01 02 03] [--ffmpeg-timeout 900]` | Compose segments (audio + video) |
| `docgen validate [--max-drift 2.75] [--pre-push]` | Run all validation checks |
| `docgen concat [--config full-demo]` | Concatenate full demo files |
| `docgen pages [--force]` | Generate index.html, pages.yml, .gitattributes, .gitignore |
| `docgen generate-all [--skip-tts] [--skip-manim] [--skip-vhs]` | Run full pipeline |
| `docgen generate-all [--skip-tts] [--skip-manim] [--skip-vhs] [--retry-manim]` | Run full pipeline (optionally auto-retry Manim after FREEZE GUARD) |
| `docgen rebuild-after-audio` | Recompose + validate + concat |

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

pipeline:
sync_vhs_after_timestamps: false # opt-in: run sync-vhs automatically in generate-all/rebuild-after-audio
Expand All @@ -91,6 +93,28 @@ compose:

If you edit a `.tape` file, run `docgen vhs` before `docgen compose` so compose does not use stale rendered terminal video.

### VHS safety: avoid real long-running commands in tapes

VHS executes commands in a real shell session. For demos, prefer simulated output with `echo`
instead of invoking real services or model inference in the tape itself.

Example:

```tape
Type "echo '$ python -m myapp run --image sample.png'"
Enter
Sleep 1s
Type "echo '[myapp] Loading model... done (2.1s)'"
Enter
```

Helpful checks:

```bash
docgen tape-lint # flag risky commands in all tapes
docgen vhs --strict # fail if VHS output includes shell/runtime errors
```

To auto-align tape pacing with generated narration:

```bash
Expand All @@ -100,6 +124,12 @@ docgen sync-vhs
docgen vhs
docgen compose
```

If `compose` fails with `FREEZE GUARD` after fresh timestamps, retry Manim once automatically:

```bash
docgen generate-all --retry-manim
```
## System dependencies

- **ffmpeg** — composition and probing
Expand Down
51 changes: 49 additions & 2 deletions src/docgen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,61 @@ def manim(ctx: click.Context, scene: str | None) -> None:
@main.command()
@click.option("--tape", default=None, help="Render a single VHS tape.")
@click.option("--strict", is_flag=True, help="Fail on any unexpected stderr output.")
@click.option(
"--timeout",
"render_timeout_sec",
default=None,
type=int,
help="Override VHS per-tape timeout seconds (default from docgen.yaml vhs.render_timeout_sec).",
)
@click.pass_context
def vhs(ctx: click.Context, tape: str | None, strict: bool) -> None:
def vhs(
ctx: click.Context,
tape: str | None,
strict: bool,
render_timeout_sec: int | None,
) -> None:
"""Render VHS terminal recordings."""
from docgen.vhs import VHSRunner

cfg = ctx.obj["config"]
runner = VHSRunner(cfg)
results = runner.render(tape=tape, strict=strict)
results = runner.render(tape=tape, strict=strict, timeout_sec=render_timeout_sec)
for r in results:
status = "ok" if r.success else "FAIL"
click.echo(f" [{status}] {r.tape}")
for e in r.errors:
click.echo(f" {e}")


@main.command("tape-lint")
@click.option("--tape", default=None, help="Lint a single tape name or pattern.")
@click.pass_context
def tape_lint(ctx: click.Context, tape: str | None) -> None:
"""Lint VHS tapes for potentially real/hanging commands."""
from docgen.vhs import VHSRunner

cfg = ctx.obj["config"]
runner = VHSRunner(cfg)
reports = runner.lint_tapes(tape=tape)
if not reports:
click.echo("No tape files found.")
return

total_issues = 0
for report in reports:
if report.issues:
click.echo(f"[WARN] {report.tape}")
for issue in report.issues:
click.echo(f" - {issue}")
total_issues += 1
else:
click.echo(f"[ok] {report.tape}")

if total_issues:
raise SystemExit(1)


@main.command("sync-vhs")
@click.option("--segment", default=None, help="Sync tape(s) for one segment ID/name.")
@click.option("--dry-run", is_flag=True, help="Preview updates without writing files.")
Expand Down Expand Up @@ -238,13 +278,19 @@ def pages(ctx: click.Context, force: bool) -> None:
@click.option("--skip-manim", is_flag=True)
@click.option("--skip-vhs", is_flag=True)
@click.option("--skip-tape-sync", is_flag=True, help="Skip optional sync-vhs stage after timestamps.")
@click.option(
"--retry-manim",
is_flag=True,
help="If compose hits FREEZE GUARD, clear Manim cache and retry Manim + compose once.",
)
@click.pass_context
def generate_all(
ctx: click.Context,
skip_tts: bool,
skip_manim: bool,
skip_vhs: bool,
skip_tape_sync: bool,
retry_manim: bool,
) -> None:
"""Run full pipeline: TTS -> Manim -> VHS -> compose -> validate -> concat -> pages."""
from docgen.pipeline import Pipeline
Expand All @@ -256,6 +302,7 @@ def generate_all(
skip_manim=skip_manim,
skip_vhs=skip_vhs,
skip_tape_sync=skip_tape_sync,
retry_manim_on_freeze=retry_manim,
)


Expand Down
4 changes: 3 additions & 1 deletion src/docgen/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ def _compose_simple(self, seg_id: str, video_path: Path, *, strict: bool = True)
msg = (
f" FREEZE GUARD: {seg_id} visual is {video_dur:.1f}s but audio "
f"is {audio_dur:.1f}s → {freeze:.0%} frozen "
f"(max {max_ratio:.0%}). Re-render the visual source to be longer."
f"(max {max_ratio:.0%}). Re-render the visual source to be longer. "
"If this segment uses timing-driven Manim waits, run `docgen manim` again "
"after `docgen timestamps`, or use `docgen generate-all --retry-manim`."
)
if strict:
raise ComposeError(msg)
Expand Down
5 changes: 5 additions & 0 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def vhs_config(self) -> dict[str, Any]:
"typing_ms_per_char": 35,
"max_typing_sec": 3.0,
"min_sleep_sec": 0.2,
"render_timeout_sec": 120,
}
defaults.update(self.raw.get("vhs", {}))
return defaults
Expand All @@ -128,6 +129,10 @@ def max_typing_sec(self) -> float:
def min_sleep_sec(self) -> float:
return float(self.vhs_config.get("min_sleep_sec", 0.2))

@property
def vhs_render_timeout_sec(self) -> int:
return int(self.vhs_config.get("render_timeout_sec", 120))

@property
def sync_vhs_after_timestamps(self) -> bool:
pipeline_cfg = self.raw.get("pipeline", {})
Expand Down
37 changes: 37 additions & 0 deletions src/docgen/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ def generate_files(plan: InitPlan) -> list[str]:
if not narr_readme.exists():
created.append(_write_narration_readme(plan))

# terminal/README.md with safe tape authoring guidance
terminal_readme = plan.demo_dir / "terminal" / "README.md"
if not terminal_readme.exists():
created.append(_write_terminal_readme(plan))

# Starter narration files (only for segments without existing files)
for seg in plan.segments:
narr_file = plan.demo_dir / "narration" / f"{seg['name']}.md"
Expand Down Expand Up @@ -261,6 +266,7 @@ def _write_config(plan: InitPlan) -> str:
"typing_ms_per_char": 55,
"max_typing_sec": 3.0,
"min_sleep_sec": 0.2,
"render_timeout_sec": 120,
},
"compose": {
"ffmpeg_timeout_sec": 300,
Expand Down Expand Up @@ -424,6 +430,37 @@ def _write_narration_readme(plan: InitPlan) -> str:
return str(path)


def _write_terminal_readme(plan: InitPlan) -> str:
content = textwrap.dedent("""\
# Terminal tape authoring (VHS)

`.tape` files run in a real shell. Avoid real long-running commands in demos.

## Safe pattern: simulate output with `echo`

Prefer:

```tape
Type "echo '$ python app.py --serve'"
Enter
Type "echo 'Starting server on :8080'"
Enter
```

Avoid in tapes unless you really want to execute them:
- `python ...`
- `curl localhost ...`
- `npm start`, `docker ...`, `kubectl ...`

Useful checks:
- `docgen tape-lint` (warn on risky command patterns)
- `docgen vhs --strict` (fails on common shell error output)
""")
path = plan.demo_dir / "terminal" / "README.md"
path.write_text(content, encoding="utf-8")
return str(path)


def _install_pre_push_hook(plan: InitPlan) -> str | None:
git_root = detect_git_root(plan.demo_dir)
if not git_root:
Expand Down
35 changes: 33 additions & 2 deletions src/docgen/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import shutil
from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand All @@ -18,6 +19,7 @@ def run(
skip_manim: bool = False,
skip_vhs: bool = False,
skip_tape_sync: bool = False,
retry_manim_on_freeze: bool = False,
) -> None:
if not skip_tts:
print("\n=== Stage: TTS ===")
Expand Down Expand Up @@ -47,8 +49,21 @@ def run(
print(f" WARNING: {r.tape} had errors: {r.errors}")

print("\n=== Stage: Compose ===")
from docgen.compose import Composer
Composer(self.config).compose_segments(self.config.segments_all)
from docgen.compose import ComposeError, Composer
composer = Composer(self.config)
try:
composer.compose_segments(self.config.segments_all)
except ComposeError as exc:
if self._should_retry_manim(exc, skip_manim, retry_manim_on_freeze):
print("\n=== Compose FREEZE GUARD detected; retrying Manim + compose once ===")
self._clear_manim_media_cache()
print("\n=== Stage: Manim (retry) ===")
from docgen.manim_runner import ManimRunner
ManimRunner(self.config).render()
print("\n=== Stage: Compose (retry) ===")
composer.compose_segments(self.config.segments_all)
else:
raise

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

print("\n=== Pipeline complete ===")

@staticmethod
def _should_retry_manim(
exc: Exception, skip_manim: bool, retry_manim_on_freeze: bool
) -> bool:
if skip_manim or not retry_manim_on_freeze:
return False
return "FREEZE GUARD" in str(exc).upper()

def _clear_manim_media_cache(self) -> None:
media_dir = self.config.animations_dir / "media"
if not media_dir.exists():
print("[pipeline] Manim cache already empty")
return
shutil.rmtree(media_dir)
print(f"[pipeline] Cleared Manim cache: {media_dir}")
Loading