Playwright gif capture
Skill simiancraft/simiancraft-skills/skills/playwright-gif-capture
Public Claude Code skills and agents from simiancraft. Marketplace-installable.
npx -y skills add simiancraft/simiancraft-skills --skill playwright-gif-captureAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Specialization of playwright-harness for capturing an ANIMATED GIF of web content: drive a page/canvas/WebGL animation, grab a sequence of frames, and encode a looping GIF. Use when the task is "make a gif of this", "grab a gif of the demo/animation/shader", "record the canvas as a gif", or "capture a looping clip for a PR/README/issue"; any time a still screenshot is not enough. Read playwright-harness FIRST for script conventions and execution. For the quality/size dials (preventing deep-fried color, janky frame rate, or oversized files) read references/gif-optimization.md rather than guessing. Validated on Linux/WSL, headless Chromium + ANGLE, against WebGL canvas content.
SKILL.md
7.2 KB, as published. Nobody here has run it
GIF Capture: specialization of playwright-harness
Read playwright-harness first. The base owns prerequisites, the run pattern
(write to /tmp/pw-*.mjs, run with playwright resolvable), and the drive/assert
- WebGL-GPU patterns. This skill changes only how you turn a running animation
into frames. Encoding and the quality/size tradeoffs live in
references/gif-optimization.md; open it before tuning, since a deep-fried, janky, or oversized GIF is easy to make by accident.
Headless is required here (not just the default): the encode is offline and you want clean, chrome-free frames.
Prerequisites (in addition to the base)
ffmpegto encode frames/video into a GIF;npm i ffmpeg-staticin a /tmp dir gives a binary path, no system install.- For tuning,
gifski(best on gradients) andgifsicle(size reduction); seereferences/gif-optimization.md. ImageMagick is an optional alternative.
Capture the frames. Two ways.
A. Frame-by-frame screenshots (default)
The default for canvas, WebGL, or any clock-driven animation. Drive the clock yourself, one frame at a time, and screenshot each step: deterministic (same frames every run), evenly spaced (no wall-clock jitter, so no jank), croppable to an element, and it sidesteps the recordVideo GPU-death trap below.
// /tmp/pw-gif.mjs (run via the harness: node, with playwright resolvable)
import { chromium } from 'playwright';
import { mkdirSync } from 'node:fs';
const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8080/';
const FRAMES = 48, FPS = 16, OUT = '/tmp/gif-frames';
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: true,
args: ['--use-gl=angle', '--use-angle=gl', '--ignore-gpu-blocklist'], // real GPU under WSLg; see GPU note
});
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });
await page.goto(TARGET_URL, { waitUntil: 'load' });
const target = page.locator('#canvas'); // element to crop to (omit -> full page)
for (let i = 0; i < FRAMES; i++) {
const t = i / FPS; // animation time for this frame (seconds)
// Step the animation deterministically. Best: the page exposes a render hook.
await page.evaluate((t) => window.__renderAtTime?.(t), t);
await page.waitForTimeout(30); // let the draw land (and the GPU flush)
await target.screenshot({ path: `${OUT}/f${String(i).padStart(4, '0')}.png` });
}
await browser.close();
console.log(`wrote ${FRAMES} frames to ${OUT}`);
The clean version needs a render-at-time hook on the page
(window.__renderAtTime(t) sets the clock and draws one frame). If the app only
has a free-running requestAnimationFrame loop, either (a) add a tiny hook for
the capture, or (b) fall back to wall-clock sampling: waitForTimeout(1000/FPS)
between shots, accepting minor unevenness. Capture exactly one period of a
periodic animation so it loops seamlessly.
B. Playwright recordVideo (DOM/CSS animations you cannot time-step)
const context = await browser.newContext({
recordVideo: { dir: '/tmp/gif-vid', size: { width: 960, height: 540 } },
});
const page = await context.newPage();
await page.goto(TARGET_URL);
await page.waitForTimeout(3000); // record real-time playback
await context.close(); // <- video finalizes on CONTEXT close, not page close
// -> /tmp/gif-vid/<hash>.webm, then encode (below)
Real-time, whole viewport, no hook needed. Two costs: it records wall-clock (so it inherits any jank), and under heavy GPU load it can kill the GPU process mid-record and the rest goes black (cranked WebGL + recordVideo is the known offender). For canvas/shader content prefer method A, or record at a light load.
Encode to a GIF
A sane default to get a watchable loop (ffmpeg-static via npm i in /tmp; npm,
not bun, since the postinstall downloads the binary):
cd /tmp && npm i ffmpeg-static >/dev/null 2>&1
FF=$(node -e "process.stdout.write(require('/tmp/node_modules/ffmpeg-static'))")
# from frame PNGs (method A):
"$FF" -y -framerate 16 -i /tmp/gif-frames/f%04d.png \
-vf "scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=256:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5" \
-loop 0 /tmp/out.gif
# from a recorded webm (method B): swap "-framerate 16 -i .../f%04d.png" for "-i /tmp/gif-vid/*.webm" and add fps=16 to the -vf chain
Then stop guessing and open references/gif-optimization.md for the
color/fps/size tradeoff model, the tool matrix (ffmpeg vs gifski vs gifsicle vs
ImageMagick), and the target-size loop. The short version: banding/deep-fried,
raise the dither or switch to gifski; too large, trade dims, then duration, fps,
colors, then a gifsicle lossy pass; janky, capture by method A at a fixed clock
and encode at the capture fps (12 to 20 is the sweet spot).
Always print the output byte size and Read the GIF back (vision) before shipping.
GPU note (delta)
The base's WebGL/GPU caveat applies verbatim; the launch args above already use it. For shader content the ANGLE relaunch is mandatory (heavy shaders no-op under SwiftShader headless), and you must let the animation settle a second or two (PSO compile) before the first captured frame. See playwright-harness, "WebGL / GPU caveat".
Embedding the GIF in a GitHub PR / issue / README
GitHub's inline drag-and-drop upload (user-attachments) has no API, so an
agent cannot post one that way. The working pattern: commit the GIF to a
never-merge assets branch (e.g. evidence/issue-verification) and embed it by
raw.githubusercontent URL
(),
which renders and animates inline. Push that branch from a detached worktree with
--no-verify (an assets worktree has no node_modules, so a pre-commit hook
manager such as lefthook or husky would fail).
Never open a PR from the assets branch. For a committed README asset, a normal
commit on the feature branch is fine; mind the repo's size budget.
Scope
- CAN: produce a reproducible, tight animated GIF of any web/canvas/WebGL
animation; crop to an element; control frame count, fps, size, and palette;
capture the real deployed site (point
TARGET_URLat it). - CANNOT: prove real-time FPS/perf (frame-by-frame is a controlled reconstruction at a chosen fps, not a measurement; use a GPU-time meter for cost); capture audio; or render a single frame so heavy it trips the OS GPU watchdog (split the work or lower the load first).