Zum Inhalt

tools/screenshots/regenerate.py

#!/usr/bin/env python3
"""Regenerate the WebGUI screenshots in docs/images/.

End-to-end and self-contained: builds a synthetic demo output/ tree, starts the
real FastAPI/HTMX WebGUI, drives it through six views (start, four run states,
transcript editor) with Playwright (system Chrome, dark theme forced), optimises
the PNGs with pngquant, and cleans up.

    python tools/screenshots/regenerate.py            # default port 8799
    python tools/screenshots/regenerate.py --port 9000 --keep-demo

Prerequisites (see tools/screenshots/README.md):
  * the project env (`uv sync` — playwright is in the dev group)
  * Google Chrome installed (Playwright uses it via channel="chrome")
  * ffmpeg (poster frame) and, optionally, pngquant (PNG optimisation)
"""

from __future__ import annotations

import argparse
import shutil
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

import demo_data  # same directory (sys.path[0])
from playwright.sync_api import sync_playwright

REPO = Path(__file__).resolve().parents[2]
DOCS_IMAGES = REPO / "docs" / "images"

DIV = "─" * 60
TRANSCRIBE = [
    DIV,
    "SCHRITT 1: Transkription",
    DIV,
    "[1/4] Modell laden (large-v3-turbo, de)",
    "[2/4] Transkribieren",
    "[3/4] Wort-Alignment",
    "[4/4] Speaker-Diarization",
    "✓ Transkription fertig",
]
META = [
    DIV,
    "SCHRITT 2: YouTube-Metadaten generieren",
    DIV,
    "Metadaten generieren via MLX (qwen2.5-7b-instruct-4bit) …",
    "✓ Metadaten geschrieben",
]
RENDER_RUNNING = [
    DIV,
    "SCHRITT 3: Video rendern (Remotion)",
    DIV,
    "Rendering 12.0%",
    "Rendering 24.0%",
    "Rendering 41.7%",
    "Rendering 53.4%",
]


def _render_done(stem: str) -> list[str]:
    return [
        DIV,
        "SCHRITT 3: Video rendern (Remotion)",
        DIV,
        "Rendering 12.0%",
        "Rendering 24.0%",
        "Rendering 53.4%",
        "Rendering 87.2%",
        "Rendering 100.0%",
        f"✓ Render fertig: output/{stem}/{stem}-dialogue.mp4",
    ]


def _upload(url: str) -> list[str]:
    return [
        DIV,
        "SCHRITT 4: YouTube-Upload",
        DIV,
        "Upload zu YouTube als private …",
        f"✓ Hochgeladen: {url}",
    ]


_URL = "https://youtu.be/qC7w-2hL"

# (output name, path, seeded log, progress bar)
TARGETS = [
    ("webgui-start", "/", None, None),
    (
        "webgui-running",
        "/runs/folge-082",
        TRANSCRIBE + META + RENDER_RUNNING,
        {"pct": 53, "left": "Rendering", "right": "53%"},
    ),
    (
        "webgui-upload",
        "/runs/folge-083",
        TRANSCRIBE + META + _render_done("folge-083"),
        {"pct": 100, "left": "Render complete", "right": "100%"},
    ),
    (
        "webgui-done",
        "/runs/folge-081",
        TRANSCRIBE + META + _render_done("folge-081") + _upload(_URL),
        {"pct": 100, "left": "Pipeline complete", "right": "4 / 4"},
    ),
    # Paused after transcribe — the "Pause after transcribe" trust moment + Edit CTA.
    (
        "webgui-paused",
        "/runs/folge-084",
        [*TRANSCRIBE, "⏸ Pipeline pausiert nach Transkription — Transkript bereit zum Editieren"],
        None,
    ),
    # Transcript editor — per-segment text + speaker editing, merge/split, bulk rename.
    ("webgui-editor", "/runs/folge-081/edit", None, None),
]

