Generate polished application demo videos automatically using Playwright for scripted screen capture and ffmpeg to stitch the frames into an MP4.
The repo ships a minimal but real Vite + React demo app ("AppFlow") whose Playwright tour produces a ready-to-share product walkthrough video in a single command.
# 1. Clone & install
git clone https://github.com/element-software/e2e-application-demo-videos-with-playwright.git
cd e2e-application-demo-videos-with-playwright
npm install # installs Playwright (root)
npm run app:install # installs Vite + React deps
# 2. Install Playwright's Chromium browser
npx playwright install chromium
# 3. Generate the demo video (requires ffmpeg on PATH — see Prerequisites)
npm run demo:videoThe final MP4 will be written to demo/output/demo.mp4.
| Tool | Minimum version | Install |
|---|---|---|
| Node.js | 18 | nodejs.org |
| ffmpeg | 4.x | brew install ffmpeg · sudo apt install ffmpeg · ffmpeg.org |
| Chromium | via Playwright | npx playwright install chromium |
Windows users: make sure
ffmpeg.exeis on yourPATH(e.g. add it to System → Environment Variables after extracting the zip from ffmpeg.org).
| Script | What it does |
|---|---|
npm run demo:video |
Full pipeline: capture + render |
npm run demo:video:capture |
Run Playwright tour → save PNGs + manifest |
npm run demo:video:render |
Read manifest → invoke ffmpeg → write MP4 |
npm run app:dev |
Start the Vite dev server (port 5173) |
npm run app:build |
Compile the React app to app/dist/ |
npm run app:preview |
Serve the production build locally |
.
├── app/ # Vite + React demo application
│ ├── src/
│ │ ├── App.tsx # Router / layout shell
│ │ ├── index.css # Global styles (dark theme, CSS variables)
│ │ ├── components/
│ │ │ └── NavBar.tsx
│ │ └── pages/
│ │ ├── Home.tsx # Hero / landing
│ │ ├── Features.tsx # Feature grid
│ │ ├── Dashboard.tsx # Metrics + bar chart
│ │ └── GetStarted.tsx # Sign-up form
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
│
├── tests/
│ └── demo-tour.spec.ts # Playwright scripted tour
│
├── scripts/
│ └── render-video.mjs # Node.js ffmpeg invocation script
│
├── demo/ # Generated at runtime (git-ignored)
│ ├── slides/ # PNGs written by Playwright
│ ├── output/ # demo.mp4 written by render script
│ └── manifest.txt # ffmpeg concat-demuxer manifest
│
├── .github/
│ └── workflows/
│ └── demo-video.yml # CI workflow
│
├── demo.config.mjs # Central config (fps, crf, audio, paths)
├── playwright.config.ts # Playwright settings (viewport, webServer)
├── package.json # Root scripts & devDependencies
├── LICENSE # MIT
└── README.md
┌──────────────┐ Playwright ┌───────────────────┐
│ Vite + React│ ←── scripted tour ──│ demo-tour.spec.ts │
│ dev server │ └─────────┬─────────┘
│ :5173 │ │ screenshots
└──────────────┘ ▼
demo/slides/*.png
│
│ + durations
▼
demo/manifest.txt
(ffmpeg concat format)
│
render-video.mjs
│ ffmpeg
▼
demo/output/demo.mp4
tests/demo-tour.spec.ts runs in a Chromium browser at a fixed
1280 × 720 viewport. Each captureSlide() call:
- Navigates to a route / triggers a UI state.
- Waits for
networkidleso animations and fetches are settled. - Saves a PNG to
demo/slides/NN-name.png. - Records the slide's display duration (seconds).
After all slides are captured the test writes demo/manifest.txt in
ffmpeg concat-demuxer format:
file '/abs/path/01-home.png'
duration 3
file '/abs/path/02-features.png'
duration 4
...
file '/abs/path/last-slide.png' ← repeated without duration (required quirk)
ffmpeg concat quirk: the last
fileline must be repeated without adurationentry, otherwise ffmpeg discards the final frame entirely and the last slide has zero length in the output video.
scripts/render-video.mjs (pure Node.js, no extra dependencies):
-
Loads configuration from
demo.config.mjs. -
Parses
manifest.txtand sumsdurationlines to gettotalDuration. -
Validates that
ffmpegis on PATH and all referenced files exist. -
Calls
ffmpegwith:ffmpeg \ -f concat -safe 0 -i manifest.txt \ # image sequence input [-i audio/background.mp3] \ # optional audio input -vf fps=30,format=yuv420p \ # normalise framerate + pixel fmt [-af atrim=0:<total>,afade=out:...] \ # audio trim + fade-out -c:v libx264 -crf 22 -preset medium \ -movflags +faststart \ # web-optimised: moov atom first [-c:a aac -b:a 192k -shortest] \ -y demo/output/demo.mp4
const config = {
slidesDir: 'demo/slides', // where Playwright writes PNGs
manifestPath: 'demo/manifest.txt', // ffmpeg manifest
outputPath: 'demo/output/demo.mp4',
fps: 30,
crf: 22, // 18 = near-lossless, 28 = smaller
// Optional background audio
audioPath: null, // e.g. 'audio/background.mp3'
audioFadeDuration: 2, // seconds of fade-out at end
};- Obtain a royalty-free MP3 (e.g. from Pixabay Music,
freemusicarchive.org, or generate a silent
placeholder with
ffmpeg -f lavfi -i "anullsrc=r=44100:cl=stereo" -t 60 audio/placeholder.mp3). - Place it at e.g.
audio/background.mp3. - Set
audioPath: 'audio/background.mp3'indemo.config.mjs.
The
audio/directory is listed in.gitignore. Do not commit copyrighted audio files.
Edit tests/demo-tour.spec.ts. Each slide is defined with:
await captureSlide(
'03-dashboard', // slide file name (determines sort order)
4, // display duration in seconds
async () => {
await page.click('a[href="/dashboard"]');
await page.waitForSelector('[data-testid="page-dashboard"]');
}
);Adjust slide durations, add new slides, or change the viewport in
playwright.config.ts.
The workflow at .github/workflows/demo-video.yml triggers on:
- Manual dispatch (Actions tab → "Run workflow")
- Pushes to
mainthat touch the app, tests, scripts, or config
It installs ffmpeg, runs the full pipeline, and uploads demo.mp4 as a
downloadable workflow artifact (retained 30 days).
Install ffmpeg and ensure it is on your PATH. Verify with ffmpeg -version.
The concat manifest must end with a bare file 'last.png' line (no duration).
The writeManifest helper in demo-tour.spec.ts handles this automatically.
Make sure the Playwright viewport and all screenshots share the same width/height.
The default 1280 × 720 is set in playwright.config.ts → use.viewport.
Run npm run demo:video:capture before npm run demo:video:render, or use the
combined npm run demo:video script.
If webServer times out, start the dev server manually in a separate terminal
(npm run app:dev) then re-run with:
DEMO_BASE_URL=http://localhost:5173 npm run demo:video:captureMIT © element-software