| name | video-recording |
|---|---|
| description | Records browser usage flow videos using Playwright Firefox with natural typing, a visible cursor overlay, and click/typing sounds mixed in via ffmpeg. Includes scaffolding for new projects. Use when the user asks to record a video, run a flow script, create a new flow, set up video recording in a project, or debug a recording issue. |
Records scripted browser flows as WebM videos with a visible cursor, natural character-by-character typing, and click/typing sounds post-processed via ffmpeg.
To install this skill in another project:
git clone https://github.com/xguz/playwright-video-recording .agents/skills/video-recordingTwo MP3 files are bundled in this skill folder:
computer-mouse-click-352734.mp3— played on each tracked clickkeyboard-typing-asmr-356118.mp3— looped during typing events
Scripts resolve them via a SKILL_DIR constant (see config.mjs).
When setting up video recording in a project for the first time:
- Copy
config.mjsandadd_click_sounds.mjsfrom this skill folder intoscripts/video_recording/ - In the copied
config.mjs, setBASE_URLto the app's local dev server URL and add any project-specific test/setup helpers - Create
scripts/video_recording/record_all.mjslisting all flows (see structure in this project) - Create flow scripts using
flow_template.mjsas the starter (copy and fill in the steps)
# All flows, all viewports
node scripts/video_recording/record_all.mjs
# Specific flow and viewport
node scripts/video_recording/record_all.mjs --flow=my-flow --mobile
# Single script directly
node scripts/video_recording/flow_my_flow.mjs mobileOutput: videos/<subdir>/*-with-audio.webm (plain .webm alongside it is the raw recording without audio).
Use flow_template.mjs from this skill folder as the starting point. The canonical structure:
import { firefox } from 'playwright';
import { mkdir, rename } from 'fs/promises';
import { BASE_URL, VIDEO_DIR, DEVICES, cleanup, pause,
injectCursor, createClickTracker, naturalClick,
naturalFill, saveAndAddSound } from './config.mjs';
async function recordFlow(deviceType = 'mobile') {
const device = DEVICES[deviceType];
const videoDir = /* path to output subfolder */;
await mkdir(videoDir, { recursive: true });
const browser = await firefox.launch({ headless: true, slowMo: 80 });
const context = await browser.newContext({ ...device, recordVideo: { dir: videoDir, size: device.viewport } });
const page = await context.newPage();
const tracker = createClickTracker();
let finalVideoPath = null;
try {
await cleanup(page); // clear test data
await injectCursor(page); // visible cursor overlay
// --- scripted steps ---
await page.goto(`${BASE_URL}/some/path`);
await page.waitForLoadState('networkidle');
await naturalFill(page, page.locator('input[name="field"]'), 'value', tracker, 'label');
await naturalClick(page, page.locator('button[type="submit"]'), tracker, 'submit');
// IMPORTANT: after fetch/Turbo form submits, wait before navigating
await pause(1500);
await page.goto(`${BASE_URL}/next/step`);
await page.waitForLoadState('networkidle');
} catch (err) {
console.error(`Error: ${err.message}`);
await page.screenshot({ path: 'error.png', fullPage: true });
} finally {
const tmpPath = page.video() ? await page.video().path() : null;
await context.close();
await browser.close();
if (tmpPath) {
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
finalVideoPath = /* timestamped output path */;
await rename(tmpPath, finalVideoPath).catch(() => { finalVideoPath = tmpPath; });
await saveAndAddSound(finalVideoPath, tracker);
}
}
}
recordFlow(process.argv[2] || 'mobile');Then register the flow in record_all.mjs:
{ name: 'my-flow', script: 'flow_my_flow.mjs', description: '...', viewports: ['mobile', 'desktop'] }- Never use
locator.fill()— it sets the value instantly (looks like paste). Always usenaturalFill()which types character by character. naturalClick(page, locator, tracker, label)— moves the cursor to the element before clicking; always use instead oflocator.click().injectCursor(page)— call once per page object; it auto-reinjects on every navigation viaaddInitScript.- After fetch/Turbo form submits —
waitForLoadState('networkidle')returns before the server commits. Always follow withpause(1500)then an explicitpage.goto(nextUrl). - Email confirmation links — if the mailer uses a different host/port than
BASE_URL, extract the token from the email HTML and rebuild the URL. SeeextractConfirmUrlpattern in this project'sconfig.mjs.
- Generic scripts:
config.mjs,add_click_sounds.mjs,flow_template.mjsin this skill folder - This project's flows:
scripts/video_recording/flow_*.mjs - Orchestrator:
scripts/video_recording/record_all.mjs - Full helper reference:
scripts/video_recording/README.md