#!/usr/bin/env python3 """Encode lexically ordered browser screenshots into a verified GIF.""" from __future__ import annotations import argparse import json import math import shutil import subprocess import tempfile from pathlib import Path from typing import NoReturn DEFAULT_MAX_BYTES = 5 * 1024 * 1024 def fail(message: str) -> NoReturn: """Exit with a concise user-correctable error.""" raise SystemExit(f"error: {message}") def positive_float(value: str) -> float: """Parse one finite positive command-line number.""" try: parsed = float(value) except ValueError: fail(f"expected a number, got {value!r}") if not math.isfinite(parsed) or parsed <= 0: fail(f"expected a positive finite number, got {value!r}") return parsed def positive_int(value: str) -> int: """Parse one positive command-line integer.""" try: parsed = int(value) except ValueError: fail(f"expected an integer, got {value!r}") if parsed <= 0: fail(f"expected a positive integer, got {value!r}") return parsed def parse_durations(value: str, frame_count: int) -> list[float]: """Expand one hold duration or validate one duration per source frame.""" parts = [part.strip() for part in value.split(",")] if not parts or any(not part for part in parts): fail("--durations must be a number or a comma-separated list of numbers") durations = [positive_float(part) for part in parts] if len(durations) == 1: return durations * frame_count if len(durations) != frame_count: fail(f"--durations supplied {len(durations)} values for {frame_count} frames") return durations def require_binary(name: str) -> str: """Resolve a required media binary or fail without attempting installation.""" path = shutil.which(name) if path is None: fail(f"required binary {name!r} is not available on PATH") return path def run_json(command: list[str]) -> dict[str, object]: """Run a media probe and parse its JSON object.""" try: completed = subprocess.run(command, check=True, capture_output=True, text=True) except subprocess.CalledProcessError as error: detail = error.stderr.strip() or error.stdout.strip() or str(error) fail(detail) try: value = json.loads(completed.stdout) except json.JSONDecodeError as error: fail(f"media probe returned invalid JSON: {error}") if not isinstance(value, dict): fail("media probe returned a non-object JSON value") return value def probe_stream(ffprobe: str, path: Path) -> dict[str, object]: """Read the first video stream's dimensions and timing metadata.""" result = run_json( [ ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,nb_frames,duration,r_frame_rate", "-of", "json", str(path), ] ) streams = result.get("streams") if not isinstance(streams, list) or len(streams) != 1 or not isinstance(streams[0], dict): fail(f"expected one video stream in {path}") return streams[0] def stream_int(stream: dict[str, object], key: str, path: Path) -> int: """Read a positive integer stream field.""" try: value = int(stream[key]) except (KeyError, TypeError, ValueError): fail(f"missing integer {key!r} in media probe for {path}") if value <= 0: fail(f"non-positive {key!r} in media probe for {path}") return value def ffconcat_quote(path: Path) -> str: """Quote an ffconcat path while preserving literal backslashes.""" value = str(path) if "\n" in value or "\r" in value: fail(f"frame path contains a newline: {path}") return "'" + value.replace("'", "'\\''") + "'" def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]) -> None: """Write an ffconcat manifest that materializes the final frame's hold.""" lines = ["ffconcat version 1.0"] for frame, duration in zip(frames, durations): lines.append(f"file {ffconcat_quote(frame)}") lines.append(f"duration {duration:.6f}") lines.append(f"file {ffconcat_quote(frames[-1])}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") def build_parser() -> argparse.ArgumentParser: """Build the command-line contract.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("frames", type=Path, help="directory containing lexically ordered frames") parser.add_argument("output", type=Path, help="output .gif path") parser.add_argument("--pattern", default="*.png", help="frame glob within the input directory") parser.add_argument( "--durations", default="2", help="one hold duration or one comma-separated value per frame", ) parser.add_argument("--fps", type=positive_int, default=10, help="encoded frames per second") parser.add_argument( "--max-width", type=positive_int, default=1200, help="maximum output width", ) parser.add_argument( "--colors", type=positive_int, default=128, help="palette colors, from 4 through 256", ) parser.add_argument( "--max-bytes", type=positive_int, default=DEFAULT_MAX_BYTES, help="maximum output size", ) parser.add_argument("--force", action="store_true", help="replace an existing output file") return parser def main() -> None: """Validate inputs, encode the GIF, verify it, and print a JSON summary.""" args = build_parser().parse_args() frame_dir = args.frames.resolve() output = args.output.resolve() if not frame_dir.is_dir(): fail(f"frame directory does not exist: {frame_dir}") if output.suffix.lower() != ".gif": fail(f"output must end in .gif: {output}") if output.exists() and not args.force: fail(f"output already exists (pass --force to replace it): {output}") if not 4 <= args.colors <= 256: fail("--colors must be between 4 and 256") if args.fps > 30: fail("--fps must not exceed 30") frames = sorted(path.resolve() for path in frame_dir.glob(args.pattern) if path.is_file()) if len(frames) < 2: fail(f"expected at least two frames matching {args.pattern!r} in {frame_dir}") if output in frames: fail("output path must not match an input frame") durations = parse_durations(args.durations, len(frames)) expected_duration = sum(durations) ffmpeg = require_binary("ffmpeg") ffprobe = require_binary("ffprobe") dimensions = { (stream_int(stream, "width", frame), stream_int(stream, "height", frame)) for frame in frames for stream in [probe_stream(ffprobe, frame)] } if len(dimensions) != 1: fail(f"all frames must have identical dimensions, got {sorted(dimensions)}") output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="record-browser-gif-") as temporary: manifest = Path(temporary) / "frames.ffconcat" write_concat_manifest(manifest, frames, durations) scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos" palette = f"palettegen=max_colors={args.colors}:stats_mode=full" filters = ( f"fps={args.fps},{scale},split[base][palette_input];" f"[palette_input]{palette}[palette];" "[base][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" ) command = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(manifest), "-vf", filters, "-loop", "0", "-t", f"{expected_duration:.6f}", "-y" if args.force else "-n", str(output), ] try: subprocess.run(command, check=True) except subprocess.CalledProcessError as error: fail(f"ffmpeg failed with exit code {error.returncode}") stream = probe_stream(ffprobe, output) width = stream_int(stream, "width", output) height = stream_int(stream, "height", output) encoded_frames = stream_int(stream, "nb_frames", output) try: actual_duration = float(stream["duration"]) except (KeyError, TypeError, ValueError): fail(f"missing duration in media probe for {output}") tolerance = max(0.2, 2 / args.fps) if abs(actual_duration - expected_duration) > tolerance: fail(f"expected about {expected_duration:.3f}s, encoded {actual_duration:.3f}s") if width > args.max_width: fail(f"expected width at most {args.max_width}, encoded {width}") if encoded_frames < 2: fail(f"expected an animated GIF, encoded {encoded_frames} frame") byte_size = output.stat().st_size if byte_size > args.max_bytes: fail(f"output is {byte_size} bytes, above --max-bytes {args.max_bytes}") print( json.dumps( { "output": str(output), "sourceFrames": len(frames), "encodedFrames": encoded_frames, "width": width, "height": height, "durationSeconds": actual_duration, "fps": args.fps, "bytes": byte_size, }, indent=2, sort_keys=True, ) ) if __name__ == "__main__": main()