Demo gif
Skill oaklake-studio/oaklake-tools/plugins/demo-gif/skills/demo-gif
Use when the user wants a demo GIF / screen recording of a web UI (for a PR, README, changelog, or showcase) in ANY project. Drives a browser through a short scripted flow you define with the user, records one continuous video (headless by default), and converts it to a calm, followable GIF saved under .demo-gifs/ at the project root (auto-added to .gitignore when the root is a git repo). Browser/UI demos only, not terminal/CLI recordings. Assumes a POSIX shell (macOS/Linux; on Windows use WSL or git-bash).From its SKILL.md
npx -y skills add oaklake-studio/oaklake-tools --skill demo-gifAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 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.
SKILL.md
14.8 KB, ~4.0k tokens by cl100k_base, as published. Nobody here has run it
demo-gif
Overview
Records a demo GIF of a running web UI in any project. Pipeline: prepare a reusable Playwright environment → scope the demo with the user → resolve the output folder + gitignore + a run-config file → write a capture script → record the run as one continuous video → verify each beat by screenshot → trim and convert to a palette-optimized GIF under .demo-gifs/.
Two principles carry the quality:
- Calm and followable. Hold a beat on the full view (~3s), then perform one deliberate interaction at a human pace, then hold again. Don't rapid-fire toggles or cram actions. A slow, clear story reads far better.
- Artifacts, not repo files. GIFs are large binaries. They live in
.demo-gifs/at the project root, gitignored by default. The user attaches them where needed.
Recording is headless by default (deterministic, works over SSH/CI/no-display). The browser records video fine headless; you don't need a visible window.
When to Use
- "Make/record a demo gif of <feature>", "capture this for the PR/README", "screen-record the flow"
- Re-taking a demo (bump the version:
-v1→-v2)
Do NOT use when:
- The demo needs a real third-party SSO login on camera (Google/Okta/…). Hands-off automation is blocked by modern browsers, so use hybrid capture: run headed, drive to the provider's screen, then poll only the URL (never screenshot credentials) while the user signs in; delete the throwaway profile after.
- It's a terminal/CLI demo: use a terminal recorder (asciinema), not this skill.
- A still screenshot would do, just take one.
⚠️ Shell state does not persist between Bash calls
Each Bash tool call is a fresh shell: variables set in one call are gone in the next, and you MUST stop between capture and conversion to Read the screenshots. So: Step 2 writes a run-config file; every later Bash block starts by sourcing it. Never assume a variable set in an earlier step still exists.
RUNENV="$HOME/.cache/claude/demo-gif/run.env" # fixed path used by every step
Step 0: Reusable environment (idempotent; once per machine)
POSIX shell required (macOS/Linux; on Windows run this under WSL or git-bash). Keep Playwright in a dedicated persistent venv outside any project, because dependency managers (uv sync, poetry install) prune anything not in their lockfile, which is why an in-project install "disappears."
VENV="$HOME/.cache/claude/demo-gif/venv"; PW="$VENV/bin/python"
mkdir -p "$HOME/.cache/claude/demo-gif"
command -v python3 >/dev/null || { echo "python3 required"; exit 1; }
python3 -c 'import sys; raise SystemExit(0 if sys.version_info>=(3,9) else 1)' || { echo "need python3 >= 3.9"; exit 1; }
# capability-gated (not just "does bin/python exist"): a half-built venv is rebuilt, not reused
if ! "$PW" -c 'import playwright' 2>/dev/null; then
rm -rf "$VENV" # a partial venv must be deleted, never reused
python3 -m venv "$VENV" || { echo "venv failed (Debian: sudo apt-get install python3-venv)"; exit 1; }
"$VENV/bin/pip" install -q --upgrade pip playwright || { echo "pip failed: see stderr; behind a proxy set HTTPS_PROXY"; exit 1; }
fi
# ensure a real browser exists: prefer system Chrome, else fetch bundled Chromium once (~150MB)
if ! "$PW" - <<'PY' 2>/dev/null
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
try: b = p.chromium.launch(headless=True, channel="chrome")
except Exception: b = p.chromium.launch(headless=True) # bundled chromium (raises if not installed)
b.close()
PY
then
echo "no usable browser: installing bundled Chromium…"
"$PW" -m playwright install chromium || echo "chromium install failed (network/proxy?). On Linux use: $PW -m playwright install --with-deps chromium"
fi
command -v ffmpeg >/dev/null || echo "WARN: ffmpeg missing, install it (brew install ffmpeg | apt-get install ffmpeg | dnf install ffmpeg | choco install ffmpeg)"
echo "ready: $PW"
If anything above fails, surface the stderr and stop. Don't proceed on a broken environment. channel="chrome" skips the browser download entirely when Chrome is present; on headless Linux you generally want the bundled Chromium with --with-deps (pulls libnss3/libatk/libgbm).
Step 1: Scope the demo
Ask the user only what you can't infer:
- What we're demoing: a one-line story + a short slug for the filename (e.g.
checkout-flow). - Base URL + whether the app is already running (if not, how to start it, or have them start it).
- Login: none / credentials / a described flow. Never guess credentials; for real SSO see "Do NOT use."
- Theme + data: light or dark (
color_scheme)? And confirm the target has meaningful, stable demo data (not an empty/half-seeded DB or randomized rows) so the GIF shows something real. - The beats: 3–4 max, each one deliberate action.
Discover selectors from the live page with a quick throwaway script using the Step-0 venv (navigate + page.get_by_role(...) / page.content() / a snapshot screenshot) rather than guessing. (If the Playwright MCP is available in your session, its browser_snapshot also works.)
Step 2: Output dir + gitignore + run-config
Resolve the project root (git top-level if in a repo, else cwd), make .demo-gifs/, gitignore it (only when not already effectively ignored), pick the next version, and persist everything to $RUNENV:
RUNENV="$HOME/.cache/claude/demo-gif/run.env"
VENV="$HOME/.cache/claude/demo-gif/venv"
if ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then IS_GIT=1; else ROOT=$(pwd); IS_GIT=0; fi
mkdir -p "$ROOT/.demo-gifs"
if [ "$IS_GIT" = 1 ] && ! git -C "$ROOT" check-ignore -q .demo-gifs/ 2>/dev/null; then
printf '\n# demo/screencast artifacts (claude demo-gif skill)\n.demo-gifs/\n' >> "$ROOT/.gitignore"
fi
SLUG="<slug>"; SCHEME="light" # set SLUG; SCHEME=dark if the app is dark-first
N=1; while [ -e "$ROOT/.demo-gifs/$SLUG-v$N.gif" ]; do N=$((N+1)); done
WORK=$(mktemp -d "${TMPDIR:-/tmp}/demo-gif-$SLUG.XXXXXX")
cat > "$RUNENV" <<EOF
PW="$VENV/bin/python"
ROOT="$ROOT"
WORK="$WORK"
SLUG="$SLUG"
N="$N"
OUT="$ROOT/.demo-gifs/$SLUG-v$N.gif"
BASE="<base-url>"
SCHEME="$SCHEME"
EOF
cat "$RUNENV"
(If a project deliberately wants demos committed, the user just removes the .demo-gifs/ line; mention only if relevant.)
Step 3: Write the capture script
Write the template below to $WORK/capture.py (source $RUNENV first to know $WORK). Set/delete the login block and replace # ===== BEATS =====. For each beat: perform one action → wait on the resulting element (get_by_*().wait_for()), never networkidle → hold() → shot(). Scroll to below-the-fold content with a short page.mouse.wheel loop so the motion reads.
Step 4: Run + verify
source "$HOME/.cache/claude/demo-gif/run.env"
rm -f "$WORK"/video/*.webm 2>/dev/null; mkdir -p "$WORK/video" # clean slate so re-runs don't reuse a stale take
DEMO_URL="$BASE" DEMO_WORK="$WORK" DEMO_SCHEME="$SCHEME" "$PW" "$WORK/capture.py"
Then Read the beat screenshots in $WORK: confirm framing and that each beat landed on real content (not a loader/empty state/overlay). Fix the script and re-run before converting. To pick trim points, build a duration-spanning labeled montage and Read it:
source "$HOME/.cache/claude/demo-gif/run.env"
WEBM=$(ls -t "$WORK"/video/*.webm | head -1)
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WEBM")
STEP=$(awk "BEGIN{s=$DUR/20; print (s<0.5?0.5:s)}") # 20 tiles always span the whole clip
ffmpeg -y -i "$WEBM" -vf "fps=1/$STEP,scale=320:-1,drawtext=text='%{pts\:hms}':x=6:y=6:fontsize=20:fontcolor=yellow:box=1:[email protected],tile=5x4" -frames:v 1 "$WORK/montage.png" 2>/dev/null \
|| ffmpeg -y -i "$WEBM" -vf "fps=1/$STEP,scale=320:-1,tile=5x4" -frames:v 1 "$WORK/montage.png" # fallback if drawtext/font unavailable
echo "montage tiles are ${STEP}s apart (tile k ≈ k*${STEP}s), clip is ${DUR}s"
Step 5: Convert to GIF
source "$HOME/.cache/claude/demo-gif/run.env"
WEBM=$(ls -t "$WORK"/video/*.webm | head -1)
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WEBM")
A=0; B=$DUR; FPS=14; SCALE=800 # set A/B from the montage (drop login head + dead tail)
awk "BEGIN{exit !($A<$DUR && $B>$A)}" || { echo "bad trim window: A=$A B=$B DUR=$DUR"; exit 1; }
PAL="$WORK/pal.png"
ffmpeg -y -i "$WEBM" -vf "trim=$A:$B,setpts=PTS-STARTPTS,fps=$FPS,scale=$SCALE:-1:lanczos,palettegen=stats_mode=diff:max_colors=200" "$PAL"
[ -s "$PAL" ] || { echo "palette empty: trim window likely off"; exit 1; }
ffmpeg -y -i "$WEBM" -i "$PAL" -lavfi "[0:v]trim=$A:$B,setpts=PTS-STARTPTS,fps=$FPS,scale=$SCALE:-1:lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=4" -loop 0 "$OUT"
ffmpeg -y -sseof -1 -i "$OUT" -frames:v 1 "$WORK/check.png" # Read this to confirm legibility at scale
ls -lh "$OUT"
Step 6: Report + clean up
State the path (.demo-gifs/<slug>-v<N>.gif), size, dimensions, duration, and the beats; note whether .demo-gifs/ was added to .gitignore. Then rm -rf "$WORK". Offer a re-take (-v<N+1>) for different framing/pacing.
Capture script template
"""<slug> demo: <story>. Records continuous video into DEMO_WORK; ffmpeg trims after."""
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
BASE = os.environ.get("DEMO_URL", "http://localhost:3000")
WORK = Path(os.environ["DEMO_WORK"]); (WORK / "video").mkdir(parents=True, exist_ok=True)
SCHEME = os.environ.get("DEMO_SCHEME", "light")
HEADED = os.environ.get("DEMO_HEADED") == "1" # default headless, works with no display
W, H = 1360, 900
def make_browser(p):
try: return p.chromium.launch(headless=not HEADED, channel="chrome") # system Chrome
except Exception:
return p.chromium.launch(headless=not HEADED) # bundled chromium
with sync_playwright() as p:
browser = make_browser(p)
ctx = browser.new_context(
viewport={"width": W, "height": H},
record_video_dir=str(WORK / "video"), record_video_size={"width": W, "height": H},
reduced_motion="reduce", color_scheme=SCHEME, # deterministic frames; correct theme
)
page = ctx.new_page()
hold = lambda ms=2500: page.wait_for_timeout(ms)
def shot(n): page.screenshot(path=str(WORK / n), animations="disabled"); print("shot", n, flush=True)
def hide_overlays(): # dev indicators / common banners that pollute frames (extend per app)
page.add_style_tag(content="nextjs-portal,[data-nextjs-toast],#__next-build-watcher{display:none!important}")
try:
# ===== BEATS (customize) =====
page.goto(BASE, wait_until="domcontentloaded")
page.get_by_role("heading").first.wait_for(timeout=30000) # wait on a REAL element, never networkidle
hide_overlays(); hold(3000); shot("01.png")
# BEAT 2, one deliberate action, wait on its result, then hold:
# page.get_by_role("button", name="<label>").click()
# page.get_by_text("<expected>").first.wait_for(timeout=15000); hold(); shot("02.png")
# BEAT 3, below the fold? Smooth-scroll first:
# for _ in range(8): page.mouse.wheel(0, 90); page.wait_for_timeout(55)
# ...; hold(); shot("03.png")
# ===== END BEATS =====
finally:
ctx.close(); browser.close() # ALWAYS close: Playwright only finalizes the .webm here
vids = sorted((WORK / "video").glob("*.webm"), key=lambda f: f.stat().st_mtime)
print("VIDEO:", vids[-1] if vids else "NONE (capture failed before any frame)", flush=True)
A thrown beat still leaves a usable partial video (the finally finalizes it). Inspect it to see which beat failed.
Design notes (calm & followable)
- Open holding on the full view so the viewer reads the starting state.
- One deliberate interaction per beat; waiting on the resulting element keeps timing honest across machines.
- A slow tooltip/hover sweep across a chart or list reads better than toggling things on/off (step
page.mouse.move(...)across an element's bounding box with small waits). - Pick a viewport that fits the important content; scroll to anything below the fold rather than shrinking everything.
- Continuous animation both breaks deterministic screenshot verification and defeats
palettegen's diff mode (bloating the GIF), hencereduced_motion+animations="disabled".
Sizing knobs (in priority order)
Detail-heavy UIs and full-page scrolls inflate GIF size. Turn these down if it's bigger than wanted:
- Duration: tighter trim / shorter holds (biggest lever).
- fps: 16 → 14 → 12 → 10.
- scale: 900 → 800 → 720.
paletteuse=dither=bayer:bayer_scale=4|5;palettegen ...:max_colors=200|128.
Rule of thumb: a ~15s UI walkthrough at fps=14 / scale=800 lands ~2–3 MB. GitHub accepts GIFs ≤10 MB, so don't over-optimize unless asked. gifsicle -O3 --lossy=80 squeezes further if it's installed.
Gotchas
- Never use
networkidle. It never settles on apps with a websocket/SSE/polling (Vite/Next HMR, live dashboards) and times out. Wait on a concrete element instead. - Headless by default. Set
DEMO_HEADED=1only when you need a visible window (e.g. SSO hybrid); on headless Linux, headed needsxvfb-run. - Never install Playwright into a project
.venv. Lockfile managers prune it. Use the~/.cache/claude/demo-gif/venvfrom Step 0; ifimport playwrightfails,rm -rfthat venv and re-run Step 0. - iframes:
page.get_by_*won't reach inside them. Usepage.frame_locator("iframe#…").get_by_role(...)(Storybook canvas, embedded widgets). - Mobile demo: build the context from a device preset (
**p.devices["iPhone 13"]) and matchrecord_video_size, instead of the 1360×900 desktop default. - Stale dev server: if the UI shows old/wrong data, the running server may predate the code under test; restart it before filming.
- SSO logins aren't automatable (see "Do NOT use"): hybrid capture, user signs in, delete the throwaway profile after.
- Keep GIFs out of git:
.demo-gifs/is gitignored by default; don't commit large binaries into history unless the user explicitly asks. - Proxy/offline: if pip or the browser download fails, surface stderr and stop; levers are
HTTPS_PROXY/HTTP_PROXY,PLAYWRIGHT_DOWNLOAD_HOST, orchannel="chrome"to skip the download.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.
Gives 0 of the 12 instructions most readme changelog skills give in ~4.0k tokens
Counted across 472 of the 478 authors here whose files we hold, read 2026-08-07
- Follow the keep a changelog formatin 31 of 472, across 26 files
- Run from the git repository rootin 20 of 472, across 7 files
- Save output directly to CHANGELOG.mdin 20 of 472, across 7 files
- Group commits into logical categoriesin 19 of 472, across 6 files
- Review the generated changelog before publishingin 19 of 472, across 6 files
- Omit empty sectionsin 19 of 472
- Apply changelog guidelines and brand voicein 18 of 472, across 5 files
- Exclude internal commits like refactoring and testsin 18 of 472, across 5 files
- Specify date ranges for focused changelogsin 17 of 472, across 4 files
- Convert technical commits into customer languagein 17 of 472, across 4 files
- Provide a migration guide for breaking changesin 15 of 472, across 10 files
- Group commits by conventional commit typein 13 of 472
Said here and by no other author read
- install playwright in a dedicated persistent venv
- scope demo beats and base URL with the user
- persist run configuration to a fixed file
- source the run configuration in every shell call
- record the capture script as one continuous video
- wait for specific elements after each interaction
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.