Playwright Video Recording
Record browser interactions as video using Playwright.
How to use it
Claude Code
- Run the line below. It pulls the whole folder into
~/.claude/skills/playwright-recording. - Describe your job in plain words. Claude Code follows the skill from there.
npx degit digitalsamba/claude-code-video-toolkit/.claude/skills/playwright-recording#main ~/.claude/skills/playwright-recordingFor one project only, change the path to .claude/skills/playwright-recording.
Claude (web or desktop app)
- On this page open ⋯ → Download .md.
- Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
- Pick the file and Save. Claude shows the name and description and runs a security scan.
- Check the skill is switched on.
- Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
- ChatGPT: make a Project and paste it into Instructions.
- Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
- Check which app you pasted it into — the steps above name the right one.
- Some skills need the paid tier of Claude or ChatGPT.
Paste into Claude, ChatGPT or Cursor.
Source of Playwright Video Recording
Show the full text470 lines
| name | description |
|---|---|
| playwright-recording | Record browser interactions as video using Playwright. Use for capturing demo videos, app walkthroughs, and UI flows for Remotion videos. Triggers include recording a demo, capturing browser video, screen recording a website, or creating walkthrough footage. |
Playwright Video Recording
Playwright can record browser interactions as video - perfect for demo footage in Remotion compositions.
Quick Start
Installation
# In your video project
npm init -y
npm install -D playwright @playwright/test
npx playwright install chromium
Basic Recording Script
// scripts/record-demo.ts
import { chromium } from 'playwright';
async function recordDemo() {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: {
dir: './recordings',
size: { width: 1920, height: 1080 }
}
});
const page = await context.newPage();
// Your recording actions
await page.goto('https://example.com');
await page.waitForTimeout(2000);
await page.click('button.demo');
await page.waitForTimeout(3000);
// Close to save video
await context.close();
await browser.close();
console.log('Recording saved to ./recordings/');
}
recordDemo();
Run with:
npx ts-node scripts/record-demo.ts
# or
npx tsx scripts/record-demo.ts
Recording Configuration
Viewport Sizes
// Standard 1080p (recommended for Remotion)
viewport: { width: 1920, height: 1080 }
// 720p (smaller files)
viewport: { width: 1280, height: 720 }
// Square (social media)
viewport: { width: 1080, height: 1080 }
// Mobile
viewport: { width: 390, height: 844 } // iPhone 14
Video Quality Settings
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: {
dir: './recordings',
size: { width: 1920, height: 1080 } // Match viewport for crisp output
},
// Slow down for visibility
// Note: slowMo is on browser launch, not context
});
// For slow motion, launch browser with slowMo
const browser = await chromium.launch({
slowMo: 100 // 100ms delay between actions
});
Recording Patterns
Form Submission Demo
import { chromium } from 'playwright';
async function recordFormDemo() {
const browser = await chromium.launch({ slowMo: 50 });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }
});
const page = await context.newPage();
await page.goto('https://myapp.com/form');
await page.waitForTimeout(1000);
// Type with realistic speed
await page.fill('#name', 'John Smith', { timeout: 5000 });
await page.waitForTimeout(500);
await page.fill('#email', '[email protected]');
await page.waitForTimeout(500);
// Click submit
await page.click('button[type="submit"]');
// Wait for result
await page.waitForSelector('.success-message');
await page.waitForTimeout(2000);
await context.close();
await browser.close();
}
Multi-Page Navigation
async function recordNavDemo() {
const browser = await chromium.launch({ slowMo: 100 });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }
});
const page = await context.newPage();
// Page 1
await page.goto('https://myapp.com');
await page.waitForTimeout(2000);
// Navigate to page 2
await page.click('nav a[href="/features"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
// Navigate to page 3
await page.click('nav a[href="/pricing"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(2000);
await context.close();
await browser.close();
}
Scroll Demo
async function recordScrollDemo() {
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }
});
const page = await context.newPage();
await page.goto('https://myapp.com/long-page');
await page.waitForTimeout(1000);
// Smooth scroll
await page.evaluate(async () => {
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < 10; i++) {
window.scrollBy({ top: 200, behavior: 'smooth' });
await delay(300);
}
});
await page.waitForTimeout(1000);
await context.close();
await browser.close();
}
Login Flow
async function recordLoginDemo() {
const browser = await chromium.launch({ slowMo: 75 });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }
});
const page = await context.newPage();
await page.goto('https://myapp.com/login');
await page.waitForTimeout(1000);
await page.fill('#email', '[email protected]');
await page.waitForTimeout(300);
await page.fill('#password', '••••••••');
await page.waitForTimeout(500);
await page.click('button[type="submit"]');
// Wait for dashboard
await page.waitForURL('**/dashboard');
await page.waitForTimeout(3000);
await context.close();
await browser.close();
}
Cursor Highlighting
Playwright doesn't show cursor by default. Add visual indicators:
CSS Cursor Highlight
// Inject cursor visualization
await page.addStyleTag({
content: `
* { cursor: none !important; }
.playwright-cursor {
position: fixed;
width: 24px;
height: 24px;
background: rgba(255, 100, 100, 0.5);
border: 2px solid rgba(255, 50, 50, 0.8);
border-radius: 50%;
pointer-events: none;
z-index: 999999;
transform: translate(-50%, -50%);
transition: transform 0.1s ease;
}
.playwright-cursor.clicking {
transform: translate(-50%, -50%) scale(0.8);
background: rgba(255, 50, 50, 0.8);
}
`
});
// Add cursor element
await page.evaluate(() => {
const cursor = document.createElement('div');
cursor.className = 'playwright-cursor';
document.body.appendChild(cursor);
document.addEventListener('mousemove', (e) => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
});
document.addEventListener('mousedown', () => cursor.classList.add('clicking'));
document.addEventListener('mouseup', () => cursor.classList.remove('clicking'));
});
Click Ripple Effect
// Add click ripple visualization
await page.addStyleTag({
content: `
.click-ripple {
position: fixed;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(234, 88, 12, 0.4);
pointer-events: none;
z-index: 999998;
transform: translate(-50%, -50%) scale(0);
animation: ripple 0.4s ease-out forwards;
}
@keyframes ripple {
to {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}
`
});
// Custom click function with ripple
async function clickWithRipple(page, selector) {
const element = await page.locator(selector);
const box = await element.boundingBox();
await page.evaluate(({ x, y }) => {
const ripple = document.createElement('div');
ripple.className = 'click-ripple';
ripple.style.left = x + 'px';
ripple.style.top = y + 'px';
document.body.appendChild(ripple);
setTimeout(() => ripple.remove(), 400);
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
await element.click();
}
Output for Remotion
Move Recording to public/demos/
import { chromium } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
async function recordForRemotion(outputName: string) {
const browser = await chromium.launch({ slowMo: 50 });
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
recordVideo: { dir: './temp-recordings', size: { width: 1920, height: 1080 } }
});
const page = await context.newPage();
// ... recording actions ...
await context.close();
// Get the video path
const video = page.video();
const videoPath = await video?.path();
if (videoPath) {
const destPath = `./public/demos/${outputName}.webm`;
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.renameSync(videoPath, destPath);
console.log(`Recording saved to: ${destPath}`);
// Get duration for config
// Use ffprobe: ffprobe -v error -show_entries format=duration -of csv=p=0 file.webm
}
await browser.close();
}
Convert WebM to MP4
Playwright outputs WebM. Convert for better Remotion compatibility:
ffmpeg -i recording.webm -c:v libx264 -crf 20 -preset medium -movflags faststart public/demos/demo.mp4
Interactive Recording
For user-driven recordings where you manually perform actions:
// Inject ESC key listener to stop recording
async function injectStopListener(page: Page): Promise<void> {
await page.evaluate(() => {
if ((window as any).__escListenerAdded) return;
(window as any).__escListenerAdded = true;
(window as any).__stopRecording = false;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
(window as any).__stopRecording = true;
}
});
});
}
// Poll for stop signal - handle navigation errors gracefully
while (!stopped) {
try {
const shouldStop = await page.evaluate(() => (window as any).__stopRecording === true);
if (shouldStop) break;
} catch {
// Page navigating - continue recording
}
await new Promise(r => setTimeout(r, 200));
}
Key insight: page.evaluate() throws during navigation. Use try/catch and continue - don't treat errors as stop signals.
Window Scaling for Laptops
Record at full 1080p while showing a smaller window:
const scale = 0.75; // 75% window size
const context = await browser.newContext({
viewport: { width: 1920 * scale, height: 1080 * scale },
deviceScaleFactor: 1 / scale,
recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } },
});
Cookie Banner Dismissal
Comprehensive selector list for common consent platforms:
const COOKIE_SELECTORS = [
'#onetrust-accept-btn-handler', // OneTrust
'#CybotCookiebotDialogBodyButtonAccept', // Cookiebot
'.cc-btn.cc-dismiss', // Cookie Consent by Insites
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
'button:has-text("Accept all")',
'button:has-text("Accept cookies")',
'button:has-text("Got it")',
];
async function dismissCookieBanners(page: Page): Promise<void> {
await page.waitForTimeout(500);
for (const selector of COOKIE_SELECTORS) {
try {
const btn = page.locator(selector).first();
if (await btn.isVisible({ timeout: 100 })) {
await btn.click({ timeout: 500 });
return;
}
} catch { /* try next */ }
}
}
Call after page.goto() and on page.on('load') for navigation.
Important: Injected Elements Appear in Video
Warning: Any DOM elements you inject (cursors, control panels, overlays) will be recorded. For UI-free recordings, use terminal-based controls only (Ctrl+C, max duration timer).
Tips for Good Demo Recordings
- Use slowMo - 50-100ms makes actions visible
- Add waitForTimeout - Pause between actions for comprehension
- Wait for animations - Use
waitForLoadState('networkidle') - Match Remotion dimensions - 1920x1080 at 30fps typical
- Test without recording first - Debug before final capture
- Clear browser state - Use fresh context for clean demos
- Dismiss cookie banners - Use comprehensive selector list above
- Re-inject on navigation - Cursor/listeners reset on page load
Feedback & Contributions
If this skill is missing information or could be improved:
- Missing a pattern? Describe what you needed
- Found an error? Let me know what's wrong
- Want to contribute? I can help you:
- Update this skill with improvements
- Create a PR to github.com/digitalsamba/claude-code-video-toolkit
Just say "improve this skill" and I'll guide you through updating .claude/skills/playwright-recording/SKILL.md.
| 1 | |
| 2 | name playwright-recording |
| 3 | description Record browser interactions as video using Playwright. Use for capturing demo videos, app walkthroughs, and UI flows for Remotion videos. Triggers include recording a demo, capturing browser video, screen recording a website, or creating walkthrough footage. |
| 4 | |
| 5 | |
| 6 | # Playwright Video Recording |
| 7 | |
| 8 | Playwright can record browser interactions as video - perfect for demo footage in Remotion compositions. |
| 9 | |
| 10 | ## Quick Start |
| 11 | |
| 12 | ### Installation |
| 13 | |
| 14 | |
| 15 | # In your video project |
| 16 | npm init -y |
| 17 | npm install -D playwright @playwright/test |
| 18 | npx playwright install chromium |
| 19 | |
| 20 | |
| 21 | ### Basic Recording Script |
| 22 | |
| 23 | |
| 24 | // scripts/record-demo.ts |
| 25 | import { chromium } from 'playwright'; |
| 26 | |
| 27 | async function recordDemo() { |
| 28 | const browser = await chromium.launch(); |
| 29 | const context = await browser.newContext({ |
| 30 | viewport: { width: 1920, height: 1080 }, |
| 31 | recordVideo: { |
| 32 | dir: './recordings', |
| 33 | size: { width: 1920, height: 1080 } |
| 34 | } |
| 35 | }); |
| 36 | |
| 37 | const page = await context.newPage(); |
| 38 | |
| 39 | // Your recording actions |
| 40 | await page.goto('https://example.com'); |
| 41 | await page.waitForTimeout(2000); |
| 42 | await page.click('button.demo'); |
| 43 | await page.waitForTimeout(3000); |
| 44 | |
| 45 | // Close to save video |
| 46 | await context.close(); |
| 47 | await browser.close(); |
| 48 | |
| 49 | console.log('Recording saved to ./recordings/'); |
| 50 | } |
| 51 | |
| 52 | recordDemo(); |
| 53 | |
| 54 | |
| 55 | Run with: |
| 56 | |
| 57 | npx ts-node scripts/record-demo.ts |
| 58 | # or |
| 59 | npx tsx scripts/record-demo.ts |
| 60 | |
| 61 | |
| 62 | ## Recording Configuration |
| 63 | |
| 64 | ### Viewport Sizes |
| 65 | |
| 66 | |
| 67 | // Standard 1080p (recommended for Remotion) |
| 68 | viewport: { width: 1920, height: 1080 } |
| 69 | |
| 70 | // 720p (smaller files) |
| 71 | viewport: { width: 1280, height: 720 } |
| 72 | |
| 73 | // Square (social media) |
| 74 | viewport: { width: 1080, height: 1080 } |
| 75 | |
| 76 | // Mobile |
| 77 | viewport: { width: 390, height: 844 } // iPhone 14 |
| 78 | |
| 79 | |
| 80 | ### Video Quality Settings |
| 81 | |
| 82 | |
| 83 | const context = await browser.newContext({ |
| 84 | viewport: { width: 1920, height: 1080 }, |
| 85 | recordVideo: { |
| 86 | dir: './recordings', |
| 87 | size: { width: 1920, height: 1080 } // Match viewport for crisp output |
| 88 | }, |
| 89 | // Slow down for visibility |
| 90 | // Note: slowMo is on browser launch, not context |
| 91 | }); |
| 92 | |
| 93 | // For slow motion, launch browser with slowMo |
| 94 | const browser = await chromium.launch({ |
| 95 | slowMo: 100 // 100ms delay between actions |
| 96 | }); |
| 97 | |
| 98 | |
| 99 | ## Recording Patterns |
| 100 | |
| 101 | ### Form Submission Demo |
| 102 | |
| 103 | |
| 104 | import { chromium } from 'playwright'; |
| 105 | |
| 106 | async function recordFormDemo() { |
| 107 | const browser = await chromium.launch({ slowMo: 50 }); |
| 108 | const context = await browser.newContext({ |
| 109 | viewport: { width: 1920, height: 1080 }, |
| 110 | recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } } |
| 111 | }); |
| 112 | const page = await context.newPage(); |
| 113 | |
| 114 | await page.goto('https://myapp.com/form'); |
| 115 | await page.waitForTimeout(1000); |
| 116 | |
| 117 | // Type with realistic speed |
| 118 | await page.fill('#name', 'John Smith', { timeout: 5000 }); |
| 119 | await page.waitForTimeout(500); |
| 120 | |
| 121 | await page.fill('#email', '[email protected]'); |
| 122 | await page.waitForTimeout(500); |
| 123 | |
| 124 | // Click submit |
| 125 | await page.click('button[type="submit"]'); |
| 126 | |
| 127 | // Wait for result |
| 128 | await page.waitForSelector('.success-message'); |
| 129 | await page.waitForTimeout(2000); |
| 130 | |
| 131 | await context.close(); |
| 132 | await browser.close(); |
| 133 | } |
| 134 | |
| 135 | |
| 136 | ### Multi-Page Navigation |
| 137 | |
| 138 | |
| 139 | async function recordNavDemo() { |
| 140 | const browser = await chromium.launch({ slowMo: 100 }); |
| 141 | const context = await browser.newContext({ |
| 142 | viewport: { width: 1920, height: 1080 }, |
| 143 | recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } } |
| 144 | }); |
| 145 | const page = await context.newPage(); |
| 146 | |
| 147 | // Page 1 |
| 148 | await page.goto('https://myapp.com'); |
| 149 | await page.waitForTimeout(2000); |
| 150 | |
| 151 | // Navigate to page 2 |
| 152 | await page.click('nav a[href="/features"]'); |
| 153 | await page.waitForLoadState('networkidle'); |
| 154 | await page.waitForTimeout(2000); |
| 155 | |
| 156 | // Navigate to page 3 |
| 157 | await page.click('nav a[href="/pricing"]'); |
| 158 | await page.waitForLoadState('networkidle'); |
| 159 | await page.waitForTimeout(2000); |
| 160 | |
| 161 | await context.close(); |
| 162 | await browser.close(); |
| 163 | } |
| 164 | |
| 165 | |
| 166 | ### Scroll Demo |
| 167 | |
| 168 | |
| 169 | async function recordScrollDemo() { |
| 170 | const browser = await chromium.launch(); |
| 171 | const context = await browser.newContext({ |
| 172 | viewport: { width: 1920, height: 1080 }, |
| 173 | recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } } |
| 174 | }); |
| 175 | const page = await context.newPage(); |
| 176 | |
| 177 | await page.goto('https://myapp.com/long-page'); |
| 178 | await page.waitForTimeout(1000); |
| 179 | |
| 180 | // Smooth scroll |
| 181 | await page.evaluate(async () => { |
| 182 | const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); |
| 183 | for (let i = 0; i < 10; i++) { |
| 184 | window.scrollBy({ top: 200, behavior: 'smooth' }); |
| 185 | await delay(300); |
| 186 | } |
| 187 | }); |
| 188 | |
| 189 | await page.waitForTimeout(1000); |
| 190 | await context.close(); |
| 191 | await browser.close(); |
| 192 | } |
| 193 | |
| 194 | |
| 195 | ### Login Flow |
| 196 | |
| 197 | |
| 198 | async function recordLoginDemo() { |
| 199 | const browser = await chromium.launch({ slowMo: 75 }); |
| 200 | const context = await browser.newContext({ |
| 201 | viewport: { width: 1920, height: 1080 }, |
| 202 | recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } } |
| 203 | }); |
| 204 | const page = await context.newPage(); |
| 205 | |
| 206 | await page.goto('https://myapp.com/login'); |
| 207 | await page.waitForTimeout(1000); |
| 208 | |
| 209 | await page.fill('#email', '[email protected]'); |
| 210 | await page.waitForTimeout(300); |
| 211 | |
| 212 | await page.fill('#password', '••••••••'); |
| 213 | await page.waitForTimeout(500); |
| 214 | |
| 215 | await page.click('button[type="submit"]'); |
| 216 | |
| 217 | // Wait for dashboard |
| 218 | await page.waitForURL('**/dashboard'); |
| 219 | await page.waitForTimeout(3000); |
| 220 | |
| 221 | await context.close(); |
| 222 | await browser.close(); |
| 223 | } |
| 224 | |
| 225 | |
| 226 | ## Cursor Highlighting |
| 227 | |
| 228 | Playwright doesn't show cursor by default. Add visual indicators: |
| 229 | |
| 230 | ### CSS Cursor Highlight |
| 231 | |
| 232 | |
| 233 | // Inject cursor visualization |
| 234 | await page.addStyleTag({ |
| 235 | content: ` |
| 236 | * { cursor: none !important; } |
| 237 | .playwright-cursor { |
| 238 | position: fixed; |
| 239 | width: 24px; |
| 240 | height: 24px; |
| 241 | background: rgba(255, 100, 100, 0.5); |
| 242 | border: 2px solid rgba(255, 50, 50, 0.8); |
| 243 | border-radius: 50%; |
| 244 | pointer-events: none; |
| 245 | z-index: 999999; |
| 246 | transform: translate(-50%, -50%); |
| 247 | transition: transform 0.1s ease; |
| 248 | } |
| 249 | .playwright-cursor.clicking { |
| 250 | transform: translate(-50%, -50%) scale(0.8); |
| 251 | background: rgba(255, 50, 50, 0.8); |
| 252 | } |
| 253 | ` |
| 254 | }); |
| 255 | |
| 256 | // Add cursor element |
| 257 | await page.evaluate(() => { |
| 258 | const cursor = document.createElement('div'); |
| 259 | cursor.className = 'playwright-cursor'; |
| 260 | document.body.appendChild(cursor); |
| 261 | |
| 262 | document.addEventListener('mousemove', (e) => { |
| 263 | cursor.style.left = e.clientX + 'px'; |
| 264 | cursor.style.top = e.clientY + 'px'; |
| 265 | }); |
| 266 | |
| 267 | document.addEventListener('mousedown', () => cursor.classList.add('clicking')); |
| 268 | document.addEventListener('mouseup', () => cursor.classList.remove('clicking')); |
| 269 | }); |
| 270 | |
| 271 | |
| 272 | ### Click Ripple Effect |
| 273 | |
| 274 | |
| 275 | // Add click ripple visualization |
| 276 | await page.addStyleTag({ |
| 277 | content: ` |
| 278 | .click-ripple { |
| 279 | position: fixed; |
| 280 | width: 40px; |
| 281 | height: 40px; |
| 282 | border-radius: 50%; |
| 283 | background: rgba(234, 88, 12, 0.4); |
| 284 | pointer-events: none; |
| 285 | z-index: 999998; |
| 286 | transform: translate(-50%, -50%) scale(0); |
| 287 | animation: ripple 0.4s ease-out forwards; |
| 288 | } |
| 289 | @keyframes ripple { |
| 290 | to { |
| 291 | transform: translate(-50%, -50%) scale(2); |
| 292 | opacity: 0; |
| 293 | } |
| 294 | } |
| 295 | ` |
| 296 | }); |
| 297 | |
| 298 | // Custom click function with ripple |
| 299 | async function clickWithRipple(page, selector) { |
| 300 | const element = await page.locator(selector); |
| 301 | const box = await element.boundingBox(); |
| 302 | |
| 303 | await page.evaluate(({ x, y }) => { |
| 304 | const ripple = document.createElement('div'); |
| 305 | ripple.className = 'click-ripple'; |
| 306 | ripple.style.left = x + 'px'; |
| 307 | ripple.style.top = y + 'px'; |
| 308 | document.body.appendChild(ripple); |
| 309 | setTimeout(() => ripple.remove(), 400); |
| 310 | }, { x: box.x + box.width / 2, y: box.y + box.height / 2 }); |
| 311 | |
| 312 | await element.click(); |
| 313 | } |
| 314 | |
| 315 | |
| 316 | ## Output for Remotion |
| 317 | |
| 318 | ### Move Recording to public/demos/ |
| 319 | |
| 320 | |
| 321 | import { chromium } from 'playwright'; |
| 322 | import * as fs from 'fs'; |
| 323 | import * as path from 'path'; |
| 324 | |
| 325 | async function recordForRemotion(outputName: string) { |
| 326 | const browser = await chromium.launch({ slowMo: 50 }); |
| 327 | const context = await browser.newContext({ |
| 328 | viewport: { width: 1920, height: 1080 }, |
| 329 | recordVideo: { dir: './temp-recordings', size: { width: 1920, height: 1080 } } |
| 330 | }); |
| 331 | const page = await context.newPage(); |
| 332 | |
| 333 | // ... recording actions ... |
| 334 | |
| 335 | await context.close(); |
| 336 | |
| 337 | // Get the video path |
| 338 | const video = page.video(); |
| 339 | const videoPath = await video?.path(); |
| 340 | |
| 341 | if (videoPath) { |
| 342 | const destPath = `./public/demos/${outputName}.webm`; |
| 343 | fs.mkdirSync(path.dirname(destPath), { recursive: true }); |
| 344 | fs.renameSync(videoPath, destPath); |
| 345 | console.log(`Recording saved to: ${destPath}`); |
| 346 | |
| 347 | // Get duration for config |
| 348 | // Use ffprobe: ffprobe -v error -show_entries format=duration -of csv=p=0 file.webm |
| 349 | } |
| 350 | |
| 351 | await browser.close(); |
| 352 | } |
| 353 | |
| 354 | |
| 355 | ### Convert WebM to MP4 |
| 356 | |
| 357 | Playwright outputs WebM. Convert for better Remotion compatibility: |
| 358 | |
| 359 | |
| 360 | ffmpeg -i recording.webm -c:v libx264 -crf 20 -preset medium -movflags faststart public/demos/demo.mp4 |
| 361 | |
| 362 | |
| 363 | ## Interactive Recording |
| 364 | |
| 365 | For user-driven recordings where you manually perform actions: |
| 366 | |
| 367 | |
| 368 | // Inject ESC key listener to stop recording |
| 369 | async function injectStopListener(page: Page): Promise<void> { |
| 370 | await page.evaluate(() => { |
| 371 | if ((window as any).__escListenerAdded) return; |
| 372 | (window as any).__escListenerAdded = true; |
| 373 | (window as any).__stopRecording = false; |
| 374 | document.addEventListener('keydown', (e) => { |
| 375 | if (e.key === 'Escape') { |
| 376 | e.preventDefault(); |
| 377 | (window as any).__stopRecording = true; |
| 378 | } |
| 379 | }); |
| 380 | }); |
| 381 | } |
| 382 | |
| 383 | // Poll for stop signal - handle navigation errors gracefully |
| 384 | while (!stopped) { |
| 385 | try { |
| 386 | const shouldStop = await page.evaluate(() => (window as any).__stopRecording === true); |
| 387 | if (shouldStop) break; |
| 388 | } catch { |
| 389 | // Page navigating - continue recording |
| 390 | } |
| 391 | await new Promise(r => setTimeout(r, 200)); |
| 392 | } |
| 393 | |
| 394 | |
| 395 | **Key insight:** `page.evaluate()` throws during navigation. Use try/catch and continue - don't treat errors as stop signals. |
| 396 | |
| 397 | ## Window Scaling for Laptops |
| 398 | |
| 399 | Record at full 1080p while showing a smaller window: |
| 400 | |
| 401 | |
| 402 | const scale = 0.75; // 75% window size |
| 403 | const context = await browser.newContext({ |
| 404 | viewport: { width: 1920 * scale, height: 1080 * scale }, |
| 405 | deviceScaleFactor: 1 / scale, |
| 406 | recordVideo: { dir: './recordings', size: { width: 1920, height: 1080 } }, |
| 407 | }); |
| 408 | |
| 409 | |
| 410 | ## Cookie Banner Dismissal |
| 411 | |
| 412 | Comprehensive selector list for common consent platforms: |
| 413 | |
| 414 | |
| 415 | const COOKIE_SELECTORS = [ |
| 416 | '#onetrust-accept-btn-handler', // OneTrust |
| 417 | '#CybotCookiebotDialogBodyButtonAccept', // Cookiebot |
| 418 | '.cc-btn.cc-dismiss', // Cookie Consent by Insites |
| 419 | '[class*="cookie"] button[class*="accept"]', |
| 420 | '[class*="consent"] button[class*="accept"]', |
| 421 | 'button:has-text("Accept all")', |
| 422 | 'button:has-text("Accept cookies")', |
| 423 | 'button:has-text("Got it")', |
| 424 | ]; |
| 425 | |
| 426 | async function dismissCookieBanners(page: Page): Promise<void> { |
| 427 | await page.waitForTimeout(500); |
| 428 | for (const selector of COOKIE_SELECTORS) { |
| 429 | try { |
| 430 | const btn = page.locator(selector).first(); |
| 431 | if (await btn.isVisible({ timeout: 100 })) { |
| 432 | await btn.click({ timeout: 500 }); |
| 433 | return; |
| 434 | } |
| 435 | } catch { /* try next */ } |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | |
| 440 | Call after `page.goto()` and on `page.on('load')` for navigation. |
| 441 | |
| 442 | ## Important: Injected Elements Appear in Video |
| 443 | |
| 444 | **Warning:** Any DOM elements you inject (cursors, control panels, overlays) will be recorded. For UI-free recordings, use terminal-based controls only (Ctrl+C, max duration timer). |
| 445 | |
| 446 | ## Tips for Good Demo Recordings |
| 447 | |
| 448 | **Use slowMo** - 50-100ms makes actions visible |
| 449 | **Add waitForTimeout** - Pause between actions for comprehension |
| 450 | **Wait for animations** - Use `waitForLoadState('networkidle')` |
| 451 | **Match Remotion dimensions** - 1920x1080 at 30fps typical |
| 452 | **Test without recording first** - Debug before final capture |
| 453 | **Clear browser state** - Use fresh context for clean demos |
| 454 | **Dismiss cookie banners** - Use comprehensive selector list above |
| 455 | **Re-inject on navigation** - Cursor/listeners reset on page load |
| 456 | |
| 457 | |
| 458 | |
| 459 | ## Feedback & Contributions |
| 460 | |
| 461 | If this skill is missing information or could be improved: |
| 462 | |
| 463 | **Missing a pattern?** Describe what you needed |
| 464 | **Found an error?** Let me know what's wrong |
| 465 | **Want to contribute?** I can help you: |
| 466 | Update this skill with improvements |
| 467 | Create a PR to github.com/digitalsamba/claude-code-video-toolkit |
| 468 | |
| 469 | Just say "improve this skill" and I'll guide you through updating `.claude/skills/playwright-recording/SKILL.md`. |
| 470 |
Discussion
Browse more free Claude skills or everything in Content creator.
