From simiancraft-skills
Captures animated GIFs of web content (canvas, WebGL, shaders) using Playwright. Drives frame-by-frame screenshots or video recording and encodes looping GIFs for PRs, READMEs, or demos.
How this skill is triggered — by the user, by Claude, or both
Slash command
/simiancraft-skills:playwright-gif-captureThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Read `playwright-harness` first.** The base owns prerequisites, the run pattern
Read playwright-harness first. The base owns prerequisites, the run pattern
(write to /tmp/pw-*.mjs, run with playwright resolvable), and the drive/assert
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.
ffmpeg to encode frames/video into a GIF; npm i ffmpeg-static in a /tmp dir
gives a binary path, no system install.gifski (best on gradients) and gifsicle (size reduction); see
references/gif-optimization.md. ImageMagick is an optional alternative.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.
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.
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.
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".
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.
TARGET_URL at it).Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub simiancraft/simiancraft-skills --plugin simiancraft-skills