"""Post-production for the demo tour, with ffmpeg only: screencast frames → a constant-frame-rate video, one clip per scene with its caption faded in, title and end cards, crossfades between everything, then the final MP4, a poster and the short looping WebP teaser for the README. The filter graphs are built by pure functions (tested without ffmpeg); `run` executes a command and fails loudly. Cards and captions are small HTML pages rendered by the same Chromium (render_card), in the app's own colours. """ from __future__ import annotations import html import json import re import shutil import subprocess from pathlib import Path FFMPEG = shutil.which("ffmpeg") or "ffmpeg" FPS = 30 SIZE = (1920, 1080) # the finished videos; record_video.py --size changes it for one run LAYOUT = (1920, 1080) # the cards' and captions' CSS pixels (drawn at SIZE / LAYOUT device pixels) def run(args: list[str]) -> None: cmd = [FFMPEG, "-y", "-hide_banner", "-loglevel", "error", *map(str, args)] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: raise RuntimeError(f"ffmpeg failed ({r.returncode}): {' '.join(cmd)}\n{r.stderr[-3000:]}") # ------------------------------------------------------------------ frames → video def concat_list(frames: list[tuple[float, str]], end: float | None = None, min_frame: float = 1 / 120) -> str: """An ffconcat script showing each screencast frame until the next one came (Chrome sends frames only on changes); the last one until `end` (wall-clock), else for one frame time.""" lines = ["ffconcat version 1.0"] for i, (t, name) in enumerate(frames): nxt = frames[i + 1][0] if i + 1 < len(frames) else (end if end is not None else t + 1 / FPS) lines += [f"file '{name}'", f"duration {max(min_frame, nxt - t):.6f}"] if frames: lines.append(f"file '{frames[-1][1]}'") # ffconcat: the last entry's duration needs a following file return "\n".join(lines) + "\n" def frames_to_video(folder: Path, out: Path, end: float | None = None, size: tuple[int, int] | None = None) -> float: """The screencast (folder/frames.json + JPEGs) as a constant-frame-rate H.264 file (high quality, an intermediate). Returns the wall-clock time of its first frame (scene times are relative to it).""" frames = [tuple(f) for f in json.loads((folder / "frames.json").read_text())] if not frames: raise RuntimeError("the screencast has no frames") (folder / "frames.ffconcat").write_text(concat_list(frames, end)) w, h = size or SIZE run(["-f", "concat", "-safe", "0", "-i", folder / "frames.ffconcat", "-vf", f"fps={FPS},scale={w}:{h}:flags=lanczos:force_original_aspect_ratio=decrease," f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,format=yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", out]) return frames[0][0] # ------------------------------------------------------------------ scenes def caption_window(duration: float, show: float = 4.2, fade: float = 0.45, lead: float = 0.35) -> tuple[float, float]: """When a scene's caption is on screen: from `lead` s in, for `show` s (shorter scenes: until 0.6 s before their end), fading `fade` s in and out. Returns (start, end).""" start = min(lead, max(0.0, duration / 4)) end = min(start + show, max(start + 2 * fade, duration - 0.6)) return start, end def scene_filter(duration: float, fade: float = 0.45, size: tuple[int, int] | None = None) -> str: """The filter graph laying a caption (input 1, a still RGBA PNG looped) over a scene (input 0).""" start, end = caption_window(duration, fade=fade) size = size or SIZE return (f"[1:v]scale={size[0]}:{size[1]}:flags=lanczos,format=rgba,fade=t=in:st={start:.3f}:d={fade}:alpha=1," f"fade=t=out:st={end - fade:.3f}:d={fade}:alpha=1[cap];" f"[0:v][cap]overlay=0:0:format=auto:shortest=1,format=yuv420p[v]") def cut_scene(raw: Path, start: float, duration: float, caption: Path | None, out: Path, size: tuple[int, int] | None = None) -> None: """One scene from the raw recording, with its caption.""" if caption is None: run(["-ss", f"{start:.3f}", "-t", f"{duration:.3f}", "-i", raw, "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", "-pix_fmt", "yuv420p", out]) return run(["-ss", f"{start:.3f}", "-t", f"{duration:.3f}", "-i", raw, "-loop", "1", "-t", f"{duration:.3f}", "-i", caption, "-filter_complex", scene_filter(duration, size=size), "-map", "[v]", "-an", "-r", FPS, "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", out]) def card_clip(image: Path, duration: float, out: Path, zoom: float = 0.03) -> None: """A title/end card: the still, slowly zooming in by `zoom` over its duration (alive, not frozen).""" frames = max(1, int(duration * FPS)) w, h = SIZE run(["-loop", "1", "-t", f"{duration:.3f}", "-i", image, "-vf", f"scale={w * 2}:{h * 2},zoompan=z='1+{zoom}*on/{frames}':x='iw/2-(iw/zoom/2)':" f"y='ih/2-(ih/zoom/2)':d={frames}:s={w}x{h}:fps={FPS},format=yuv420p", "-frames:v", frames, "-c:v", "libx264", "-preset", "veryfast", "-crf", "12", out]) # ------------------------------------------------------------------ joining def xfade_offsets(durations: list[float], transition: float) -> list[float]: """Where each crossfade starts on the joined timeline: clip k+1 begins `transition` s before clip k ends.""" offsets, total = [], 0.0 for d in durations[:-1]: total += d - transition offsets.append(round(total, 3)) return offsets def xfade_graph(durations: list[float], transition: float, size: tuple[int, int] | None = None) -> tuple[str, str]: """(filter graph, output label) crossfading inputs 0..n-1 in order. Every input is brought to the same size, frame rate, timebase and pixel format first (xfade refuses anything else).""" w, h = size or SIZE parts = [f"[{k}:v]fps={FPS},scale={w}:{h}:flags=lanczos,setsar=1,format=yuv420p,settb=AVTB[n{k}]" for k in range(len(durations))] prev = "[n0]" for k, off in enumerate(xfade_offsets(durations, transition), start=1): label = f"[x{k}]" parts.append(f"{prev}[n{k}]xfade=transition=fade:duration={transition}:offset={off}{label}") prev = label parts.append(f"{prev}format=yuv420p[vout]") return ";".join(parts), "[vout]" def joined_length(durations: list[float], transition: float) -> float: return sum(durations) - transition * (len(durations) - 1) def join(clips: list[Path], durations: list[float], out: Path, transition: float = 0.5, crf: int = 23, size: tuple[int, int] | None = None, extra: list[str] | None = None) -> None: graph, label = xfade_graph(durations, transition, size) args = [] for c in clips: args += ["-i", c] run([*args, "-filter_complex", graph, "-map", label, "-an", "-r", FPS, "-c:v", "libx264", "-preset", "slow", "-crf", crf, "-pix_fmt", "yuv420p", "-movflags", "+faststart", *(extra or []), out]) def encode_within(clips: list[Path], durations: list[float], out: Path, budget_mb: float, transition: float = 0.5, crf: int = 17, max_crf: int = 30) -> int: """The final MP4 at the best quality (lowest CRF) that fits `budget_mb`. Returns the CRF used.""" while True: join(clips, durations, out, transition, crf) if out.stat().st_size <= budget_mb * 1e6 or crf >= max_crf: return crf crf += 1 def poster(video: Path, at: float, out: Path) -> None: run(["-ss", f"{at:.2f}", "-i", video, "-frames:v", "1", "-q:v", "2", out]) def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int = 1280, fps: int = 20, budget_mb: float = 8.0, transition: float = 0.35) -> int: """A looping animated WebP of the highlight clips (README: plays inline where an MP4 wouldn't). Lowers the quality until it fits `budget_mb`. Returns the quality used.""" graph, label = xfade_graph(durations, transition) graph = graph.replace("format=yuv420p[vout]", f"fps={fps},scale={width}:-2:flags=lanczos[vout]") args = [] for c in clips: args += ["-i", c] # UI footage: the "drawing" preset keeps text and edges crisp; start near-lossless (quality 72 at 960 px showed # blocky text on GitHub) and only step down when the file would be too big for a README quality = 95 while True: run([*args, "-filter_complex", graph, "-map", label, "-an", "-c:v", "libwebp_anim", "-lossless", "0", "-preset", "drawing", "-quality", quality, "-compression_level", "6", "-loop", "0", out]) if out.stat().st_size <= budget_mb * 1e6 or quality <= 50: return quality quality -= 5 # ------------------------------------------------------------------ cards and captions def theme_colors(name: str = "portal") -> dict: from frameport.ui import theme as T T.set_theme(name) return {k: getattr(T, k) for k in ("BG", "SURFACE", "SURFACE_2", "BORDER", "TEXT", "TEXT_2", "TEXT_3", "ACCENT", "SECONDARY")} FONT_CSS = ('' '') FONT = "'Roboto', 'Segoe UI', system-ui, sans-serif" def caption_html(title: str, subtitle: str, c: dict, left: int = 372, bottom: int = 56) -> str: """A lower third: a dark glass panel with a blue→orange edge (the logo's two portals), title + one line.""" e = html.escape return f"""{FONT_CSS}

