mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
259 lines
7.8 KiB
Python
Executable File
259 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Record all web UI demos.
|
|
|
|
This script orchestrates recording of web UI demos using Playwright,
|
|
then converts the WebM recordings to GIF format.
|
|
|
|
Usage:
|
|
python docs/demos/web/record.py # Record all demos
|
|
python docs/demos/web/record.py navigation # Record specific demo
|
|
|
|
Requirements:
|
|
- Playwright with Chromium: playwright install chromium
|
|
- ffmpeg for GIF conversion: apt install ffmpeg / brew install ffmpeg
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
|
|
console = Console()
|
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
REPO_DIR = SCRIPT_DIR.parent.parent.parent
|
|
OUTPUT_DIR = REPO_DIR / "docs" / "assets"
|
|
|
|
DEMOS = [
|
|
"navigation",
|
|
"stack",
|
|
"themes",
|
|
"workflow",
|
|
"console",
|
|
"shell",
|
|
]
|
|
|
|
# High-quality ffmpeg settings for VP8 encoding
|
|
# See: https://github.com/microsoft/playwright/issues/10855
|
|
# See: https://github.com/microsoft/playwright/issues/31424
|
|
#
|
|
# MAX_QUALITY: Lossless-like, largest files
|
|
# BALANCED_QUALITY: ~43% file size, nearly indistinguishable quality
|
|
MAX_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 0 -crf 0 -deadline best -speed 0 -b:v 0 -threads 0"
|
|
BALANCED_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 10 -crf 4 -deadline best -speed 0 -b:v 0 -threads 0"
|
|
|
|
# Choose which quality to use
|
|
VIDEO_QUALITY_ARGS = MAX_QUALITY_ARGS
|
|
|
|
|
|
def patch_playwright_video_quality() -> None:
|
|
"""Patch Playwright's videoRecorder.js to use high-quality encoding settings."""
|
|
from playwright._impl._driver import compute_driver_executable # noqa: PLC0415
|
|
|
|
# compute_driver_executable returns (node_path, cli_path)
|
|
result = compute_driver_executable()
|
|
node_path = result[0] if isinstance(result, tuple) else result
|
|
driver_path = Path(node_path).parent
|
|
|
|
video_recorder = driver_path / "package" / "lib" / "server" / "chromium" / "videoRecorder.js"
|
|
|
|
if not video_recorder.exists():
|
|
msg = f"videoRecorder.js not found at {video_recorder}"
|
|
raise FileNotFoundError(msg)
|
|
|
|
content = video_recorder.read_text()
|
|
|
|
# Check if already patched
|
|
if "deadline best" in content:
|
|
return # Already patched
|
|
|
|
# Pattern to match the ffmpeg args line
|
|
pattern = (
|
|
r"-c:v vp8 -qmin \d+ -qmax \d+ -crf \d+ -deadline \w+ -speed \d+ -b:v \w+ -threads \d+"
|
|
)
|
|
|
|
if not re.search(pattern, content):
|
|
msg = "Could not find ffmpeg args pattern in videoRecorder.js"
|
|
raise ValueError(msg)
|
|
|
|
# Replace with high-quality settings
|
|
new_content = re.sub(pattern, VIDEO_QUALITY_ARGS, content)
|
|
video_recorder.write_text(new_content)
|
|
console.print("[green]Patched Playwright for high-quality video recording[/green]")
|
|
|
|
|
|
def record_demo(name: str, index: int, total: int) -> Path | None:
|
|
"""Run a single demo and return the video path."""
|
|
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] web-{name}")
|
|
|
|
demo_file = SCRIPT_DIR / f"demo_{name}.py"
|
|
if not demo_file.exists():
|
|
console.print(f"[red] Demo file not found: {demo_file}[/red]")
|
|
return None
|
|
|
|
# Create temp output dir for this recording
|
|
temp_dir = SCRIPT_DIR / ".recordings"
|
|
temp_dir.mkdir(exist_ok=True)
|
|
|
|
# Run pytest with video recording
|
|
# Set PYTHONPATH so conftest.py imports work
|
|
env = {**os.environ, "PYTHONPATH": str(SCRIPT_DIR)}
|
|
result = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"pytest",
|
|
str(demo_file),
|
|
"-v",
|
|
"--no-cov",
|
|
"-x", # Stop on first failure
|
|
f"--basetemp={temp_dir}",
|
|
],
|
|
check=False,
|
|
cwd=REPO_DIR,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
console.print(f"[red] Failed to record {name}[/red]")
|
|
console.print(result.stdout)
|
|
console.print(result.stderr)
|
|
return None
|
|
|
|
# Find the recorded video
|
|
videos = list(temp_dir.rglob("*.webm"))
|
|
if not videos:
|
|
console.print(f"[red] No video found for {name}[/red]")
|
|
return None
|
|
|
|
# Use the most recent video
|
|
video = max(videos, key=lambda p: p.stat().st_mtime)
|
|
console.print(f"[green] Recorded: {video.name}[/green]")
|
|
return video
|
|
|
|
|
|
def convert_to_gif(webm_path: Path, output_name: str) -> Path:
|
|
"""Convert WebM to GIF using ffmpeg with palette optimization."""
|
|
gif_path = OUTPUT_DIR / f"{output_name}.gif"
|
|
palette_path = webm_path.parent / "palette.png"
|
|
|
|
# Two-pass approach for better quality
|
|
# Pass 1: Generate palette
|
|
subprocess.run(
|
|
[ # noqa: S607
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
str(webm_path),
|
|
"-vf",
|
|
"fps=10,scale=1280:-1:flags=lanczos,palettegen=stats_mode=diff",
|
|
str(palette_path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
# Pass 2: Generate GIF with palette
|
|
subprocess.run(
|
|
[ # noqa: S607
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
str(webm_path),
|
|
"-i",
|
|
str(palette_path),
|
|
"-lavfi",
|
|
"fps=10,scale=1280:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
|
|
str(gif_path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
palette_path.unlink(missing_ok=True)
|
|
return gif_path
|
|
|
|
|
|
def move_recording(video_path: Path, name: str) -> tuple[Path, Path]:
|
|
"""Move WebM and convert to GIF, returning both paths."""
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
output_name = f"web-{name}"
|
|
webm_dest = OUTPUT_DIR / f"{output_name}.webm"
|
|
|
|
shutil.copy2(video_path, webm_dest)
|
|
console.print(f"[blue] WebM: {webm_dest.relative_to(REPO_DIR)}[/blue]")
|
|
|
|
gif_path = convert_to_gif(video_path, output_name)
|
|
console.print(f"[blue] GIF: {gif_path.relative_to(REPO_DIR)}[/blue]")
|
|
|
|
return webm_dest, gif_path
|
|
|
|
|
|
def cleanup() -> None:
|
|
"""Clean up temporary recording files."""
|
|
temp_dir = SCRIPT_DIR / ".recordings"
|
|
if temp_dir.exists():
|
|
shutil.rmtree(temp_dir)
|
|
|
|
|
|
def main() -> int:
|
|
"""Record all web UI demos."""
|
|
console.print("[blue]Recording web UI demos...[/blue]")
|
|
console.print(f"Output directory: {OUTPUT_DIR}")
|
|
console.print()
|
|
|
|
# Patch Playwright for high-quality video recording
|
|
patch_playwright_video_quality()
|
|
|
|
# Determine which demos to record
|
|
if len(sys.argv) > 1:
|
|
demos_to_record = [d for d in sys.argv[1:] if d in DEMOS]
|
|
if not demos_to_record:
|
|
console.print(f"[red]Unknown demo(s). Available: {', '.join(DEMOS)}[/red]")
|
|
return 1
|
|
else:
|
|
demos_to_record = DEMOS
|
|
|
|
results: dict[str, tuple[Path | None, Path | None]] = {}
|
|
|
|
try:
|
|
for i, demo in enumerate(demos_to_record, 1):
|
|
video_path = record_demo(demo, i, len(demos_to_record))
|
|
if video_path:
|
|
webm, gif = move_recording(video_path, demo)
|
|
results[demo] = (webm, gif)
|
|
else:
|
|
results[demo] = (None, None)
|
|
console.print()
|
|
finally:
|
|
cleanup()
|
|
|
|
# Summary
|
|
console.print("[blue]=== Summary ===[/blue]")
|
|
success_count = sum(1 for w, _ in results.values() if w is not None)
|
|
console.print(f"Recorded: {success_count}/{len(demos_to_record)} demos")
|
|
console.print()
|
|
|
|
for demo, (webm, gif) in results.items(): # type: ignore[assignment]
|
|
status = "[green]OK[/green]" if webm else "[red]FAILED[/red]"
|
|
console.print(f" {demo}: {status}")
|
|
if webm:
|
|
console.print(f" {webm.relative_to(REPO_DIR)}")
|
|
if gif:
|
|
console.print(f" {gif.relative_to(REPO_DIR)}")
|
|
|
|
return 0 if success_count == len(demos_to_record) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|