feat(skill): record browser demo GIFs

This commit is contained in:
Tianyi Cui
2026-07-23 17:04:33 +08:00
parent 7c0c516f60
commit f7103c833c
6 changed files with 400 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
---
name: record-browser-gif
description: Record browser or Web UI interaction demos as optimized local GIFs using the available built-in browser, state-based frame capture, and deterministic encoding. Use when Codex is asked to make, record, or generate a GIF that demonstrates a browser workflow, including real-server or real-API behavior. Stop after returning the verified local artifact; do not upload it or edit a pull request.
---
# Record Browser GIF
Produce a short, truthful UI demonstration as a local GIF. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size.
## Keep the boundary explicit
- Produce frame images and one local `.gif` artifact only.
- Never upload the artifact, post a comment, or change a pull request, issue, or document under this skill. Hand those actions to a separate workflow if the user requests them.
- Preserve the requested provenance. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
## Record the flow
1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required.
2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports.
3. Choose three to six states that tell one story, such as initial, typed, submitted, and completed. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer.
4. Keep one viewport and crop for every frame. Store frames in an absolute artifact directory outside the Git worktree unless the user requests another location, and name them lexically: `00-initial.png`, `01-typed.png`, and so on.
5. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Do not use a fixed delay as proof that the application reached the state.
6. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible.
Use the browser's own screenshot API. When it returns image bytes, save those bytes directly; the encoder detects image content independently of the filename extension.
## Encode the GIF
Require `python3`, `ffmpeg`, and `ffprobe`. If either media binary is missing, report the dependency instead of installing software without authorization.
Set `GIF_SKILL_DIR` to this skill's absolute directory, then encode the lexically ordered frames:
```sh
python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \
/absolute/path/to/frames \
/absolute/path/to/demo.gif \
--durations 1.5,1.5,1.5,3.5 \
--fps 10 \
--max-width 1200 \
--colors 128
```
One duration applies to every frame; otherwise provide one comma-separated positive duration per frame. The encoder rejects fewer than two frames, mismatched dimensions or durations, invalid limits, accidental overwrite, unexpected duration, and output above `--max-bytes`.
For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; retain readable text and the final state long enough to inspect. Use `--force` only after resolving the exact output path.
## Verify and deliver
1. Read the encoder's JSON summary and confirm the output path, source and encoded frame counts, dimensions, duration, and byte size.
2. Inspect the first and final source frames and the resulting GIF. Confirm that the transition is legible, the last state is held long enough, and no sensitive content appears.
3. If capture occurred near a repository, run `git status --short` and confirm the artifact did not dirty the worktree.
4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. Stop without uploading it or editing remote content.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Record Browser GIF"
short_description: "Record and optimize local browser demo GIFs"
default_prompt: "Use $record-browser-gif to record this browser flow as a verified local GIF."

View File

@@ -0,0 +1,279 @@
#!/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 absolute path for the ffconcat file directive."""
value = str(path)
if "\n" in value or "\r" in value:
fail(f"frame path contains a newline: {path}")
return "'" + value.replace("\\", "\\\\").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 2 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 2 <= args.colors <= 256:
fail("--colors must be between 2 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=diff"
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()