/screen-record — Scripted browser-walkthrough videos
When to use
The user wants a polished video of a website doing something — typically for LinkedIn, Twitter, a sales deck, an investor update, or onboarding docs. They describe what should be on screen, what the cursor does, and roughly what the narration should say. You script it; the engine handles cursor animation, TTS via ElevenLabs, music, click SFX, and subtitle burn-in.
Do not use this skill for: real screen recordings of an already-running session (use macOS Screenshot or QuickTime), GIFs (use a GIF tool), or videos that need a real human voiceover (you can still author the script, then have them re-record it).
How it works
The engine is at engine.mjs. It exports runDemo(config) which:
- Generates one TTS clip per cue with ElevenLabs.
- Generates a soft ambient music bed + a UI click SFX with ffmpeg.
- Launches headless Chromium at 1920×1080, records video.
- Runs your
scenes(helpers)function which drives the browser. - Mixes audio (narration loud + music quiet + clicks subtle) and burns ASS-styled subtitles.
- Returns paths to the final
.mp4,.webm,.srt,.ass.
Workflow
Step 1 — read the user's request
Pin down: URL to record, flow (what the cursor should do), tone (formal/casual), length target. If anything is missing, ask one focused question rather than guessing.
Step 2 — check the API key
Look for ELEVENLABS_API_KEY in this order:
$CLAUDE_PLUGIN_ROOT/.env.local(or the skill folder's own.env.local)- The current project's
.env.local process.env
If absent, tell the user:
I need an ElevenLabs API key. Get a free one at https://elevenlabs.io/app/settings/api-keys (10,000 chars/month, enough for ~15 demos). Either paste it in chat and I'll save it to the skill's
.env.local, or add it yourself.
If a key is present but the engine throws ApiKeyDeadError (HTTP 401/402/403), tell the user:
Your ElevenLabs key got rejected (revoked, expired, or out of quota). Rotate it at https://elevenlabs.io/app/settings/api-keys, then update
.env.local. I won't retry until you do.
Do NOT silently retry, do NOT fall back to macOS say unless the user explicitly asks for it.
Step 3 — pick a voice
Default is Liam (TX3LPaxmHKxFdv7VOQHJ) — energetic American male, modern. Lighter than George (British) and Sarah (American female). Other free-tier voices in engine.mjs under FREE_TIER_VOICES. If the user says "use X voice" find that in the catalog. If they say "deep/heavy/light/warm/female" pick accordingly.
Library voices (Charlotte, Rachel, Adam, etc.) require a paid ElevenLabs subscription. If the user asks for one and they're on free tier, you'll get HTTP 402 — fall back to the closest free-tier voice and tell the user.
Step 4 — probe the target page (only if needed)
If you don't know the page's structure, use Playwright or curl to inspect the DOM and find selectors. Don't guess — selectors matter, ambiguous matches will time out the recording.
For an SPA with dynamic content, prefer aria-label, role, or text selectors over class names. Use page.locator(s).first() semantics (the engine's helpers already do this).
Step 5 — write a scene file
Create a file at /tmp/screen-record-<name>.mjs (don't pollute the user's repo) that imports the engine and calls runDemo. Template:
import { runDemo } from '/Users/<you>/Desktop/Projects/skill/screen-record-skills/engine.mjs';
// or, if installed via symlink:
// import { runDemo } from '/Users/<you>/.claude/skills/screen-record/engine.mjs';
await runDemo({
baseUrl: 'http://localhost:3000',
outputDir: './output/onboarding-demo',
outputName: 'onboarding',
splash: {
label: 'Visit us at',
url: 'www.yourapp.com',
footer: 'YourApp — onboarding flow',
},
cues: [
{ id: 'intro', text: 'Welcome to YourApp. Let me show you around.' },
{ id: 'signup', text: 'Tap Sign Up to create an account.' },
{ id: 'form', text: 'Enter your email and a password.' },
{ id: 'done', text: 'And that is it — you are in.' },
],
// Optional: mock backend so the demo is deterministic.
routes: [
{ match: /\/api\/signup$/, fulfill: { status: 200, body: { ok: true } } },
],
// Optional: pre-seed an authed user so the demo skips login.
// initialAuth: { id: 'u1', email: '[email protected]', name: 'Demo User' },
scenes: async ({ page, baseUrl, sleep, speak, awaitCue,
clickAt, typeInto, moveCursorTo, hideSplash, typeSplashUrl,
configSwitch, showSwitch, hideSwitch, setAuth }) => {
// Scene 0: URL splash (engine already navigated to baseUrl off-camera).
speak('intro');
await typeSplashUrl('www.yourapp.com');
await awaitCue('intro');
await hideSplash();
await sleep(750); // matches the splash fade
// Scene 1: click sign up
speak('signup');
await clickAt('button:has-text("Sign Up")');
await awaitCue('signup');
// Scene 2: fill the form
speak('form');
await typeInto('input[type="email"]', '[email protected]');
await typeInto('input[type="password"]', 'CorrectHorseBattery');
await awaitCue('form');
// Scene 3: submit
speak('done');
await clickAt('button[type="submit"]');
await sleep(1000);
await awaitCue('done');
},
});
Step 6 — run it
cd /Users/<you>/Desktop/Projects/skill/screen-record-skills
# First time only:
npm install --silent
# Every time:
node /tmp/screen-record-<name>.mjs
Each run takes about as long as the final video plus ~10s overhead.
Step 7 — report
Tell the user:
- Path to the final
.mp4(LinkedIn-ready, 1920×1080, H.264 + AAC) - Path to
.srt(LinkedIn will auto-import this as closed captions) - Offer to play with
afplay <path>on macOS
Helper reference (what your scenes can call)
| Helper | What it does |
|---|---|
page | Raw Playwright Page if you need it |
baseUrl | The configured base URL |
sleep(ms) | await sleep(800) |
moveCursorTo(x, y, durationMs?) | Smooth move to coords |
moveToSelector(selector, durationMs?, offset?) | Smooth move to element center |
clickAt(selector, durationMs?) | Move + pulse + click (logs click time for SFX) |
typeInto(selector, text, perChar?) | Click + clear + type character-by-character |
recordClick() | Log a click timestamp manually (for SFX) |
speak(cueId) | Mark cue start. End is start + audioDuration + 0.35s |
awaitCue(cueId) | Sleep until the cue's narration finishes |
typeSplashUrl(text, perChar?) | Animated typing into the splash URL bar |
hideSplash() | Fade out the splash (always sleep ~750ms after) |
configSwitch({ fromInitial, fromLabel, toInitial, toLabel, title, sub }) | Set the account-switch card content |
showSwitch() / hideSwitch() | Toggle the account-switch overlay |
setAuth(user) | Set localStorage[authStorageKey] to a new user (paired with showSwitch for account-swap demos) |
Pacing rules of thumb
- One sentence per cue. Keep it under ~12 words.
- For UI actions,
speak(cue)BEFORE the action so narration plays while the cursor moves. - For typing-heavy scenes, the typing takes longer than the narration — use
sleepinstead ofawaitCueto pace. - Total video length is usually
sum(audioDurations) + sum(sleeps). Aim for 30–75s for LinkedIn.
Common selectors that break
text=Fooon its own can match many elements. Usepage.locator(...).first()which is what the engine already does, OR usebutton:has-text("Foo")to scope to a tag.- Tailwind class names are fragile. Prefer
aria-label, role, semantic tag + text. - Modals/dialogs: wait for
[role="dialog"]or the modal's heading text before clicking inside.
What to NOT do
- Don't write actual narration text into the scene file — keep narration in
cues[].textso subtitles and audio stay in sync. - Don't burn the cursor into the page CSS — the engine injects it.
- Don't
await page.screenshot()mid-scene — you'll capture the cursor in odd positions; useextractFramespost-hoc if needed. - Don't commit
.env.local(it's in.gitignore).