recording-walkthrough-videos
activeCreates narrated screen-recorded walkthrough videos from written guides for web apps (Odoo, Business Central, any SaaS). Handles TTS synthesis, Playwright recording, ffmpeg montage with per-step synchronisation. Use when a guide or walkthrough document needs to become a video with voiceover, when feedback arrives about voice/action sync issues, or when recordings show pages jumping instead of being clicked.
Quality Score Breakdown
Show checks (11)
- ✓ SKILL.md exists with exact casing 3/3
- ✓ Valid YAML frontmatter 3/3
- ✓ No unexpected frontmatter keys 1/1
- ✓ Name field valid (kebab-case) 2/2
- ✓ Name matches folder name 1/1
- ✓ Description field present 2/2
- ✓ No angle brackets in frontmatter 1/1
- ✓ Folder name is kebab-case 1/1
- ✓ No README.md inside skill folder 1/1
- ✓ Test directory with test-cases.yml exists 2/2
- ✓ Status 'active' is valid 1/1
Show checks (7)
- ✓ Contains action verbs: creates, handles 4/4
- ✓ Contains trigger indicators: use when 2.5/5
- ✓ Description is specific and actionable 4/4
- ✗ Skill handles files but description doesn't mention file types 0/3
- ✓ Description length: 410/1024 chars 2/2
- ✗ Consider adding 'Do NOT use for...' to reduce over-triggering 0/2
- ✓ Owner/author specified in metadata 2/2
Show checks (8)
- ✓ Skill body has content 3/3
- ✓ Has step/section structure 4/4
- ✓ Includes examples 5/5
- ✓ Includes error handling 4/4
- ✓ Uses progressive disclosure (references/scripts) 4/4
- ✓ Actionable language: 6/10 verb patterns found 3/3
- ✓ Word count: 2957/5000 2/2
- ✓ All referenced paths exist 3/3
Show checks (10)
- ✓ test-cases.yml exists and parses 3/3
- ✓ 9 should-trigger tests ✓ 4/4
- ✓ 6 should-not-trigger tests ✓ 3/3
- ✓ 7 functional tests ✓ 5/5
- ✓ 3 negative tests ✓ 3/3
- ✓ 2 edge case tests ✓ 3/3
- ✓ Performance baseline documented 2/2
- ✓ All functional tests have ≥2 assertions 2/2
- ✓ All trigger phrases are diverse 2/2
- ✓ All assertions are specific 2/2
Show checks (5)
- ✓ No secrets detected 5/5
- ✓ No injection vectors in frontmatter 3/3
- ✓ Name is not reserved 3/3
- ✓ No suspicious code patterns 2/2
- ✗ 6 external URLs found — verify they're necessary: https://…, http://www.w3.org/2000/svg, https://api.elevenlabs.io/v1/voices 1/2
Suggestions
- → Add negative triggers like 'Do NOT use for simple X — use Y skill instead'
Test Coverage
Recording walkthrough videos
Prerequisites / ensure-deps
Before starting any video project, verify all dependencies are installed. Run the check script from the skill directory:
python3 scripts/check.py <projectDir>
If the script fails or reports missing tools, install the required dependencies:
Required tools
- Python 3.8+ with pip
- Node.js 18+ with npm
- ffmpeg (for video assembly)
- Playwright browsers (system Chrome)
Installation steps
# Check if ffmpeg is available
ffmpeg -version || sudo apt-get install -y ffmpeg
# Check Node.js
node --version || echo "Install Node.js 18+ from nodejs.org"
# Check Python
python3 --version || echo "Install Python 3.8+"
# Install Playwright and browsers (in the video project)
cd <projectDir>
npm install playwright
npx playwright install chromium
Authentication (one-time human setup)
- Business Central: Uses a persistent Chrome profile (
profileDirinvideo.config.json). Runnode <skill>/scripts/session_check_bc.js --headedonce to complete MFA login manually. The session persists in the profile. - Odoo: Uses JSON-RPC login with
authCmdpointing to credentials in a local env file (e.g.,~/.velais/odoo-sandbox.env). The session file auto-refreshes.
These logins are human one-time actions — the recording scripts reuse the saved session, they do not perform silent auto-login.
Verify setup
After installing dependencies, re-run the check:
python3 scripts/check.py <projectDir>
The check validates: scene step names, narration anchor uniqueness, sentence endings, and estimates video length. A non-zero exit code means errors that must be fixed before recording.
Overview
A narrated walkthrough video is a scripted recording (no model in the loop while filming) whose narration is synchronised per step, not per scene: every sentence is attached to the exact action it describes, and the montage fits each step’s footage to its sentence. Navigation is clicked the way a viewer would click. Everything is measured with real timestamps, never assumed.
One pipeline, two proven app adapters:
| App | Session | Locators | Reference | Worked example |
|---|---|---|---|---|
| Odoo 19 | JSON-RPC login → host-only cookie (authCmd, sessionFile, auto re-auth) | field, tab, menuGo, row, card built into record.js; probe.js | odoo-19-ui.md | worked-example.md — Telchar, 26 min |
| Business Central (it-it) | persistent Chrome profile (profileDir = chrome-profile-video2, human MFA once), shared by parallel recordings | scripts/bc_helpers.js (iframe, aria-labelledby, stacked pages, grids, lookups, Cerca); probe_bc.js, session_check_bc.js, scenes.example.bc.js | bc-ui.md — read it whole before the first BC scene | worked-example-bc.md — Saito, 17 min |
Any other web app: the Odoo path minus the Odoo locators (write a small <app>_helpers.js the same way as the BC one).
When to use
- A guide exists (markdown + demo data + reset/replay scripts) and the client wants “the same, as a video”.
- Feedback on a cut: “parla dei reparti ma ha già cliccato”, “salta da una pagina all’altra”, “si vede fermo”, “i riquadri sono sballati”.
- Any browser demo with a generated or recorded voice (see Voices).
Not for live capture of a human, or silent GIFs of a flow.
Project layout (one folder per video, next to the guide)
video/
video.config.json baseUrl, viewport [1600,900], chromeArgs ["--no-proxy-server"], locale, timezone
+ Odoo: sessionFile, authCmd, loginPath | + BC: tenantPath, company, profileDir
+ optional: channel ("chromium" when Google Chrome is not installed; default "chrome"),
clientLogo (path relative to the project) + clientName → client logo on title cards
scenes.js module.exports = (h) => ({ S00: async () => {…} }) ← scenes.example.js (Odoo) / scenes.example.bc.js (BC)
narration.json [{id, label, segments:[{at, text}]}] (at = step name)
pages.json title / terminal / file pages → python3 scripts/pages.py pages.json pages/
ids.json record ids + tokens resolved by API AFTER the data replay (+ BC: pre.py writes ids.json › video)
probes/ read-only DOM probes, one per page you will click (keep them: they are the evidence)
package.json npm i playwright (system Chrome is used: channel "chrome")
clips/ audio/ build/ profile/ generated — never committed (media stay local or on a media branch)
Odoo authCmd: ["python3", "<skill>/scripts/auth_odoo.py", "~/.velais/odoo-sandbox.env", "~/.velais/odoo-edu-session.txt"].
BC: "profileDir": "~/.claude/chrome-profile-video2", "tenantPath": "<tenant>/<env>", "company": "…"; check with
node <skill>/scripts/session_check_bc.js from the project dir. One browser on a profile at a time (parallel videos:
§ Parallel recordings).
The pipeline (in this order)
- Fresh data, then ids. Run the guide’s reset → seed → demo flows; create what will be worked live on camera;
resolve ids into
ids.json. Keep stock/records for TWO runs (headless replay + live recording) and apre.pythat rebuilds the “already done by the office” state before every recording pass. - Probe before you script. Odoo:
node scripts/probe.js video/ /odoo/accounting --menus "Fornitori". BC: copyscripts/probe_bc.jsintoprobes/, one per page/menu/button, dump captions and actions, walk the write path once (create → fill → post) and verify the outcome through the API. A selector you did not see in the DOM is a guess, and guesses fail at minute 12 of the recording. - Scenes = steps. One
h.step('<name>', …)per thing the voice will say; names unique per scene (anchors are looked up by name — a secondregistrain the same scene silently breaks the montage). Fixed pauses 1–3 s, real clicks on menus/rows/breadcrumbs,highlight()on the fields the voice names (the box follows the element),arrow(loc, 'Registra', 'bottom')for a labelled pointer,zoom(loc, 1.7)for small details,type/upload/scroll/framefor input. Deep links only in a scene’s firstapristep. Files, terminals and title cards are local pages frompages.pywith real outputs. Numbers created live are handed to later scenes throughclips/runtime.json. - Narration as segments — plain language for a newcomer, digits and acronyms spelled for TTS, product labels
quoted as shown, one WAV per segment. Two layers per scene: the action (what is clicked) and the context (why
the office does it, how it was done before, why the system stops you, how to fix it) — context goes where the
footage has silent room. Rules, word budgets, the context rules and the review-lens prompts:
narration-and-sync.md.
python3 scripts/check.py video/validates anchors, uniqueness, sentence endings and estimates the length (≈ 2.5 words/s) before anything is recorded (steps inside helper functions are not seen by its regex: those anchors are validated bysteps.jsonafter recording). - Review the narration with independent lenses (a Sonnet subagent: sentences longer than the step, English terms, claims not shown on screen), apply, then synthesise — see Voices.
- Record:
node scripts/record.js video/ [S01,S02](BC: same script; the project may keep arecord_bc.jscopy). Each scene →clips/<S>.webm+clips/<S>.steps.json(t0/t1/ok per step; a failed step is loggedERRwith the full Playwright message). Re-record only scenes with a failed step — after resetting the app state those scenes left behind (documents are real). When a probe passes but the scene fails, run a diagnostic mini-scene throughrecord.js(scenes.diag.js+ symlink) withlog()after every sub-click: the recorder’s click glides, scrolls and waits, a probe’s does not. - Review sync with the real timestamps, move sentences to steps with room, then
W=1600 H=900 MAX_SPEED=1.6 python3 scripts/assemble.py video/ out.mp4(Odoo footage tolerates 2.5×; BC page loads make 2.5× unwatchable). Read the per-step report: afermoabove ~10 s is a sentence on the wrong step; amuto≥ 4 s is a silent gap → add a context sentence there (~2 words per silent second), re-TTS, re-assemble. Unchanged segments are not re-encoded. - Verify, then deliver: look at frames at 3–4 segment boundaries (boxes on the right element, no tooltip, no
stale dialog),
ffprobethe duration, confirm an audible audio stream (volumedetect), phone copy (scale=1152:720 -crf 30, ≈ 1 MB/min, ≤ 30 MiB). Say the length, what is live vs pre-built, every step that was simplified or failed, and check nothing sensitive is on screen or in URLs.
Parallel recordings (several videos at once)
Several Claude sessions may each record a video at the same time — nothing to coordinate by hand. For BC
(profileDir set) record.js, probe_bc.js and session_check_bc.js do not launch Chrome themselves: they open a
tab in ONE shared headless Chrome per profile (scripts/bc_browser.js), started by the first client and closed by
itself after 15 idle minutes; each tab records its own clip (page.screencast). One login serves every video.
Locks in ~/.claude/video-locks/ stop real conflicts and exit 3 naming the holder.
Status: node scripts/bc_browser.js status · node scripts/video_lock.js status (company! = exclusive).
| Resource | Rule | Why |
|---|---|---|
| Chrome profile | "profileDir": "~/.claude/chrome-profile-video2" for every video (shared, one tab each) | ~/.claude/chrome-profile belongs to the Playwright MCP: a shared browser on it locks the MCP out until idle-exit |
| Project folder | one recorder per project (all its scenes in one run) | clips/_tmp, durations.json, runtime.json are per project |
| Company (BC) / database (Odoo) | shared (Saito: Saito - Test v6, never the Golden itself); "exclusiveCompany": true in video.config.json for a video that changes setup, closes a period (VAT settlement) or deletes data — nobody else records there meanwhile | copying a company per video costs too much time |
TTS · check.py · assemble.py | no lock | pure files |
A video on a shared company has a pre.py that only adds: its own documents on its own customer / vendor /
item (stock is shared — two videos shipping one item starve each other), no Setup/No. Series changes, no deletes or
resets outside what it created, and scenes open their documents by id (ids.json), never “the latest in the list”.
The other video’s documents may show in lists: filter, or accept it.
Shared-browser consequences (all tabs live and die together):
- Session expired (login page in a clip): wait until
video_lock.js statusshows norecord, thennode scripts/bc_browser.js stop→session_check_bc.js --headed(human MFA) → re-record the failed scenes. Neverstopwhile another session is recording: it kills its tabs. - A scene with a download (
DownloadFromStreamcloses the Chrome context = every tab): run that video alone,VIDEO_SOLO=1 node scripts/record.js …on a profile nobody else uses (own browser, profile locked). - Deploys/app changes break every running video: hold the project’s environment lock for the batch.
- Measured: 4 recordings started in the same second (Cerca → list → card) all passed; scenes ~12 % slower than alone.
- End every script with
await br.close()(the object frombc_browser.open()): it closes YOUR tabs, then disconnects. A barepage.close()leaves the process connected forever, holding its locks. A disconnect without closing leaves a live BC tab in the shared Chrome, and the server never idles out while one is open. - JS dialogs (alert/confirm/beforeunload) are closed by the client that owns the tab; the server closes them only on orphan tabs, after 10 s. Before 28/09 every connected client auto-dismissed every dialog, and the losers died of «No dialog is showing»: the server died 6 times, and every tab of every session with it.
- «Blocked»? Run
node scripts/bc_browser.js status: it prints CDP health and the open tabs. If CDP does not answer, note the tab count andpsof the Chrome processes beforestop: that hang is not reproduced yet.
Voices (same contract: one WAV per segment + sidecar .txt; only changed segments regenerate)
Default voice = edge-tts Isabella (2026-09-15). Unless Giovanni names another voice in the request, synthesise with
scripts/tts_edge.py(it-IT-IsabellaNeural). Mistral («la mia voce»), ElevenLabs, Diego, or microphone only on explicit request.
| Voice | Command | Notes |
|---|---|---|
| Default = edge-tts Isabella | python3 scripts/tts_edge.py $PWD/video/narration.json $PWD/video/audio | free, no API key. Override: EDGE_VOICE=it-IT-DiegoNeural (or Elsa / GiuseppeMultilingualNeural). Optional EDGE_RATE=+0%. |
| Giovanni’s cloned voice = Mistral | python3 scripts/tts.py $PWD/video/narration.json $PWD/video/audio | voice “Me” from the telegram-voice skill env. When Giovanni says «la mia voce» he means THIS. |
| Own microphone | python3 scripts/voice_record.py … --speaker <name> | interactive, one take per sentence, trimmed + normalised, --redo |
| ElevenLabs library | ELEVEN_VOICE=<id> ELEVEN_LANG=it python3 scripts/tts_eleven.py … | free tier: no cloning, ~10k chars quota (a 12-min Italian video ≈ 10k). Italian-verified: Matilda XrExE9yKIg1WjnnlVkGX, Alice Xb7hH8MSUJpSbSDYk0k2, Lily pFZP5JQG7iQjIQuC4Bku, River SAz9YHcvj6GT2YYXdXww; English: Daniel onwK4e9ZLuTAKqWW03F9. Key in <skill>/.env. |
| Silent placeholder | python3 scripts/silent_wavs.py … | mute cut for sync review before the real voice exists |
Pronunciation and pauses (all voices). Every TTS script sends spoken.py text, not the raw narration:
- English and product words are respelled from
scripts/pronunce.json(generic: «Business Central» → «Bìsnes Sèntral»), overlaid by<project>/pronunce.json(names, acronyms:"DDT": "di di ti"). The narration keeps the written form.check.pylists the words that look English and are not mapped yet. - Edge voices give every in-sentence mark the same ~0.3 s pause.
tts_edge.pytherefore synthesises each segment in pieces split after. ? ! … ; : —and joins them withspoken.PAUSE: full stop 0.55 s, «…» 0.45, «;» and «:» 0.4, «—» 0.35. Commas stay with the voice (~0.3 s). The engine’s own lead and tail silence is trimmed. UseEDGE_PIECES=0for one call per segment.assemble.pyputs 0.5 s (GAP) between segments, so the punctuation you write is the pacing you hear. - Dead footage: when a step’s footage is still longer than its voice at
MAX_SPEED,assemble.pyshortens the still stretches (page loads, waits), longest first, down toKEEP(0.6 s). The report shows this in thetagliatocolumn.
Switching voice regenerates everything on purpose (the sidecar stamps voice+model); keep the other voice’s WAVs in
audio-<voice>/. A second language = a second project folder with its own narration, pages and audio.
Quick reference
| Need | Do |
|---|---|
| Voice ↔ action sync | per-step timestamps + per-segment audio, never one stretch factor per scene |
| Footage shorter than voice | slow ≤ 1.5× then hold; hold > 10 s → move text or add a pause to the step |
| Footage longer than voice | speed ≤ MAX_SPEED (2.5 Odoo, 1.6 BC) then silence tail; never cut the action |
| Visible interaction | injected cursor (28 px, Velais Energetic blue, click ripple), glide() ~0.5 s, following highlight() (timed: gone after ms, keep it 1.2–1.6 s; click() clears boxes and arrows), arrow(), zoom() |
| Odoo session | JSON-RPC login → host-only cookie (url: not domain:) + auto re-auth on /web/login |
| BC session / DOM | persistent profile via profileDir; iframe + aria-labelledby + stacked pages → bc_helpers.js; bc-ui.md |
| Several videos at once | just start them (one tab each in the shared browser); shared company, additive pre.py (§ Parallel recordings) |
| Chrome hangs on every site | system PAC proxy → --no-proxy-server |
| Slow menus / lookups | CLICK_TIMEOUT=20000 (record.js default) — 8 s timeouts fail on BC |
| Sentences/chapters run together | assemble.py keeps ≥ GAP 0.6 s after every sentence, SCENE_GAP 1.2 s at a scene end; open a chapter with a full clause («Nel pomeriggio, …»), not a one-word «Pomeriggio.» |
| Keyboard shortcut in the voice | "tasti": "Alt+Q" (+ "rate": "-12%") on the segment → keycap badge bottom-right; spoken «Alt e Q» |
| Length target | check.py estimate; 15 min ≈ 2 250 words; phone copy ≈ 1 MB/min |
Common mistakes (all seen in real cuts)
- Uniform stretch per scene → “parla dei reparti mentre è già sulla scheda”. Sync per step.
- Deep links between chapters → the viewer sees pages jump. Click menus, rows, breadcrumbs.
- Domain cookie → session dies after ~5 min (the app rotates the sid; a domain cookie coexists and wins).
- Guessed selectors (docs label,
<button>for a styled<a>) → 30 s timeouts inside the clip. Probe first; every step is logged; nevercatch(() => {})silently. - Highlight drawn once → box on the wrong spot after a scroll, a FastTab opening or a dialog sliding in.
- Unscoped
.last()/ bare[role=dialog](BC) → box or click on the page BEHIND, or on an empty twin dialog. - Per-user UI state (BC filter pane, FastTabs) → a toggle that worked in the probe closes it in the scene.
- Duplicate step names → the montage anchors the sentence to the last occurrence.
- Sentences longer than the step → frozen frame. A 5 s highlight carries ~12 words.
- Only the clicks, no reasons → silent stretches between clicks and a viewer who learns buttons, not the process
(a BC video: 630 s of silence in 24 min). Fill
mutogaps with sourced context: why, what goes wrong, how it was before. - Claims without measurement (“misurato sul provino”) →
ffprobe,steps.json, frames. Report what failed. - One-shot narration → lens reviews found 45 real issues on the first script, 28 on the segmented one, 16 on BC.
- Re-recording without resetting state → documents already shipped/posted, duplicate vendor invoice numbers, empty orders in the lists.
Rationalisations to refuse
| Excuse | Reality |
|---|---|
| ”I’ll sync by adding sleeps that match the audio” | Page loads vary 2–40 s; only timestamps + montage hold. |
| ”Deep links are cleaner, I’ll cover them with a card” | The client asked to see the clicks. |
| ”The selector is obvious from the label” | Approva was Valida; Registra was Conferma registrazioni contabili; BC’s «Nuovo» matched a hidden group. |
| ”The probe passed, so the scene will pass” | The recorder clicks differently; run the diagnostic mini-scene. |
| ”The narration is fine, it’s just words” | Two review rounds rewrote a third of it. |
| ”Re-recording everything is simpler” | Re-record failed scenes only; clips, WAVs and encoded segments are cached — but reset the app state first. |
| ”The step logged ok, so it posted” | Check the outcome through the API; a dialog on the wrong element logs ok too. |