| name | recast-guide |
| description | Guide for using playwright-recast to convert Playwright traces into demo videos. Use when the user asks to create product demos, generate videos from tests, add voiceover to traces, or process Playwright recordings. |
playwright-recast — Agent Guide
You help users convert Playwright test traces into polished demo videos using the playwright-recast library.
When to Use
- User asks to create a product demo video from tests
- User wants to add voiceover/narration to a Playwright recording
- User wants to process a Playwright trace into a video
- User mentions "demo video", "product video", "trace to video", "TTS voiceover"
- User has a
trace.zip or test-results/ directory they want to turn into a video
Prerequisites
ffmpeg and ffprobe on PATH
- Playwright trace.zip (from
trace: 'on' in playwright.config.ts)
- Optional: video recording (from
recordVideo in browser context)
- Optional: TTS API key (
OPENAI_API_KEY or ELEVENLABS_API_KEY)
Core API — Fluent Pipeline
playwright-recast uses an immutable, fluent pipeline. Every method returns a new pipeline. Nothing executes until .toFile().
import { Recast, OpenAIProvider } from 'playwright-recast'
await Recast
.from('./test-results/trace.zip')
.parse()
.hideSteps(s => s.hidden)
.speedUp({
duringIdle: 3.0,
duringUserAction: 1.0,
duringNetworkWait: 2.0,
})
.subtitlesFromSrt('./narration.srt')
.voiceover(OpenAIProvider({
voice: 'nova',
speed: 1.2,
}))
.render({
format: 'mp4',
resolution: '1080p',
})
.toFile('demo.mp4')
CLI
npx playwright-recast -i ./test-results -o demo.mp4
npx playwright-recast -i ./traces --srt narration.srt --provider openai --voice nova
npx playwright-recast -i trace.zip --speed-idle 4 --speed-action 1
npx playwright-recast -i ./traces --srt narration.srt --burn-subs
Pipeline Stages
| Method | Purpose |
|---|
.parse() | Parse trace.zip into actions, frames, network, cursor data |
.hideSteps(fn) | Remove steps matching predicate (login, setup) |
.speedUp(config) | Adjust speed by activity type or explicit segments |
.subtitles(textFn) | Generate subtitles from trace actions |
.subtitlesFromSrt(path) | Load external SRT file |
.subtitlesFromTrace() | Auto-generate subtitles/highlights/zoom from narrate()/highlight()/zoom() marker steps; falls back to BDD step titles when no narrate() is present |
.textProcessing(config) | Sanitize subtitle text for TTS (strip quotes, normalize dashes, custom rules) |
.autoZoom(config) | Auto-zoom to user interaction targets from trace |
.enrichZoomFromReport(steps) | Apply zoom coordinates from external report data (legacy — prefer the zoom() helper which writes directly into the trace) |
.cursorOverlay(config) | Animated cursor that travels between click positions (moveDurationMs caps travel time, easing/hideAfterMs control the motion and post-arrival visibility); approachMs holds the frame for click()-marked clicks so the cursor glides over the painted target |
.clickEffect(config) | Visual ripple + optional click sound at click positions (prefers click()/markClick() markers over auto-detected clicks) |
.voiceover(provider) | Generate TTS from subtitle text |
.render(config) | Configure output format/resolution/fps/subtitle styling |
.toFile(path) | Execute pipeline and save output |
Text Processing
Sanitize subtitle text before TTS. Writes to ttsText field — voiceover uses cleaned text, burnt-in subtitles keep original.
.textProcessing({ builtins: true })
.textProcessing({
builtins: true,
rules: [{ pattern: '\\bNSS\\b', flags: 'g', replacement: 'Nejvyšší správní soud' }],
})
.textProcessing({ transform: (text) => text.replace(/\[.*?\]/g, '') })
CLI: --text-processing for built-ins, --text-processing-config <path> for JSON rules file.
Standalone: import { processText } from 'playwright-recast' for use outside the pipeline.
TTS Providers
OpenAI TTS (requires OPENAI_API_KEY):
import { OpenAIProvider } from 'playwright-recast/providers/openai'
OpenAIProvider({ voice: 'nova', speed: 1.2, instructions: 'Professional tone.' })
ElevenLabs (requires ELEVENLABS_API_KEY):
import { ElevenLabsProvider } from 'playwright-recast/providers/elevenlabs'
ElevenLabsProvider({ voiceId: 'onwK4e9ZLuTAKqWW03F9', modelId: 'eleven_multilingual_v2' })
Qwen3-TTS (local, CUDA GPU + Python sidecar):
import { QwenTtsProvider } from 'playwright-recast/providers/qwen'
QwenTtsProvider({
mode: 'clone',
voiceSample: './my-voice.wav',
refText: 'Transcript of the voice sample.',
language: 'English',
cacheAudio: true,
})
QwenTtsProvider({
mode: 'design',
voiceDescription: 'Calm, steady male voice.',
refText: 'Sample line the model will speak in the designed voice.',
language: 'English',
cacheAudio: true,
cacheVoiceDesign: true,
})
Setup — PyTorch + flash-attn is ~5–8 GB, so recommend one shared venv reused across projects pointed at via pythonBin:
python3 -m venv ~/.venvs/playwright-recast
~/.venvs/playwright-recast/bin/pip install -r node_modules/playwright-recast/dist/voiceover/providers/qwen-sidecar/requirements.txt
QwenTtsProvider({
mode: 'clone',
voiceSample: './ref.wav',
refText: 'Sample transcript.',
pythonBin: `${process.env.HOME}/.venvs/playwright-recast/bin/python3`,
})
Pass the venv's bin/python3 as an absolute path so no shell activation is needed.
Needs a CUDA GPU (~4–8 GB VRAM) and HF_TOKEN if the chosen weights are gated. Failures surface as QwenSidecarError with a stage field (init / design / clone).
playwright-bdd Integration
Step helpers for BDD test definitions. Each marker helper (narrate, highlight, zoom, click/markClick) writes a marker-prefixed test.step() directly into the trace zip — subtitlesFromTrace() picks them all up automatically. typeText() performs real, naturally paced keyboard input so every intermediate value appears in the recording. No separate report.json or extra pipeline calls required.
import { setupRecast, narrate, highlight, zoom, pace, typeText, click, waitForNarration } from 'playwright-recast'
setupRecast(test)
Given('the user opens dashboard', async ({ page }, docString?: string) => {
await narrate(docString)
await page.goto('/dashboard')
await pace(page, 4000)
})
When('the user reviews KPI', async ({ page }, docString?: string) => {
await narrate(docString, { autoWait: true })
await typeText(page.getByLabel('Search'), 'quarterly revenue')
await highlight(page.locator('h2'), { text: 'Revenue' })
await zoom(page.locator('.kpi-card'), 1.3)
})
Then('the user opens the report', async ({ page }, docString?: string) => {
await narrate(docString)
await click(page.getByRole('link', { name: 'Reports' }))
await waitForNarration()
})
typeText(): prefer this over locator.fill() when text entry should be visible in the demo. It clears the field, then enters each Unicode code point through Playwright with a bounded ±35% variation around the average delay. Configure the suite default with setupRecast({ typingDelayMs }) (default 100ms) or override one call with { delayMs }. No setup, trace marker, or render stage is required.
click() / markClick(): mark a click in the trace. The renderer prefers markers over auto-detected clicks and plays a held cursor approach over the painted target (needs cursorOverlay() and/or clickEffect(); hold = cursorOverlay({ approachMs }), default 500ms). A marker suppresses the matching auto-click; if several markers compete, the nearest eligible marker wins. click() settles → marks → real click (forwards click options; settle = setupRecast({ clickSettleMs }), default 150ms). markClick() writes the marker only — use it when you drive the interaction yourself. Without setupRecast(test), markClick() no-ops and click() falls back to plain locator.click(options).
waitForNarration(): marks a hard boundary so the preceding narration's subtitle window ends there; with voiceover the renderer holds the frame until that line finishes. Resolves instantly at test time (no real-time pause). Use before a click that must not be talked over, or at the end of a scenario. With voiceover you can skip narrateAutoWait/pace() entirely — run the test at full speed and let the per-line freezes fit the audio (even a narrate() immediately followed by waitForNarration() is kept and sized from its audio, not dropped).
Voiceover-driven freezes: when TTS audio is longer than its visual window the renderer holds the current frame until the audio finishes — overlays freeze with it, click sounds shift to match. Click approach-holds extend audio + subtitles the same way, so narration stays in sync. No config required.
Feature file with voiceover text:
Scenario: View analytics
Given the user opens dashboard
"""
Let's open the dashboard to see real-time metrics.
"""
Zoom
Zoom into specific UI areas during steps. Three approaches:
Auto-zoom from trace — detects click/fill targets automatically:
.autoZoom({ actionLevel: 1.5 })
From report data — manual viewport-relative coordinates per subtitle:
.enrichZoomFromReport([
{ zoom: null },
{ zoom: { x: 0.5, y: 0.8, level: 1.4 } },
])
From step helpers — capture element bounding box during the test (writes marker into the trace; picked up automatically by .subtitlesFromTrace()):
import { zoom } from 'playwright-recast'
await zoom(page.locator('.sidebar'), 1.3)
Zoom starts at the zoom() call site (not at the parent narration's start) and runs until the end of the surrounding subtitle.
Coordinates: x and y are viewport fractions (0.0–1.0), level is zoom factor (1.0 = none, 2.0 = 2x).
Click Effect
Highlight clicks with animated ripple and optional sound:
.clickEffect({
color: '#3B82F6',
opacity: 0.5,
radius: 30,
duration: 400,
sound: true,
soundVolume: 0.8,
filter: (a) => a.method === 'click',
})
Detects click and selectOption actions with cursor coordinates. Timestamps auto-remapped through speed processing.
CLI: --click-effect, --click-effect-config <path>, --click-sound <path>.
Styled Subtitle Burn-in
Burn configurable subtitles into the video via ASS format:
.render({
burnSubtitles: true,
fps: 60,
subtitleStyle: {
fontSize: 48,
primaryColor: '#1a1a1a',
backgroundColor: '#FFFFFF',
backgroundOpacity: 0.75,
padding: 20,
bold: true,
position: 'bottom',
marginVertical: 50,
marginHorizontal: 100,
chunkOptions: { maxCharsPerLine: 55 },
},
})
Without subtitleStyle, burnSubtitles: true uses default ffmpeg SRT rendering.
Voiceover-Driven Speed
For perfect audio-video sync, pre-generate TTS, measure real durations, and compute per-step video speed:
.speedUp({
segments: [
{ startMs: 0, endMs: 7000, speed: 1.5 },
{ startMs: 7000, endMs: 17000, speed: 1.0 },
{ startMs: 17000, endMs: 430000, speed: 60 },
],
})
Common Patterns
Generate video from existing test run
npx playwright test --trace on
npx playwright-recast -i ./test-results -o demo.mp4 --srt narration.srt --provider openai --voice nova
Hide login/setup from video
.hideSteps(s => s.keyword === 'Given' && s.text?.includes('logged in'))
Multi-language versions from one trace
const base = Recast.from('./traces').parse().speedUp({ duringIdle: 3.0 })
await base.subtitlesFromSrt('./en.srt').voiceover(openai).render().toFile('demo-en.mp4')
await base.subtitlesFromSrt('./cs.srt').voiceover(openai).render().toFile('demo-cs.mp4')