diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b567c78..8a41b3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,29 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg tesseract-ocr + run: | + set -euo pipefail + attempt=1 + max_attempts=4 + backoff=4 + + while [ "$attempt" -le "$max_attempts" ]; do + echo "apt attempt $attempt/$max_attempts" + if timeout 600s sudo apt-get -o Acquire::Retries=3 update \ + && timeout 600s sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg tesseract-ocr; then + exit 0 + fi + + if [ "$attempt" -eq "$max_attempts" ]; then + echo "apt failed after $max_attempts attempts" + exit 1 + fi + + echo "apt failed, retrying in ${backoff}s..." + sleep "$backoff" + backoff=$((backoff * 2)) + attempt=$((attempt + 1)) + done - run: pip install ".[dev]" - run: pytest tests/ --ignore=tests/e2e -v --tb=short diff --git a/README.md b/README.md index 94a356e..13ff38f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ docgen validate --pre-push # validate all outputs before committing | `docgen wizard [--port 8501]` | Launch narration setup wizard (local web GUI) | | `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 vhs [--tape 02-quickstart.tape] [--strict] [--timeout 120]` | Render VHS terminal recordings | +| `docgen playwright --script scripts/capture.py --url http://localhost:3000 --source demo.mp4` | Capture browser demo video with Playwright script | | `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) | @@ -83,6 +84,14 @@ vhs: min_sleep_sec: 0.05 # floor for rewritten Sleep values render_timeout_sec: 120 # per-tape timeout for `docgen vhs` +playwright: + python_path: "" # optional python executable for capture scripts + timeout_sec: 120 # capture timeout in seconds + default_url: "" # fallback URL when visual_map entry omits url + default_viewport: # fallback viewport when visual_map entry omits viewport + width: 1920 + height: 1080 + pipeline: sync_vhs_after_timestamps: false # opt-in: run sync-vhs automatically in generate-all/rebuild-after-audio @@ -93,6 +102,36 @@ compose: If you edit a `.tape` file, run `docgen vhs` before `docgen compose` so compose does not use stale rendered terminal video. +### Playwright visual source (`type: playwright`) + +`visual_map` entries can now use a Playwright capture script: + +```yaml +visual_map: + "04": + type: playwright + source: 04-browser-flow.mp4 + script: scripts/demo_capture.py + url: http://localhost:3300 + viewport: + width: 1920 + height: 1080 +``` + +During `docgen compose`, docgen runs the capture script first (if `source` does not exist yet), +then muxes the generated MP4 with narration audio. + +Manual capture (useful while iterating on scripts): + +```bash +docgen playwright --script scripts/demo_capture.py --url http://localhost:3300 --source 04-browser-flow.mp4 +``` + +Script contract: +- receives env vars: `DOCGEN_PLAYWRIGHT_OUTPUT`, optional `DOCGEN_PLAYWRIGHT_URL`, + `DOCGEN_PLAYWRIGHT_WIDTH`, `DOCGEN_PLAYWRIGHT_HEIGHT`, and optional segment metadata +- must write an MP4 to the requested output path +- should use headless Playwright for CI compatibility ### VHS safety: avoid real long-running commands in tapes VHS executes commands in a real shell session. For demos, prefer simulated output with `echo` diff --git a/src/docgen/cli.py b/src/docgen/cli.py index 9eb4794..c7af63b 100644 --- a/src/docgen/cli.py +++ b/src/docgen/cli.py @@ -134,6 +134,43 @@ def vhs( click.echo(f" {e}") +@main.command() +@click.option( + "--script", + "script_path", + default=None, + help="Python script to execute for browser actions (required for standalone mode).", +) +@click.option("--url", default=None, help="Target URL for browser capture.") +@click.option("--source", default="playwright-capture.mp4", help="Output filename under terminal/rendered/.") +@click.option("--width", default=1920, type=int, help="Browser viewport width.") +@click.option("--height", default=1080, type=int, help="Browser viewport height.") +@click.option("--timeout", "timeout_sec", default=120, type=int, help="Capture timeout in seconds.") +@click.pass_context +def playwright( + ctx: click.Context, + script_path: str | None, + url: str | None, + source: str, + width: int, + height: int, + timeout_sec: int, +) -> None: + """Capture a browser demo video using Playwright.""" + from docgen.playwright_runner import PlaywrightRunner + + cfg = ctx.obj["config"] + runner = PlaywrightRunner(cfg) + video = runner.capture( + script=script_path, + output=source, + url=url, + viewport={"width": width, "height": height}, + timeout_sec=timeout_sec, + ) + click.echo(f"[playwright] captured: {video}") + + @main.command("tape-lint") @click.option("--tape", default=None, help="Lint a single tape name or pattern.") @click.pass_context diff --git a/src/docgen/compose.py b/src/docgen/compose.py index ba553dc..1c022f7 100644 --- a/src/docgen/compose.py +++ b/src/docgen/compose.py @@ -38,6 +38,15 @@ def compose_segments(self, segment_ids: list[str], *, strict: bool = True) -> in video_path = self._vhs_path(vmap) self._warn_if_stale_vhs(vmap, video_path) ok = self._compose_simple(seg_id, video_path, strict=strict) + elif vtype == "playwright": + from docgen.playwright_runner import PlaywrightError, PlaywrightRunner + + try: + video_path = PlaywrightRunner(self.config).capture_segment(seg_id, vmap) + except PlaywrightError as exc: + print(f" SKIP: playwright capture failed ({exc})") + video_path = Path("") + ok = video_path.exists() and self._compose_simple(seg_id, video_path, strict=strict) elif vtype == "mixed": sources = [self._resolve_source(s) for s in vmap.get("sources", [])] ok = self._compose_mixed(seg_id, sources) @@ -245,6 +254,12 @@ def _vhs_path(self, vmap: dict[str, Any]) -> Path: src = vmap.get("source", "") return self.config.terminal_dir / "rendered" / src + def _playwright_path(self, vmap: dict[str, Any]) -> Path: + src = str(vmap.get("source", "")).strip() + if not src: + return self.config.terminal_dir / "rendered" / "playwright.mp4" + return self.config.terminal_dir / "rendered" / src + def _resolve_source(self, source: str) -> Path: for base in self._manim_video_dirs(): manim_path = base / source diff --git a/src/docgen/config.py b/src/docgen/config.py index e36c614..dc94fcd 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -139,6 +139,40 @@ def sync_vhs_after_timestamps(self) -> bool: if "sync_vhs_after_timestamps" in pipeline_cfg: return bool(pipeline_cfg.get("sync_vhs_after_timestamps")) return self.sync_from_timing + + # -- Playwright ------------------------------------------------------------ + + @property + def playwright_config(self) -> dict[str, Any]: + defaults: dict[str, Any] = { + "python_path": "", + "timeout_sec": 120, + "default_url": "", + "default_viewport": {"width": 1920, "height": 1080}, + } + defaults.update(self.raw.get("playwright", {})) + return defaults + + @property + def playwright_python_path(self) -> str | None: + value = self.playwright_config.get("python_path") + return str(value) if value else None + + @property + def playwright_timeout_sec(self) -> int: + return int(self.playwright_config.get("timeout_sec", 120)) + + @property + def playwright_default_url(self) -> str | None: + value = str(self.playwright_config.get("default_url", "")).strip() + return value or None + + @property + def playwright_default_viewport(self) -> tuple[int, int]: + raw = self.playwright_config.get("default_viewport", {}) or {} + width = int(raw.get("width", 1920)) + height = int(raw.get("height", 1080)) + return width, height # -- Compose ---------------------------------------------------------------- @property diff --git a/src/docgen/playwright_runner.py b/src/docgen/playwright_runner.py new file mode 100644 index 0000000..7cebedf --- /dev/null +++ b/src/docgen/playwright_runner.py @@ -0,0 +1,152 @@ +"""Playwright visual source runner via external capture scripts.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from docgen.config import Config + + +class PlaywrightError(RuntimeError): + """Raised when Playwright capture fails.""" + + +class PlaywrightRunner: + """Runs user-provided browser capture scripts for docgen segments.""" + + def __init__(self, config: Config, timeout_sec: int | None = None) -> None: + self.config = config + self.timeout_sec = ( + int(timeout_sec) + if timeout_sec is not None + else int(self.config.playwright_timeout_sec) + ) + + def capture_segment(self, seg_id: str, vmap: dict[str, Any]) -> Path: + """Capture (or resolve) segment video for `type: playwright` visual map.""" + source = str(vmap.get("source", "")).strip() + if not source: + raise PlaywrightError( + f"visual_map[{seg_id}] type=playwright requires a 'source' output path" + ) + output_path = self._resolve_output_path(source) + + script = str(vmap.get("script", "")).strip() + if not script: + if output_path.exists(): + return output_path + raise PlaywrightError( + f"type=playwright source missing and no script configured: {output_path}" + ) + + script_path = self._resolve_path(script) + if not script_path.exists(): + raise PlaywrightError(f"Playwright script not found: {script_path}") + + url = str(vmap.get("url", "")).strip() or None + viewport = vmap.get("viewport", {}) or {} + width = int(viewport.get("width", 1920)) + height = int(viewport.get("height", 1080)) + args = [str(a) for a in (vmap.get("args", []) or [])] + + return self.capture( + script=script_path, + output=output_path, + url=url, + viewport={"width": width, "height": height}, + args=args, + segment_id=seg_id, + ) + + def capture( + self, + *, + script: Path | str | None, + output: Path | str | None = None, + source: str | None = None, + url: str | None = None, + viewport: dict[str, int] | None = None, + args: list[str] | None = None, + segment_id: str | None = None, + timeout_sec: int | None = None, + ) -> Path: + """Run one external capture script and return the output video path.""" + if script is None and url is None: + raise PlaywrightError("capture requires --script or --url") + if script is None: + raise PlaywrightError("capture requires --script") + + script_path = self._resolve_path(script) + output_value = output if output is not None else source + if output_value is None: + output_value = "playwright-capture.mp4" + output_path = self._resolve_output_path(output_value) + output_path.parent.mkdir(parents=True, exist_ok=True) + + python_bin = self.config.playwright_python_path or sys.executable + env = os.environ.copy() + env["DOCGEN_PLAYWRIGHT_OUTPUT"] = str(output_path) + if url: + env["DOCGEN_PLAYWRIGHT_URL"] = url + if segment_id: + env["DOCGEN_PLAYWRIGHT_SEGMENT"] = segment_id + vp = viewport or {} + width = int(vp.get("width", 1920)) + height = int(vp.get("height", 1080)) + env["DOCGEN_PLAYWRIGHT_WIDTH"] = str(width) + env["DOCGEN_PLAYWRIGHT_HEIGHT"] = str(height) + env["DOCGEN_PLAYWRIGHT_VIEWPORT"] = f"{width}x{height}" + + effective_timeout = max(1, int(timeout_sec if timeout_sec is not None else self.timeout_sec)) + env["DOCGEN_PLAYWRIGHT_TIMEOUT_SEC"] = str(effective_timeout) + + cmd = [python_bin, str(script_path), *(args or [])] + try: + result = subprocess.run( + cmd, + cwd=str(self.config.base_dir), + env=env, + capture_output=True, + text=True, + timeout=effective_timeout, + check=True, + ) + except FileNotFoundError: + raise PlaywrightError(f"python executable not found: {python_bin}") + except subprocess.TimeoutExpired: + raise PlaywrightError( + f"Playwright capture timed out after {effective_timeout}s ({script_path.name})" + ) + except subprocess.CalledProcessError as exc: + detail = (exc.stderr or exc.stdout or "")[:400] + raise PlaywrightError( + f"Playwright script failed ({script_path.name}): {detail}" + ) + + if not output_path.exists(): + detail = (result.stderr or result.stdout or "").strip() + hint = f" ({detail[:200]})" if detail else "" + raise PlaywrightError( + f"Playwright script finished but output is missing: {output_path}{hint}" + ) + return output_path + + def _resolve_path(self, value: Path | str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + return (self.config.base_dir / path).resolve() + + def _resolve_output_path(self, value: Path | str) -> Path: + path = Path(value) + if path.is_absolute(): + return path + # Source values are normally relative to terminal/rendered. + if path.parent == Path("."): + return (self.config.terminal_dir / "rendered" / path).resolve() + return (self.config.base_dir / path).resolve() diff --git a/src/docgen/wizard.py b/src/docgen/wizard.py index 83f6d98..7608a95 100644 --- a/src/docgen/wizard.py +++ b/src/docgen/wizard.py @@ -386,6 +386,25 @@ def api_run_step(step: str, segment_id: str): comp.compose_segments([segment_id]) return jsonify({"ok": True, "step": "compose", "segment": segment_id}) + elif step == "playwright": + from docgen.playwright_runner import PlaywrightRunner + + vmap = cfg.visual_map.get(segment_id, {}) + source = str(vmap.get("source", "")).strip() + if not source: + return jsonify({"error": "visual_map source is required for playwright"}), 400 + + runner = PlaywrightRunner(cfg) + video = runner.capture_segment(segment_id, vmap) + return jsonify( + { + "ok": True, + "step": "playwright", + "segment": segment_id, + "video": str(video.relative_to(cfg.base_dir)), + } + ) + elif step == "validate": from docgen.validate import Validator v = Validator(cfg) diff --git a/tests/test_compose.py b/tests/test_compose.py index 20230b7..5737949 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -10,6 +10,7 @@ from docgen.compose import Composer from docgen.config import Config +from docgen.playwright_runner import PlaywrightError def _write_cfg(tmp_path: Path, cfg: dict) -> Config: @@ -80,3 +81,105 @@ def test_stale_vhs_warning_can_be_disabled(tmp_path: Path, capsys) -> None: composer._warn_if_stale_vhs(c.visual_map["01"], video) out = capsys.readouterr().out assert out == "" + + +def test_playwright_source_resolves_to_rendered_path(tmp_path: Path) -> None: + cfg = { + "dirs": { + "terminal": "terminal", + "audio": "audio", + "recordings": "recordings", + "animations": "animations", + }, + "segments": {"default": ["01"], "all": ["01"]}, + "visual_map": {"01": {"type": "playwright", "source": "01-browser.mp4"}}, + } + c = _write_cfg(tmp_path, cfg) + rendered = tmp_path / "terminal" / "rendered" + rendered.mkdir(parents=True, exist_ok=True) + expected = rendered / "01-browser.mp4" + expected.write_text("video", encoding="utf-8") + + composer = Composer(c) + resolved = composer._playwright_path(c.visual_map["01"]) + assert resolved == expected + + +def test_compose_playwright_runs_capture_when_source_missing(tmp_path: Path, monkeypatch) -> None: + cfg = { + "dirs": { + "terminal": "terminal", + "audio": "audio", + "recordings": "recordings", + "animations": "animations", + }, + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-demo"}, + "visual_map": { + "01": { + "type": "playwright", + "source": "01-browser.mp4", + "script": "scripts/capture.py", + } + }, + } + c = _write_cfg(tmp_path, cfg) + audio = tmp_path / "audio" / "01-demo.mp3" + audio.parent.mkdir(parents=True, exist_ok=True) + audio.write_bytes(b"mp3") + + rendered = tmp_path / "terminal" / "rendered" + rendered.mkdir(parents=True, exist_ok=True) + expected_video = rendered / "01-browser.mp4" + + calls: list[str] = [] + + class FakeRunner: + def __init__(self, _config) -> None: + pass + + def capture_segment(self, seg_id: str, vmap: dict) -> Path: + calls.append(seg_id) + expected_video.write_bytes(b"video") + return expected_video + + monkeypatch.setattr("docgen.playwright_runner.PlaywrightRunner", FakeRunner) + + composer = Composer(c) + monkeypatch.setattr(composer, "_probe_duration", lambda _p: 10.0) + monkeypatch.setattr(composer, "_run_ffmpeg", lambda _cmd: None) + ok = composer.compose_segments(["01"], strict=True) + assert ok == 1 + assert calls == ["01"] + + +def test_compose_playwright_skip_on_capture_error(tmp_path: Path, monkeypatch) -> None: + cfg = { + "dirs": { + "terminal": "terminal", + "audio": "audio", + "recordings": "recordings", + "animations": "animations", + }, + "segments": {"default": ["01"], "all": ["01"]}, + "visual_map": { + "01": { + "type": "playwright", + "source": "01-browser.mp4", + "script": "scripts/capture.py", + } + }, + } + c = _write_cfg(tmp_path, cfg) + + class FakeRunner: + def __init__(self, _config) -> None: + pass + + def capture_segment(self, seg_id: str, vmap: dict) -> Path: + raise PlaywrightError("boom") + + monkeypatch.setattr("docgen.playwright_runner.PlaywrightRunner", FakeRunner) + composer = Composer(c) + ok = composer.compose_segments(["01"], strict=True) + assert ok == 0 diff --git a/tests/test_config.py b/tests/test_config.py index bc274fc..2158195 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -61,6 +61,10 @@ def test_defaults(): assert c.manim_path is None assert c.vhs_path is None assert c.vhs_render_timeout_sec == 120 + assert c.playwright_python_path is None + assert c.playwright_timeout_sec == 120 + assert c.playwright_default_url is None + assert c.playwright_default_viewport == (1920, 1080) finally: cfg_path.unlink() @@ -87,6 +91,12 @@ def test_binary_paths_and_compose_config(tmp_path): }, "compose": {"ffmpeg_timeout_sec": 900, "warn_stale_vhs": False}, "pipeline": {"sync_vhs_after_timestamps": True}, + "playwright": { + "python_path": "/opt/bin/python3", + "timeout_sec": 240, + "default_url": "http://localhost:3300", + "default_viewport": {"width": 1366, "height": 768}, + }, } p = tmp_path / "docgen.yaml" p.write_text(yaml.dump(cfg), encoding="utf-8") @@ -99,3 +109,7 @@ def test_binary_paths_and_compose_config(tmp_path): assert c.sync_vhs_after_timestamps is True assert c.typing_ms_per_char == 40 assert c.vhs_render_timeout_sec == 240 + assert c.playwright_python_path == "/opt/bin/python3" + assert c.playwright_timeout_sec == 240 + assert c.playwright_default_url == "http://localhost:3300" + assert c.playwright_default_viewport == (1366, 768) diff --git a/tests/test_playwright_runner.py b/tests/test_playwright_runner.py new file mode 100644 index 0000000..e1809ce --- /dev/null +++ b/tests/test_playwright_runner.py @@ -0,0 +1,94 @@ +"""Tests for Playwright runner command and output path behavior.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +import yaml + +from docgen.playwright_runner import PlaywrightError, PlaywrightRunner +from docgen.config import Config + + +def _write_cfg(tmp_path: Path) -> Config: + cfg = { + "dirs": {"terminal": "terminal"}, + "segments": {"default": ["01"], "all": ["01"]}, + } + path = tmp_path / "docgen.yaml" + path.write_text(yaml.dump(cfg), encoding="utf-8") + return Config.from_yaml(path) + + +def test_capture_requires_script_or_url(tmp_path: Path) -> None: + cfg = _write_cfg(tmp_path) + runner = PlaywrightRunner(cfg) + with pytest.raises(PlaywrightError, match="requires --script or --url"): + runner.capture(script=None, url=None) + + +def test_capture_runs_script_and_outputs_mp4(tmp_path: Path) -> None: + cfg = _write_cfg(tmp_path) + runner = PlaywrightRunner(cfg) + script = tmp_path / "capture.py" + output = cfg.terminal_dir / "rendered" / "demo.mp4" + script.write_text( + ( + "import os\n" + "from pathlib import Path\n" + "out = Path(os.environ['DOCGEN_PLAYWRIGHT_OUTPUT'])\n" + "out.parent.mkdir(parents=True, exist_ok=True)\n" + "out.write_bytes(b'fake-mp4')\n" + ), + encoding="utf-8", + ) + + path = runner.capture(script=str(script), source="demo.mp4") + assert path == output + assert output.exists() + assert output.read_bytes() == b"fake-mp4" + + +def test_capture_builds_env_from_options(tmp_path: Path, monkeypatch) -> None: + cfg = _write_cfg(tmp_path) + runner = PlaywrightRunner(cfg) + script = tmp_path / "capture.py" + script.write_text("print('ok')\n", encoding="utf-8") + + observed: dict[str, str] = {} + + def _fake_run(cmd, *, cwd, env, capture_output, text, timeout, check): # noqa: ANN001 + observed["cmd0"] = cmd[0] + observed["script"] = cmd[1] + observed["cwd"] = cwd + observed["url"] = env.get("DOCGEN_PLAYWRIGHT_URL", "") + observed["viewport"] = env.get("DOCGEN_PLAYWRIGHT_VIEWPORT", "") + observed["timeout"] = env.get("DOCGEN_PLAYWRIGHT_TIMEOUT_SEC", "") + out = Path(env["DOCGEN_PLAYWRIGHT_OUTPUT"]) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("x", encoding="utf-8") + + class _Proc: + returncode = 0 + stdout = "" + stderr = "" + + return _Proc() + + monkeypatch.setattr("subprocess.run", _fake_run) + out = runner.capture( + script=str(script), + url="http://localhost:3300", + source="custom.mp4", + viewport={"width": 1280, "height": 720}, + timeout_sec=45, + ) + assert out.name == "custom.mp4" + assert observed["cmd0"] == sys.executable + assert observed["script"] == str(script.resolve()) + assert observed["cwd"] == str(cfg.base_dir) + assert observed["url"] == "http://localhost:3300" + assert observed["viewport"] == "1280x720" + assert observed["timeout"] == "45"