YAML in, narrated video out.
Automated screen recordings of any web app — 16:9 tutorials, 9:16 shorts,
AI voiceover, karaoke subtitles, and publishing to YouTube.
Live page • Architecture • Quick start • Actions • Plugins • YouTube
English • Deutsch
You describe a flow as YAML. The recorder drives a real Chromium through it with an animated cursor, speaks over it with an AI voice, and hands back a finished video.
Nothing about it is tied to one application: every selector, format and style value
lives in recorder.config.yaml, and anything a generic click cannot reach is added
as a plugin.
| Demo flow | Tutorial | Short | |
|---|---|---|---|
| Turned on by | no title: |
title: set |
format: short |
| Purpose | silent walkthrough | narrated explainer | vertical clip for a cold audience |
| Resolution | 1920×1080 | 1920×1080 | 1080×1920 |
| Output | .webm |
.mp4 (H.264 + AAC) |
.mp4 with burned-in subtitles |
| Login | visible | behind a frosted title card | never in the video |
| Audio | none | voice + optional music | voice at 1.1× + music |
git clone https://github.com/straussbastian/howTo-Recorder.git
cd howTo-Recorder
npm install && npm run install-browsers
cp recorder.config.example.yaml recorder.config.yaml
cp .env.example .env
# point both at your app
npm run record -- example-flownpm run record -- my-scenario # record one scenario
npm run record -- my-scenario --preview # silent dry run, no voice-synthesis cost
npm run record -- all-howtos # batch (master YAML)
HEADED=1 npm run record -- my-scenario # watch the browser while it works
npm run record -- --list-actions # every action, plugins included
npm test # unit tests
npm run typecheckScenarios live in scenarios/*.yaml. A step is an action plus its argument;
narrate: and highlight: are metadata any step can carry.
name: howto-create-user
title: "How do I create a user?" # a title turns on the narrated pipeline
fadeOut: 1.5
steps:
- login
- wait.ready
- narrate: In this tutorial I'll show you how to add a user.
- click.text: Users
narrate: We open the user list.
highlight: Users # spotlight while the sentence plays
- wait.url: /users
- fill:
Name: Ada Lovelace
narrate: We type the name.
- click.button: Create
narrate: One click and the user is saved.
- wait.idleNarration fires before the action, and the runner holds the step until the audio clip ends — so the voice never runs ahead of the picture.
| Field | Default | Meaning |
|---|---|---|
title |
— | Set it and the narrated pipeline turns on (voice, title card, MP4) |
format |
howto |
howto or short — selects the whole recording profile |
narration |
— | One clip for the entire video (alternative to per-step narrate:) |
music |
profile | File in assets/ used as the music bed |
musicVolume |
profile | 0–1 |
speechRate |
profile | ffmpeg atempo factor on the voice |
subtitles |
profile | Burn karaoke subtitles into the picture |
targetLength |
profile | Seconds. Warns before the browser starts; never aborts |
fadeOut |
0 |
true for 1.5 s, or a number of seconds |
Speech synthesis mangles domains, abbreviations and product names. Write what should
be shown in brackets and what should be spoken after narrate::
- narrate: Everything lives at [go-lizard.com](narrate: go minus lizard dot com).The subtitle reads go-lizard.com; the voice says "go minus lizard dot com". The
karaoke timing keeps the whole substitution as one token, so it stays in step.
# scenarios/all-howtos.yaml
name: all-howtos
scenarios:
- howto-create-user
- howto-edit-profileOne failure does not stop the batch; the summary at the end lists what succeeded.
Speech synthesis costs money, and narration written against an imagined screen is
usually wrong. --preview records the flow silently, estimates how long each
sentence would take, and pulls one frame per planned narration point:
npm run record -- my-scenario --preview[howto] PREVIEW — no speech synthesis, 6 clips estimated
6 preview frames → videos/my-scenario.preview
01 4.2s In this tutorial I'll show you how to add a user.
02 11.8s We open the user list.
Check the frames, fix the wording, then record for real. Frames rather than screenshots, deliberately: a screenshot shows the viewport, not what lands in the video.
Everything app-specific lives here. Copy the example, then override only what differs — every key has a working default.
app:
base_url: https://your-app.com
ready_selector: ".app-shell" # the app has finished loading
loading_selector: ".spinner" # must disappear before continuing
dialog_selector: '[role="dialog"]' # clicks and fields scope to an open modal
auth:
login_path: /login
selectors:
email: "input[type='email']"
password: "input[type='password']"
submit: "button[type='submit']"
formats:
short:
speechRate: 1.2
musicVolume: 0.1
plugins: ["./plugins/filament"]Ready-made presets ship for WordPress, Next.js (NextAuth), React-Admin, Laravel Filament, Vue + Vuetify and Django Admin — uncomment the matching block at the bottom of the example file.
RECORDER_BASE_URL=https://your-app.com
RECORDER_EMAIL=user@example.com
RECORDER_PASSWORD=secret
ELEVENLABS_API_KEY= # only for narrated videos
ELEVENLABS_VOICE_ID=
YOUTUBE_CLIENT_ID= # only for publishing
YOUTUBE_CLIENT_SECRET=| File | Effect |
|---|---|
assets/logo.svg |
Logo shown throughout a short. Absent → skipped |
assets/*.ttf |
Subtitle font; name it under subtitles.font_name |
assets/*.mp3 |
Music bed; name it under music: or formats.<name>.defaultMusic |
| Action | Argument | Description |
|---|---|---|
login |
— | Sign in with the .env credentials |
goto |
URL | Navigate, relative or absolute |
wait.ready |
— | ready_selector present and loading finished |
wait.idle |
— | Every loading_selector element gone |
wait.url |
substring | URL contains the substring |
wait.selector |
CSS | Element is visible |
wait |
milliseconds | Hard pause |
| Action | Argument | Description |
|---|---|---|
click |
CSS | Click the first match, cursor animated |
click.text |
visible text | Click by text, scoped to an open dialog |
click.button |
label | Click a button or link by role, exact label first |
fill |
{ Label: value } |
Type into a field by its label |
fill.css |
{ selector: value } |
Type into a field with no unique label |
select.native |
{ Label: option } |
Pick from a native <select> |
upload |
file path | Put a file into a file input |
drag |
{ from, to | dx, dy, edge? } |
Realistic mouse drag |
scroll.to |
heading text | Scroll a section into the middle |
scroll.end |
— | Step to the bottom, pulling in lazy content |
| Action | Argument | Description |
|---|---|---|
narrate |
text | Voiceover step (also usable as step metadata) |
hud |
text or { text, ms } |
Keystroke overlay |
blackout / blackout.off |
— | Curtain; survives navigation, even across domains |
screenshot |
file path | Save a PNG |
Any step also accepts highlight: — a spotlight frame around an element, useful for
narration steps where nothing else moves:
- narrate: Top left you filter by location.
highlight: { target: "Location", nth: 2 }Plugins register extra actions for widgets a generic click cannot reach.
// plugins/my-app/index.ts
import type { Page } from 'playwright';
type ActionHandler = (page: Page, arg: unknown) => Promise<void>;
export function register(actions: Record<string, ActionHandler>): void {
actions['modal.open'] = async (page, arg) => {
await page.getByRole('button', { name: String(arg) }).click();
await page.waitForSelector('.modal', { state: 'visible' });
};
}# recorder.config.yaml
plugins: ["./plugins/my-app"]plugins/filament ships as a working reference: Filament
selects (including searchable and multi), vue-treeselect, the TipTap rich editor,
stats widgets and Fabric.js canvases.
Optional. Without a youtube: block in the config, everything else works unchanged.
npm run youtube-auth # once, see docs/youtube-setup.md
npm run publish-video -- my-scenario --dry-run # exactly what would go up
npm run publish-video -- my-scenario
npm run playlist -- --list
npm run playlist -- --move my-scenario --to 3
npm run unpublish -- my-scenario # deletes for good; asks you to type the idThe recorder writes videos/<name>.timing.json next to every MP4 — narration text
with its offset and duration, which is what chapter markers are built from.
Full walkthrough: docs/youtube-setup.md.
Explore the interactive diagram →
howto-recorder/
├── recorder.config.yaml # everything app-specific
├── .env # credentials and API keys
├── scenarios/ # YAML scenarios
├── plugins/ # custom actions (filament ships as a reference)
├── assets/ # logo, fonts, music
├── videos/ # output: mp4, timing.json, youtube.json
└── src/
├── cli.ts # entry point
├── config.ts # config loader and defaults
├── formats.ts # howto / short profiles
├── recorder.ts # recording session and media pipeline
├── yaml-scenario.ts # parser and step runner
├── actions.ts # action registry and plugin loader
├── narration-text.ts # pronunciation markup
├── subtitles.ts # ASS karaoke subtitles
├── preview.ts # frame extraction for the dry run
├── audio/ # ElevenLabs, tempo, ffmpeg mux
├── overlays/ # cursor, HUD, title card, highlight, blackout, logo
├── core/ # auth, waits, forms, scroll, upload, drag
└── youtube/ # OAuth, upload, playlist, sidecar
- Parse — the scenario resolves into steps, narration texts and a format profile.
- Synthesize — every clip is generated before the browser opens, so the recorded timeline never stalls on an API call. ElevenLabs returns word-level timings, which is what makes the karaoke subtitles land on the beat.
- Warn — with the speech duration known, an overlong video is flagged before a browser even starts.
- Record — Playwright drives the flow with cursor, HUD and overlay layers. For shorts the login happens in a separate, unrecorded context first.
- Mux — ffmpeg lays the clips at their measured offsets, mixes music, burns in subtitles before the fade, and writes the MP4 plus its timing sidecar.
Node 20 or newer. ffmpeg ships with the install (ffmpeg-static); Chromium comes from
npm run install-browsers. ElevenLabs credentials are needed only for narrated
videos, YouTube credentials only for publishing.
Issues and pull requests are welcome. npm run typecheck and npm test should pass;
CI runs both. The tests cover the parser, subtitles, format profiles and YouTube
logic — everything decidable without a browser or a live API.
Built and maintained by Wolf+Strauss Solutions.
The recorder grew out of the tooling behind Lizard, a Laravel/Filament product whose entire tutorial library is produced with it — which is why the bundled reference plugin targets Filament. You can watch the output on the Lizard YouTube channel.
MIT — see LICENSE.
Built with Playwright, ElevenLabs and ffmpeg by
Wolf+Strauss Solutions
Lizard •
YouTube •
bastianstrauss.digital