SEED_JS = """
([log, progress]) => {
  const el = document.getElementById('log');
  if (el && log && log.length) {
    el.innerHTML = '';
    for (const t of log) {
      const row = document.createElement('div'); row.className = 'row';
      const m = document.createElement('span'); m.className = 'mark'; m.textContent = '·';
      const s = document.createElement('span'); s.className = 'msg'; s.textContent = t;
      row.append(m, s); el.append(row);
    }
    el.scrollTop = el.scrollHeight;
  }
  if (progress) {
    const fill = document.querySelector('#progress .progress-fill');
    if (fill) fill.style.setProperty('--progress', progress.pct + '%');
    const metas = document.querySelectorAll('#progress .progress-meta span');
    if (metas[0]) metas[0].textContent = progress.left;
    if (metas[1]) metas[1].textContent = progress.right;
  }
  document.querySelectorAll('video').forEach(v => {
    try { v.pause(); v.currentTime = 0.1; } catch (e) {}
  });
}
"""


def _wait_healthz(base: str, timeout: float = 25.0) -> None:
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            with urllib.request.urlopen(base + "/healthz", timeout=2) as r:
                if r.status == 200:
                    return
        except Exception:
            time.sleep(0.3)
    raise RuntimeError(f"WebGUI did not come up at {base} within {timeout}s")


def _capture(base: str, raw_dir: Path) -> None:
    raw_dir.mkdir(parents=True, exist_ok=True)
    with sync_playwright() as p:
        browser = p.chromium.launch(channel="chrome", headless=True)
        ctx = browser.new_context(
            viewport={"width": 1280, "height": 900}, device_scale_factor=2, color_scheme="dark"
        )
        ctx.add_init_script("try{localStorage.setItem('theme','dark')}catch(e){}")
        page = ctx.new_page()
        for name, path, log, progress in TARGETS:
            page.goto(base + path, wait_until="networkidle")
            if log or progress:
                page.evaluate(SEED_JS, [log, progress])
            page.wait_for_timeout(700)  # let the video poster frame paint
            page.screenshot(path=str(raw_dir / f"{name}.png"), full_page=True)
            print(f"  captured {name}.png")
        browser.close()


def _optimise(raw_dir: Path) -> None:
    DOCS_IMAGES.mkdir(parents=True, exist_ok=True)
    has_pngquant = shutil.which("pngquant") is not None
    if not has_pngquant:
        print("  pngquant not found — copying raw PNGs (larger files)")
    for name, *_ in TARGETS:
        src = raw_dir / f"{name}.png"
        dst = DOCS_IMAGES / f"{name}.png"
        if has_pngquant:
            subprocess.run(
                [
                    "pngquant",
                    "--quality=62-90",
                    "--strip",
                    "--speed",
                    "1",
                    "--force",
                    "--output",
                    str(dst),
                    str(src),
                ],
                check=True,
            )
        else:
            shutil.copyfile(src, dst)
        print(f"  wrote {dst.relative_to(REPO)} ({dst.stat().st_size // 1024} KB)")


def main() -> int:
    ap = argparse.ArgumentParser(description="Regenerate docs/images WebGUI screenshots")
    ap.add_argument("--port", type=int, default=8799)
    ap.add_argument(
        "--keep-demo",
        action="store_true",
        help="keep the synthetic output/ demo runs instead of deleting them",
    )
    args = ap.parse_args()
    base = f"http://127.0.0.1:{args.port}"

    print("• building demo output/ tree …")
    stems = demo_data.build(REPO)

    print(f"• starting WebGUI on {base} …")
    server = subprocess.Popen(
        [
            sys.executable,
            str(REPO / "webgui.py"),
            "--no-open",
            "--host",
            "127.0.0.1",
            "--port",
            str(args.port),
        ],
        cwd=str(REPO),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    raw_dir = REPO / "output" / "_shots"
    try:
        _wait_healthz(base)
        print("• capturing screenshots …")
        _capture(base, raw_dir)
        print("• optimising PNGs → docs/images/ …")
        _optimise(raw_dir)
    finally:
        server.terminate()
        try:
            server.wait(timeout=5)
        except subprocess.TimeoutExpired:
            server.kill()
        if not args.keep_demo:
            shutil.rmtree(raw_dir, ignore_errors=True)
            for stem in stems:
                shutil.rmtree(REPO / "output" / stem, ignore_errors=True)
            out = REPO / "output"
            if out.exists() and not any(out.iterdir()):
                out.rmdir()
    print("✓ done")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())