Automatically convert Playwright test videos to MP4 or GIF and embed them in your README.
When you record Playwright tests, they're saved as .webm files which:
- May not play in all environments
- Aren't automatically embedded in documentation
- Need manual conversion and README updates
This package automates the entire workflow: convert videos → copy to assets folder → update README with proper tags.
- 🎬 Convert Playwright
.webmvideos to MP4 or GIF - 📝 Automatically update README with video tags
- 🎯 Select specific tests via glob patterns
- 🔧 Fully configurable (format, directories, markers, etc.)
- 📦 Works as CLI tool or programmatic API
- 🚀 Zero config needed for common use cases
npm install --save-dev playwright-readme-videos# My Project
## Demo
<!-- pw-videos:start -->
<!-- pw-videos:end -->playwright testnpx pw-test-videosThat's it! Your README now includes the test video.
# Basic usage (MP4, newest video)
npx pw-test-videos
# Convert to GIF
npx pw-test-videos --format gif --name demo.gif
# Process multiple videos
npx pw-test-videos --max 3
# Custom directories
npx pw-test-videos --from test-results --to assets/videos
# Full example
npx pw-test-videos \
--format mp4 \
--from test-results \
--to docs/videos \
--readme README.md \
--max 1 \
--name demo.mp4import { processTestVideos } from 'playwright-readme-videos';
await processTestVideos({
format: 'mp4',
sourceDir: 'test-results',
outputDir: 'docs/pw-videos',
readmePath: 'README.md',
maxVideos: 1,
outputFilename: 'demo.mp4',
});Add to your package.json:
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:mp4": "playwright test && pw-test-videos",
"test:e2e:gif": "playwright test && pw-test-videos --format gif --name demo.gif"
}
}This repo exposes:
- A composite action (for Marketplace later, and for local path usage today):
action.yml - A reusable workflow:
.github/workflows/ci.yml(triggerable viaworkflow_call) - A CI helper action for running this repo’s tests when consumed as a submodule:
.github/actions/ci/action.yml
In another repo (like vscode-copilot-debugger) with this repo checked out under
external/playwright-test-videos, you can run the submodule tests like this:
- name: Test playwright-test-videos
uses: ./external/playwright-test-videos/.github/actions/ci
with:
working-directory: external/playwright-test-videos
# Note: set cache: 'false' under act / local runners if caching hangs.
cache: 'true'
run: npm testOnce this repo is referenced by a git ref (tag/sha), another repo can call the reusable workflow:
jobs:
pw-test-videos:
uses: dkattan/playwright-test-videos/.github/workflows/ci.yml@v0
with:
run: npm run test:ciFor now (when vendored as a submodule), prefer the local composite action shown above.
The package supports three configuration methods with the following priority: CLI arguments > Environment variables > Config file > Defaults
Create a config file in your project root. The package will auto-discover it:
Supported file names:
pw-test-videos.config.jsor.mjs(JavaScript/ESM)pw-test-videos.config.json(JSON).pw-test-videosrcor.pw-test-videosrc.json(JSON)
JavaScript example:
// pw-test-videos.config.js
export default {
format: 'mp4',
sourceDir: 'test-results',
outputDir: 'docs/pw-videos',
readmePath: 'README.md',
testPattern: '**/*.webm',
maxVideos: 1,
outputFilename: 'demo.mp4',
startMarker: '<!-- pw-videos:start -->',
endMarker: '<!-- pw-videos:end -->',
overwrite: false,
};JSON example:
{
"format": "gif",
"maxVideos": 3,
"outputDir": "assets/videos"
}You can also specify a custom config file:
npx pw-test-videos --config my-config.jsonPrefix all config options with PW_TEST_VIDEOS_:
# Set format to GIF
export PW_TEST_VIDEOS_FORMAT=gif
# Set output directory
export PW_TEST_VIDEOS_OUTPUT_DIR=assets/videos
# Set max videos
export PW_TEST_VIDEOS_MAX_VIDEOS=3
# Run the tool
npx pw-test-videosAvailable environment variables:
PW_TEST_VIDEOS_FORMAT- Video format (mp4 or gif)PW_TEST_VIDEOS_SOURCE_DIR- Source directoryPW_TEST_VIDEOS_OUTPUT_DIR- Output directoryPW_TEST_VIDEOS_README_PATH- README pathPW_TEST_VIDEOS_TEST_PATTERN- Glob patternPW_TEST_VIDEOS_MAX_VIDEOS- Max videosPW_TEST_VIDEOS_OUTPUT_FILENAME- Output filenamePW_TEST_VIDEOS_START_MARKER- Start markerPW_TEST_VIDEOS_END_MARKER- End markerPW_TEST_VIDEOS_OVERWRITE- Overwrite (true/false)PW_TEST_VIDEOS_DEDUPE- De-dupe frames (true/false)PW_TEST_VIDEOS_DEDUPE_START_AFTER_SECONDS- Only start de-duping after N seconds (e.g.0.5)PW_TEST_VIDEOS_DEDUPE_END_PAD_SECONDS- Ensure the final view is visible for at least N seconds (e.g.0.5)PW_TEST_VIDEOS_REMOVE_RANGES- Remove ranges JSON arrayPW_TEST_VIDEOS_SIDECAR_MODE- Sidecar mode (noneoradjacent)PW_TEST_VIDEOS_SIDECAR_SUFFIX- Sidecar suffix (e.g..ranges.json)PW_TEST_VIDEOS_SIDECAR_REQUIRED- Sidecar required (true/false)
CLI arguments override all other configuration sources:
npx pw-test-videos --format gif --max 3 --to assets/videosinterface PlaywrightTestVideosConfig {
/** Video format: 'mp4' or 'gif' (default: 'mp4') */
format?: 'mp4' | 'gif';
/** Source directory with test videos (default: 'test-results') */
sourceDir?: string;
/** Output directory for processed videos (default: 'docs/pw-videos') */
outputDir?: string;
/** README file path (default: 'README.md') */
readmePath?: string;
/** Glob pattern(s) to select videos (default: '**/*.webm') */
testPattern?: string | string[];
/** Max number of videos to include (default: 1) */
maxVideos?: number;
/** Output filename when maxVideos=1 (default: 'demo.mp4' or 'demo.gif') */
outputFilename?: string;
/** Start marker in README (default: '<!-- pw-videos:start -->') */
startMarker?: string;
/** End marker in README (default: '<!-- pw-videos:end -->') */
endMarker?: string;
/** Overwrite existing converted videos (default: false) */
overwrite?: boolean;
/**
* Remove duplicate/near-duplicate frames to shorten long pauses.
*
* Uses ffmpeg's mpdecimate filter during conversion.
* Note: animated spinners prevent de-dupe because frames are no longer identical.
* In that case, prefer `removeRanges` (or sidecar ranges) to cut waiting windows by time.
*/
dedupe?: boolean | {
/** Only start de-duping after this many seconds from the start of the (trimmed) video. Default: 0.5 */
startAfterSeconds?: number;
/** Ensure the final view is visible for at least this many seconds by padding the last frame. Default: 0.5 */
endPadSeconds?: number;
};
/**
* Optional time ranges to remove from the output video (seconds).
* Useful when you can emit timestamps from your tests (e.g. spinner visible/hidden)
* and want to cut waiting sections purely by time.
*/
removeRanges?: Array<{ startSeconds: number; endSeconds: number; keepStartSeconds?: number; keepEndSeconds?: number }>;
/**
* Optional sidecar file mode for per-video ranges.
* - 'none' (default): do not read sidecars.
* - 'adjacent': look for '<videoPath><sidecarSuffix>' next to each input video.
*/
sidecarMode?: 'none' | 'adjacent';
/** Suffix for adjacent sidecar files (default: '.ranges.json') */
sidecarSuffix?: string;
/** If true, fail if an expected adjacent sidecar is missing (default: false) */
sidecarRequired?: boolean;
}Playwright videos often contain long waits (build/startup/network). Those waits usually produce many identical frames that can be safely removed.
There are two complementary strategies:
If your tests (or a post-processor) can emit timestamps for “waiting windows” (e.g. spinner visible → spinner hidden), you can remove those windows using removeRanges.
Programmatic example:
import { convertVideo } from 'playwright-test-videos';
convertVideo('path/to/video.webm', {
format: 'mp4',
overwrite: true,
removeRanges: [
{ startSeconds: 3.1, endSeconds: 8.7, keepStartSeconds: 0.35, keepEndSeconds: 0.35 },
],
});When sidecarMode: 'adjacent', the tool will look for a JSON file next to each input video (default suffix: .ranges.json).
This lets your tests write per-video trimming metadata without having to plumb it through config globals.
You can also enable dedupe: true to collapse long stretches of identical frames using ffmpeg’s mpdecimate.
By default, de-dupe only starts after the first 0.5 seconds so the initial UI state isn’t “collapsed away”. You can customize this with:
dedupe: { startAfterSeconds: 0 }(de-dupe from the beginning)dedupe: { startAfterSeconds: 0.5 }(default)dedupe: { startAfterSeconds: 1.2 }(leave more context)
Also by default, the final UI state is padded (last frame cloned) to be visible for 0.5 seconds:
dedupe: { endPadSeconds: 0 }(no padding; final state may display for only a single frame)dedupe: { endPadSeconds: 0.5 }(default)
If the UI contains an animated spinner, frames won’t be identical, so time-based trimming (above) is usually the better fit.
In this repository, there’s a small end-to-end demo script that records a spinner page, then produces:
unshortened.mp4(baseline)shortened.mp4(spinner window removed via adjacent sidecar ranges)shortened-dedupe.mp4(optional “extra short”: trimming +dedupe: true)
It writes artifacts under:
external/playwright-test-videos/test-artifacts/spinner-demo/**
Run:
npm --prefix external/playwright-test-videos run demo:spinner
If you need Playwright browsers installed:
npm --prefix external/playwright-test-videos run demo:spinner:ci
--format <mp4|gif> Video format (default: mp4)
--from <dir> Source directory (default: test-results)
--to <dir> Output directory (default: docs/pw-videos)
--readme <path> README file (default: README.md)
--pattern <glob> Glob pattern (default: **/*.webm)
--max <n> Max videos to process (default: 1)
--name <filename> Output filename when max=1
--start-marker <text> Start marker in README
--end-marker <text> End marker in README
--overwrite Overwrite existing videos
--dedupe Remove duplicate/near-duplicate frames to shorten pauses
--dedupe-start-after <s> Only start de-duping after this many seconds (default: 0.5 when dedupe enabled)
--dedupe-start-after-ms <ms> Same as --dedupe-start-after, but in milliseconds
--dedupe-end-pad <s> Ensure the final view is visible for at least this many seconds (default: 0.5 when dedupe enabled)
--dedupe-end-pad-ms <ms> Same as --dedupe-end-pad, but in milliseconds
--remove-range <s,e> Remove a time range in seconds (can be specified multiple times)
--remove-ranges <json> Remove ranges as JSON array (e.g. '[{"startSeconds":1,"endSeconds":2}]')
--sidecar-adjacent Read per-video sidecar JSON ranges next to each input video
--sidecar-suffix <text> Sidecar suffix when using --sidecar-adjacent (default: .ranges.json)
--sidecar-required Fail if an expected sidecar file is missing
-h, --help Show help
# Run a specific test and convert to MP4
playwright test -g "login flow"
npx pw-test-videos --name login-demo.mp4# Process up to 3 newest test videos
npx pw-test-videos --max 3# Convert to GIF (better for GitHub README previews)
npx pw-test-videos --format gif --name demo.gif# Only process videos from "smoke" tests
npx pw-test-videos --pattern "**/smoke-*.webm"# Use different markers
npx pw-test-videos \
--start-marker "<!-- videos:start -->" \
--end-marker "<!-- videos:end -->"- Find Videos: Searches for
.webmfiles in source directory using glob pattern - Sort: Orders by modification time (newest first)
- Select: Takes up to
maxVideosbased on configuration - Convert: Uses ffmpeg to convert to MP4 or GIF
- MP4: H.264 codec for broad compatibility
- GIF: Two-pass conversion with palette generation for quality
- Copy: Moves converted videos to output directory
- Update: Replaces content between markers in README with video tags
This package requires ffmpeg to be installed on your system.
macOS:
brew install ffmpegUbuntu/Debian:
sudo apt-get install ffmpegWindows: Download from ffmpeg.org or use chocolatey:
choco install ffmpegYour project should have Playwright configured with video recording:
// playwright.config.ts
export default defineConfig({
use: {
video: 'on', // or 'retain-on-failure'
},
});✅ Smaller file size ✅ Better quality ✅ Supports audio (though test videos typically have none) ❌ May not autoplay in all contexts
✅ Autoplays everywhere ✅ No player controls needed ❌ Larger file size ❌ Limited color palette ❌ Lower quality
Recommendation: Use MP4 by default, GIF for specific use cases where autoplay is essential.
You can also use this package as a reusable GitHub Action.
Because this action lives in a subdirectory, reference it with the full path:
jobs:
videos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Run your tests first (this action only converts/embeds videos)
- name: Run Playwright tests
run: npx playwright test
- name: Convert videos + update README
uses: immense/playwright-readme-videos@<ref>
with:
# Directory containing your Playwright test-results/ and README markers
working-directory: .
# Optional: override any of the CLI args below
format: mp4
from: test-results
to: docs/pw-videos
readme: README.md
max: "1"
name: demo.mp4
# Optional: commit/push the updated README + assets
- name: Commit updated README/videos
run: |
git config user.name github-actions
git config user.email github-actions@github.com
git add README.md docs/
git commit -m "Update Playwright demo videos" || echo "No changes"
git pushThe action inputs map to the CLI flags (when provided):
config→--configformat→--formatfrom→--fromto→--toreadme→--readmepattern→--patternmax→--maxname→--namestart-marker→--start-markerend-marker→--end-markeroverwrite→--overwrite(set totrue)
The action will install ffmpeg on the runner by default (set install-ffmpeg: "false" to skip).
- name: Run Playwright tests
run: npm run test:e2e
- name: Process test videos
run: npx pw-test-videos
- name: Commit updated README
run: |
git config user.name github-actions
git config user.email github-actions@github.com
git add README.md docs/
git commit -m "Update test videos" || echo "No changes"
git push{
"scripts": {
"test:e2e": "playwright test",
"videos:convert": "pw-test-videos",
"test:e2e:demo": "run-s test:e2e videos:convert"
}
}Install ffmpeg (see Requirements section).
Ensure your README has both start and end markers.
Check that:
- Playwright tests have run
- Video recording is enabled in
playwright.config.ts sourceDirpath is correct
- Use MP4 instead of GIF
- Reduce video dimensions in Playwright config
- Lower frame rate for GIFs
Main function to process test videos.
Parameters:
config: Optional configuration object
Returns: Promise<ProcessResult>
interface ProcessResult {
converted: ConversionResult[];
copied: string[];
}Example:
const result = await processTestVideos({
format: 'gif',
maxVideos: 2,
});
console.log(`Converted ${result.converted.length} videos`);MIT
Contributions welcome! Please feel free to submit a Pull Request.
Originally developed as part of the relative-link-handler VS Code extension project.