{e(title)}

{f'

{e(subtitle)}

' if subtitle else ''}
""" def card_html(title: str, subtitle: str, footer: str, logo_svg: str, c: dict) -> str: """A full-screen card: the logo, the two-colour wordmark, a line, a footer; a soft portal glow behind.""" e = html.escape word = ('FramePort' .format(a=c["ACCENT"], b=c["SECONDARY"])) if title == "FramePort" else e(title) return f"""{FONT_CSS}

{word}

{e(subtitle)}

{e(footer)}
""" def step_card_html(card: dict, c: dict) -> str: """An instruction card for what happens outside FramePort (a download, a command on the Frame): an eyebrow (where you are), a heading, numbered steps, an optional command in a terminal box and a note. Steps and the note may mark words **bold** (a button or menu name).""" e = html.escape def rich(text: str) -> str: return re.sub(r"\*\*(.+?)\*\*", r"\1", e(text)) steps = "".join(f'
  • {i}{rich(s)}
  • ' for i, s in enumerate(card.get("steps") or [], 1)) code = (f'
    ${e(card["code"])}
    ' if card.get("code") else "") return f"""{FONT_CSS}
    {f'
    {e(card["eyebrow"])}
    ' if card.get("eyebrow") else ''}

    {e(card["heading"])}

    {f'

    {rich(card["line"])}

    ' if card.get("line") else ''} {f'
      {steps}
    ' if steps else ''}{code} {f'

    {rich(card["note"])}

    ' if card.get("note") else ''}
    FramePort
    """ def render_card(playwright, page_html: str, out: Path, transparent: bool = False) -> Path: """Render an HTML card to a PNG at the video size (fonts loaded first).""" browser = playwright.chromium.launch() try: page = browser.new_page(viewport={"width": LAYOUT[0], "height": LAYOUT[1]}, device_scale_factor=SIZE[0] / LAYOUT[0]) page.set_content(page_html, wait_until="networkidle") page.evaluate("document.fonts.ready") page.screenshot(path=str(out), omit_background=transparent) finally: browser.close() return out