"""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(subtitle)}
' if subtitle else ''}{e(subtitle)}
{rich(card["line"])}
' if card.get("line") else ''} {f'{rich(card["note"])}
' if card.get("note") else ''}