A reusable toolkit for recording scripted browser flows as polished WebM videos — with a visible cursor overlay, natural character-by-character typing, and click/typing sounds mixed in via ffmpeg post-processing.
Designed to work as a Cursor Agent Skill or Claude Code skill that scaffolds itself into any web project.
- Visible cursor overlay injected via Playwright's
addInitScript— follows the mouse naturally with smoothpage.mouse.moveanimations - Natural typing — characters typed one by one with human-like random delays (not
fill()paste) - Click & typing sounds — post-processed by ffmpeg from bundled MP3 assets
- Framework-agnostic — works with any web app (Rails, Next.js, Django, etc.)
- Mobile + desktop viewports — record both in a single
record_all.mjsrun
- Node.js ≥ 18
- Playwright (
npm install playwright) - ffmpeg (for audio post-processing)
Clone this repo as a git submodule into your project's skills folder:
git submodule add https://github.com/xguz/playwright-video-recording.git .agents/skills/video-recording
git commit -m "Add playwright-video-recording skill"To update to the latest version later:
git submodule update --remote .agents/skills/video-recording-
Copy templates into your project's
scripts/video_recording/directory:mkdir -p scripts/video_recording cp .agents/skills/video-recording/config.mjs scripts/video_recording/ cp .agents/skills/video-recording/add_click_sounds.mjs scripts/video_recording/
-
Edit
scripts/video_recording/config.mjs:- Set
BASE_URLto your dev server URL (e.g.http://localhost:3000) - Add project-specific helpers (login, seed data, etc.)
- Set
-
Create
scripts/video_recording/record_all.mjslisting your flows:import { execSync } from 'child_process'; const flows = [ { name: 'my-flow', script: 'flow_my_flow.mjs', description: 'Description', viewports: ['mobile', 'desktop'] }, ]; const args = process.argv.slice(2); const onlyFlow = args.find(a => a.startsWith('--flow='))?.split('=')[1]; const onlyViewport = args.includes('--mobile') ? 'mobile' : args.includes('--desktop') ? 'desktop' : null; for (const flow of flows) { if (onlyFlow && flow.name !== onlyFlow) continue; for (const vp of flow.viewports) { if (onlyViewport && vp !== onlyViewport) continue; console.log(`\n▶ ${flow.name} [${vp}]`); execSync(`node scripts/video_recording/${flow.script} ${vp}`, { stdio: 'inherit' }); } }
-
Create flow scripts using
flow_template.mjsas the starting point:cp .agents/skills/video-recording/flow_template.mjs scripts/video_recording/flow_my_flow.mjs
# All flows, all viewports
node scripts/video_recording/record_all.mjs
# Specific flow
node scripts/video_recording/record_all.mjs --flow=my-flow
# Specific viewport
node scripts/video_recording/record_all.mjs --mobile
node scripts/video_recording/record_all.mjs --desktop
# Combine
node scripts/video_recording/record_all.mjs --flow=my-flow --mobile
# Single script directly
node scripts/video_recording/flow_my_flow.mjs mobileOutput lands in videos/<subdir>/:
*-with-audio.webm— final video with sounds*.webm— raw recording (no audio)
| Rule | Reason |
|---|---|
Use naturalFill() instead of locator.fill() |
fill() sets value instantly (looks like paste) |
Use naturalClick() instead of locator.click() |
Moves cursor to element before clicking |
Call injectCursor(page) once per page object |
Sets up the visible cursor overlay for all navigations |
After fetch/Turbo form submits: pause(1500) then page.goto(nextUrl) |
waitForLoadState('networkidle') returns before server commits |
Edit the pause() durations in flow scripts:
await pause(2000); // slower, more dramatic
await pause(500); // faster, quick demoEdit DEVICES in scripts/video_recording/config.mjs:
export const DEVICES = {
mobile: {
viewport: { width: 375, height: 667 }, // iPhone SE
// ...
}
};# Single video
ffmpeg -i input.webm -c:v libx264 -c:a aac output.mp4
# All videos in a directory
for f in videos/my-flow/*.webm; do
ffmpeg -i "$f" -c:v libx264 -c:a aac "${f%.webm}.mp4"
doneTwo MP3 files are bundled in this repo:
computer-mouse-click-352734.mp3— played on each tracked clickkeyboard-typing-asmr-356118.mp3— looped during typing events
Scripts resolve them automatically via a SKILL_DIR constant — no manual path configuration needed.
| File | Purpose |
|---|---|
config.mjs |
Core helpers: cursor, click tracker, natural typing, sound post-processing |
add_click_sounds.mjs |
ffmpeg wrapper — mixes click/typing sounds into a raw .webm |
flow_template.mjs |
Minimal starter template for new flow scripts |
SKILL.md |
Instructions for Cursor Agent (auto-read by the agent when this skill is active) |
"Failed to get login token" / server errors
- Ensure your dev server is running
- Check that any test helper endpoints are enabled in your environment
Videos are blank or corrupt
- Ensure
context.close()is called in thefinallyblock — this is what saves the video - Check that the browser didn't crash during recording
No audio in output
- Make sure ffmpeg is installed (
ffmpeg -version) - Use the
*-with-audio.webmfile, not the raw*.webm
Elements not found
- Add longer
pause()calls if the page loads slowly - Use
page.screenshot({ path: 'error.png', fullPage: true })in thecatchblock to capture what the page looked like when it failed
MIT