Skip to content
Development
Skill

/playwright-gif-capture

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

From plugin
simiancraft-skills
816 skills4 agents
Install
$ npx -y skills add simiancraft/simiancraft-skills --skill playwright-gif-capture --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/playwright-gif-capture

Context preview

The summary Claude sees to decide when to auto-load this skill.

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

SKILL.md

playwright-gif-capture.SKILL.md
name: playwright-gif-capture
description: >-
  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.

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)

  • `ffmpeg` to encode frames/video into a GIF; `npm i ffmpeg-static` in a /tmp dir

gives a binary path, no system install.

  • For tuning, `gifski` (best on gradients) and `gifsicle` (size reduction); see

`references/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 fram

Read more
Ships withsimiancraft-skills

Claude Code skills for the full arc of a change: farm to table, with receipts. Curated Claude Code skills and agents from simiancraft. Most skill collections are grab bags. This one has a spine: it carries a change through its whole life.

Get the whole plugin

Other skills on simiancraft-skills.