voila-recorder 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -13,8 +13,8 @@ Via MCP (preferred — register once):
13
13
  claude mcp add voila -- npx -y voila-recorder mcp
14
14
  ```
15
15
 
16
- Tools: `voila_outline(url)` · `voila_record(url, steps_yaml?, device?)` ·
17
- `voila_review(video_path)`.
16
+ Tools: `voila_outline(url)` · `voila_record(url, steps_yaml?, device?, voice?, speed?)` ·
17
+ `voila_review(video_path)` · `voila_voices()` · `voila_login(url)`.
18
18
 
19
19
  Via CLI (no registration needed):
20
20
 
@@ -22,6 +22,11 @@ Via CLI (no registration needed):
22
22
  npx -y voila-recorder outline <url>
23
23
  npx -y voila-recorder record <url> --steps steps.yaml [--device mobile]
24
24
  npx -y voila-recorder review demo.mp4
25
+ npx -y voila-recorder voices
26
+ npx -y voila-recorder login <url>
27
+ npx -y voila-recorder doctor
28
+ npx -y voila-recorder fork demo.mp4 [--url other] [--print]
29
+ npx -y voila-recorder rerender <dir> --voice bf_emma
25
30
  ```
26
31
 
27
32
  ## The loop — always follow it
@@ -50,7 +55,14 @@ npx -y voila-recorder review demo.mp4
50
55
  (`npx -y voila-recorder record <url> --headful` or the web UI via
51
56
  `npx -y voila-recorder serve`). The session persists in a local browser
52
57
  profile. **Never type credentials yourself.**
53
- - Narration style: short sentences, product language, 8–15 words per beat.
58
+ - Narration style: short sentences, product language, 8-15 words per beat.
59
+ - Prefer `zoom` with a `selector` over a raw `level`: voila measures the element
60
+ and picks the level and camera centre so nothing gets cropped.
61
+ - Voices: 28 English (US/UK). af_heart (A) is the default, af_bella (A-) and
62
+ bf_emma (British) are the other good ones. `speed` 0.5-1.6 sets pace.
63
+ - Sign-in walls: recording refuses to film a login page. Run `voila login <url>`
64
+ (or the voila_login tool), let the HUMAN sign in in the window that opens, and
65
+ the session persists in a local profile for every later recording.
54
66
 
55
67
  ## Working on this repo
56
68
 
package/README.md CHANGED
@@ -28,9 +28,18 @@ voila record <url> --device mobile # iPhone-class viewport (portrait)
28
28
  voila outline <url> # page structure for planning
29
29
  voila review demo.mp4 --frames 12 # frames + recipe for self-review
30
30
  voila serve # web UI
31
+ voila doctor # check + pre-download chromium and the voice model
32
+ voila fork demo.mp4 --url https://other.app # rebuild any voila demo from the recipe inside it
33
+ voila rerender <dir> --voice bf_emma # new voice, no re-recording (needs --keep-frames)
34
+ voila login https://app.example.com # you sign in; session saved locally
35
+ voila voices # 28 narration voices, graded
31
36
  voila mcp # stdio MCP server
32
37
  ```
33
38
 
39
+ First run downloads Chromium (~150MB) and the voice model (~90MB) into
40
+ `~/.cache/voila`, so upgrades do not re-download. Consent banners are dismissed
41
+ before recording, preferring "reject" over "accept".
42
+
34
43
  Devices: `desktop` (1280×800), `mobile` (390×844, touch + mobile UA),
35
44
  `tablet` (834×1112). Cross-platform: verified on macOS and Linux (arm64
36
45
  container); recording is headless-safe for CI.
package/RECIPE.md CHANGED
@@ -48,7 +48,7 @@ Each step: `{ action, ...params, caption?, narration?, optional?, pause? }`
48
48
  | `scroll` | `y` (absolute px), `ms` | eased scroll |
49
49
  | `scroll_to` | `selector`, `ms` | eased scroll until element is in view |
50
50
  | `slide` | `title`, `subtitle?`, `accent?`, `ms?` | full-screen animated title card |
51
- | `zoom` | `level` (1–3), `ms` | camera zoom toward the cursor |
51
+ | `zoom` | `level` (1-3) or `selector`, `ms` | camera zoom; with a selector it frames that element |
52
52
  | `wait` | `ms` | hold (cursor keeps breathing on long holds) |
53
53
 
54
54
  `caption` renders as a lower-third; `narration` is spoken by on-device TTS and
package/audio.js CHANGED
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const { execFile, spawn } = require('child_process');
9
9
  const ffmpegPath = require('ffmpeg-static');
10
+ const voices = require('./voices');
10
11
 
11
12
  const run = (cmd, args) => new Promise((res, rej) => {
12
13
  execFile(cmd, args, { maxBuffer: 1e7 }, (err, stdout, stderr) =>
@@ -21,6 +22,7 @@ async function getKokoro() {
21
22
  // Must load the CJS build (exports map: require → dist/kokoro.cjs): it
22
23
  // resolves bundled voice files via __dirname, while the ESM build loses
23
24
  // __dirname and breaks when cwd isn't the package root.
25
+ require('./modelcache').useStableCache();
24
26
  const { KokoroTTS } = require('kokoro-js');
25
27
  kokoroInstance = await KokoroTTS.from_pretrained(
26
28
  'onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8' }
@@ -29,15 +31,19 @@ async function getKokoro() {
29
31
  return kokoroInstance;
30
32
  }
31
33
 
32
- async function synthKokoro(texts, dir, voice, onStatus) {
34
+ async function synthKokoro(texts, dir, voice, onStatus, speed = 1) {
35
+ // Fail loudly on a bad voice name rather than silently using the default.
36
+ if (voice && /^[a-z]{2}_/.test(voice) && !voices.isValid(voice)) {
37
+ throw new Error(`unknown voice "${voice}". Try: ${voices.suggest(voice).join(', ')} (run \`voila voices\` for all ${voices.ranked().length})`);
38
+ }
33
39
  onStatus('loading Kokoro TTS');
34
40
  const tts = await getKokoro();
35
- const v = voice && /^[a-z]{2}_/.test(voice) ? voice : 'af_heart';
36
- onStatus(`narrating with Kokoro (${v})`);
41
+ const v = voice && voices.isValid(voice) ? voice : 'af_heart';
42
+ onStatus(`narrating with Kokoro (${v}${speed !== 1 ? ` @${speed}x` : ''})`);
37
43
  const clips = [];
38
44
  for (let i = 0; i < texts.length; i++) {
39
45
  const file = path.join(dir, `seg${i}.wav`);
40
- const audio = await tts.generate(texts[i], { voice: v });
46
+ const audio = await tts.generate(texts[i], { voice: v, speed });
41
47
  await audio.save(file);
42
48
  const durMs = audio.audio && audio.sampling_rate
43
49
  ? Math.round((audio.audio.length / audio.sampling_rate) * 1000)
@@ -87,13 +93,14 @@ async function synthSay(texts, dir, voice, onStatus) {
87
93
 
88
94
  // Synthesize narration clips up front so the recorder can pace segments to the
89
95
  // spoken durations. Returns {clips: [{file, durMs}], voice, backend}.
90
- async function prepareNarration(texts, dir, voice, onStatus = () => {}) {
96
+ async function prepareNarration(texts, dir, voice, onStatus = () => {}, speed = 1) {
91
97
  fs.mkdirSync(dir, { recursive: true });
92
98
  const backend = process.env.VOILA_TTS || 'kokoro';
93
99
  if (backend === 'kokoro') {
94
100
  try {
95
- return await synthKokoro(texts, dir, voice, onStatus);
101
+ return await synthKokoro(texts, dir, voice, onStatus, speed);
96
102
  } catch (e) {
103
+ if (/unknown voice/.test(e.message)) throw e; // user error, not a fallback case
97
104
  onStatus(`kokoro unavailable (${e.message.slice(0, 80)})`);
98
105
  }
99
106
  }
@@ -101,7 +108,7 @@ async function prepareNarration(texts, dir, voice, onStatus = () => {}) {
101
108
  throw new Error('no TTS backend available');
102
109
  }
103
110
 
104
- async function addNarration(meta, videoIn, videoOut, { voice = null, prepared = null, onStatus = () => {} } = {}) {
111
+ async function addNarration(meta, videoIn, videoOut, { voice = null, speed = 1, prepared = null, onStatus = () => {} } = {}) {
105
112
  const segs = (meta.segments || []).filter(s => s.narration);
106
113
  if (!segs.length) {
107
114
  fs.copyFileSync(videoIn, videoOut);
@@ -113,7 +120,7 @@ async function addNarration(meta, videoIn, videoOut, { voice = null, prepared =
113
120
  let synth = prepared && prepared.clips.length === segs.length ? prepared : null;
114
121
  if (!synth) {
115
122
  try {
116
- synth = await prepareNarration(segs.map(s => s.narration), dir, voice, onStatus);
123
+ synth = await prepareNarration(segs.map(s => s.narration), dir, voice, onStatus, speed);
117
124
  } catch (e) {
118
125
  onStatus(`narration skipped: ${e.message}`);
119
126
  fs.copyFileSync(videoIn, videoOut);
package/auth.js ADDED
@@ -0,0 +1,64 @@
1
+ // Auth: voila never handles credentials. It opens a real browser window, the
2
+ // person signs in themselves, and the logged-in session persists in a local
3
+ // Chromium profile directory that only ever lives on their machine.
4
+
5
+ // Heuristic: does this page look like a sign-in wall rather than the product?
6
+ const LOGIN_MARKERS = /\b(sign in|log ?in|continue with|forgot password|create account)\b/i;
7
+
8
+ async function detectAuthWall(page) {
9
+ try {
10
+ const url = page.url();
11
+ const signal = await page.evaluate(() => {
12
+ const pw = document.querySelectorAll('input[type="password"]').length;
13
+ const oauth = [...document.querySelectorAll('a,button')]
14
+ .filter(e => /continue with|sign in with/i.test(e.textContent || '')).length;
15
+ const bodyLen = (document.body?.innerText || '').length;
16
+ const head = (document.body?.innerText || '').slice(0, 400);
17
+ return { pw, oauth, bodyLen, head };
18
+ });
19
+ const urlLooksAuth = /\/(login|signin|sign-in|auth|account\/login)(\/|\?|$)/i.test(url);
20
+ // A password box, or an OAuth-only wall on a nearly empty page.
21
+ const isWall = signal.pw > 0
22
+ || (signal.oauth > 0 && signal.bodyLen < 1200)
23
+ || (urlLooksAuth && LOGIN_MARKERS.test(signal.head));
24
+ return isWall ? { isWall: true, url } : { isWall: false };
25
+ } catch {
26
+ return { isWall: false };
27
+ }
28
+ }
29
+
30
+ // Open a real browser window on `url` and hold it open until the person has
31
+ // signed in. Returns once they confirm, or once the page leaves the auth wall.
32
+ async function login(session, url, { onStatus = () => {} } = {}) {
33
+ session.headless = false;
34
+ const page = await session.open(url);
35
+ onStatus('browser open: sign in in that window');
36
+
37
+ const done = new Promise(resolve => {
38
+ // Preferred: the person presses Enter when finished.
39
+ if (process.stdin.isTTY) {
40
+ process.stdin.setEncoding('utf8');
41
+ process.stdin.resume();
42
+ const onData = () => { process.stdin.pause(); resolve('confirmed'); };
43
+ process.stdin.once('data', onData);
44
+ }
45
+ });
46
+
47
+ // Fallback for non-interactive callers: poll until the auth wall is gone.
48
+ const watched = (async () => {
49
+ for (let i = 0; i < 600; i++) {
50
+ await new Promise(r => setTimeout(r, 1000));
51
+ if (page.isClosed()) return 'window closed';
52
+ const wall = await detectAuthWall(page);
53
+ if (!wall.isWall && i > 3) return 'signed in';
54
+ }
55
+ return 'timed out after 10 minutes';
56
+ })();
57
+
58
+ const reason = await Promise.race([done, watched]);
59
+ const finalUrl = page.isClosed() ? url : page.url();
60
+ await session.close();
61
+ return { reason, profileDir: session.profileDir, finalUrl };
62
+ }
63
+
64
+ module.exports = { login, detectAuthWall };
package/cli.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // voila outline <url> [--device mobile]
4
4
  // voila record <url> [--steps f.yaml] [--device mobile] [--voice name] [--no-narrate] [--headful] [--out dir]
5
5
  // voila review <video.mp4> [--frames 12] [--out dir]
6
+ // voila login <url> [--profile dir]
7
+ // voila voices
6
8
  // voila serve [--port 4477]
7
9
  // voila mcp
8
10
 
@@ -16,9 +18,14 @@ function arg(name, fallback = null) {
16
18
  }
17
19
 
18
20
  const USAGE = `usage:
21
+ voila doctor (check + download everything voila needs)
19
22
  voila outline <url> [--device desktop|mobile|tablet]
20
- voila record <url> [--steps f.yaml] [--device mobile] [--voice name] [--no-narrate] [--headful] [--out dir] [--profile dir]
23
+ voila record <url> [--steps f.yaml] [--device mobile] [--voice name] [--speed 1] [--no-narrate] [--headful] [--keep-frames] [--no-dismiss] [--dismiss sel] [--out dir] [--profile dir]
21
24
  voila review <video.mp4> [--frames 12] [--out dir]
25
+ voila login <url> [--profile dir] (sign in yourself; session is saved locally)
26
+ voila voices (list every narration voice, best first)
27
+ voila fork <video.mp4> [--url u] [--voice v] [--print] [--out dir]
28
+ voila rerender <dir> [--voice v] [--speed n] (needs --keep-frames on the original)
22
29
  voila skill (install the voila skill into ~/.claude/skills)
23
30
  voila serve (web UI, PORT env or --port)
24
31
  voila mcp (stdio MCP server)`;
@@ -35,6 +42,16 @@ const USAGE = `usage:
35
42
  require('./mcp');
36
43
  return;
37
44
  }
45
+ if (cmd === 'doctor' || cmd === '--version' || cmd === '-v') {
46
+ if (cmd !== 'doctor') { console.log(require('./package.json').version); return; }
47
+ console.error(`voila ${require('./package.json').version}\n`);
48
+ const r = await require('./doctor').doctor({ fix: !process.argv.includes('--check') });
49
+ process.exit(r.ready ? 0 : 1);
50
+ }
51
+ if (cmd === 'voices') {
52
+ console.log(require('./voices').format());
53
+ return;
54
+ }
38
55
  if (cmd === 'skill') {
39
56
  // Install the agent skill the way Clipy does: one command, lands in the
40
57
  // user's skills directory, every future session knows how to demo.
@@ -60,8 +77,62 @@ const USAGE = `usage:
60
77
  return;
61
78
  }
62
79
 
80
+ if (cmd === 'fork') {
81
+ // The recipe inside a voila MP4 is its source. Turn it back into a script
82
+ // and, unless --print, record it again (optionally against another URL).
83
+ const video = process.argv[3];
84
+ if (!video) { console.error(USAGE); process.exit(1); }
85
+ const { reviewDemo } = require('./review');
86
+ const info = await reviewDemo(video, { count: 3 });
87
+ if (!info.recipe || !info.recipe.steps) {
88
+ console.error('FAILED: no voila recipe found in that file (was it made by voila?)');
89
+ process.exit(1);
90
+ }
91
+ const stepsYaml = yaml.dump(info.recipe.steps);
92
+ if (process.argv.includes('--print')) { console.log(stepsYaml); return; }
93
+
94
+ const target = arg('--url', info.recipe.url);
95
+ const workDir = arg('--out', path.join(__dirname, 'recordings', `fork-${Date.now()}`));
96
+ fs.mkdirSync(workDir, { recursive: true });
97
+ fs.writeFileSync(path.join(workDir, 'steps.yaml'), stepsYaml);
98
+ console.error(`[voila] forking ${info.recipe.steps.length} steps onto ${target}`);
99
+
100
+ const { VoilaSession } = require('./recorder');
101
+ const { produceDemo } = require('./pipeline');
102
+ const s2 = new VoilaSession({
103
+ headless: !process.argv.includes('--headful'),
104
+ device: arg('--device', 'desktop'),
105
+ profileDir: arg('--profile', path.join(__dirname, 'profile')),
106
+ });
107
+ try {
108
+ const r = await produceDemo(s2, {
109
+ url: target, steps: yaml.load(stepsYaml), workDir,
110
+ narrate: !process.argv.includes('--no-narrate'),
111
+ voice: arg('--voice'), speed: Number(arg('--speed', '1')) || 1,
112
+ keepFrames: process.argv.includes('--keep-frames'),
113
+ onStatus: m => console.error('[voila]', m),
114
+ });
115
+ console.log(r.video);
116
+ } finally { await s2.close(); }
117
+ return;
118
+ }
119
+
120
+ if (cmd === 'rerender') {
121
+ const dir = process.argv[3];
122
+ if (!dir) { console.error(USAGE); process.exit(1); }
123
+ const { rerender } = require('./pipeline');
124
+ const r = await rerender(dir, {
125
+ voice: arg('--voice'),
126
+ speed: Number(arg('--speed', '1')) || 1,
127
+ narrate: !process.argv.includes('--no-narrate'),
128
+ onStatus: m => console.error('[voila]', m),
129
+ });
130
+ console.log(r.video);
131
+ return;
132
+ }
133
+
63
134
  const url = process.argv[3];
64
- if (!cmd || !url || !['outline', 'record'].includes(cmd)) {
135
+ if (!cmd || !url || !['outline', 'record', 'login'].includes(cmd)) {
65
136
  console.error(USAGE);
66
137
  process.exit(1);
67
138
  }
@@ -72,10 +143,18 @@ const USAGE = `usage:
72
143
  headless: !process.argv.includes('--headful'),
73
144
  device: arg('--device', 'desktop'),
74
145
  profileDir: arg('--profile', path.join(__dirname, 'profile')),
146
+ dismiss: !process.argv.includes('--no-dismiss'),
147
+ dismissSelector: arg('--dismiss'),
75
148
  });
76
149
 
77
150
  try {
78
- if (cmd === 'outline') {
151
+ if (cmd === 'login') {
152
+ const { login } = require('./auth');
153
+ console.error('[voila] opening a browser window. Sign in there, then press Enter here.');
154
+ const r = await login(session, url, { onStatus: m => console.error('[voila]', m) });
155
+ console.error(`[voila] ${r.reason}. Session saved to ${r.profileDir}`);
156
+ console.log(r.profileDir);
157
+ } else if (cmd === 'outline') {
79
158
  console.log(JSON.stringify(await outline(session, url), null, 2));
80
159
  } else {
81
160
  const stepsFile = arg('--steps');
@@ -86,6 +165,8 @@ const USAGE = `usage:
86
165
  url, steps, workDir,
87
166
  narrate: !process.argv.includes('--no-narrate'),
88
167
  voice: arg('--voice'),
168
+ speed: Number(arg('--speed', '1')) || 1,
169
+ keepFrames: process.argv.includes('--keep-frames'),
89
170
  onStatus: s => console.error('[voila]', s),
90
171
  });
91
172
  console.log(result.video);
package/consent.js ADDED
@@ -0,0 +1,97 @@
1
+ // Cookie and consent banners ruin a demo: they sit in frame for the whole
2
+ // recording. Dismiss them before the camera rolls.
3
+ //
4
+ // Privacy first: we look for "reject" / "necessary only" before "accept", so
5
+ // the recorded session declines non-essential cookies wherever that choice
6
+ // exists. Accepting is only a last resort to clear the overlay.
7
+
8
+ const REJECT = [
9
+ /^(reject|decline|refuse)( all)?$/i,
10
+ /necessary (cookies )?only/i,
11
+ /^only (essential|necessary|required)/i,
12
+ /^(essential|required) (cookies )?only/i,
13
+ /continue without accepting/i,
14
+ /^reject non-essential/i,
15
+ ];
16
+
17
+ const ACCEPT = [
18
+ /^(accept|allow|agree)( all| cookies)?$/i,
19
+ /^(ok|got it|i understand|understood)$/i,
20
+ /^(dismiss|close)$/i,
21
+ ];
22
+
23
+ // Runs in the page. Returns the label it clicked, or null.
24
+ const dismissInPage = ([rejectSrc, acceptSrc]) => {
25
+ const toRe = arr => arr.map(([s, f]) => new RegExp(s, f));
26
+ const reject = toRe(rejectSrc), accept = toRe(acceptSrc);
27
+
28
+ const visible = el => {
29
+ const r = el.getBoundingClientRect();
30
+ const cs = getComputedStyle(el);
31
+ return r.width > 20 && r.height > 12 && cs.visibility !== 'hidden' && cs.opacity !== '0';
32
+ };
33
+
34
+ // Only consider controls that live inside something banner-shaped, so we
35
+ // never click an "Accept" button that is part of the product itself.
36
+ const looksLikeBanner = el => {
37
+ const box = el.closest('[class*="cookie" i],[id*="cookie" i],[class*="consent" i],[id*="consent" i],[class*="gdpr" i],[id*="gdpr" i],[aria-label*="cookie" i],[role="dialog"],[class*="banner" i]');
38
+ if (box) return true;
39
+ // Or a fixed-position bar pinned to an edge of the viewport.
40
+ let n = el;
41
+ for (let i = 0; i < 6 && n; i++, n = n.parentElement) {
42
+ const cs = getComputedStyle(n);
43
+ if (cs.position === 'fixed' || cs.position === 'sticky') {
44
+ const r = n.getBoundingClientRect();
45
+ if (r.width > innerWidth * 0.5 && (r.bottom > innerHeight * 0.6 || r.top < innerHeight * 0.4)) return true;
46
+ }
47
+ }
48
+ return false;
49
+ };
50
+
51
+ const controls = [...document.querySelectorAll('button,a[role="button"],[role="button"],input[type="button"],input[type="submit"]')]
52
+ .filter(visible).filter(looksLikeBanner);
53
+
54
+ for (const patterns of [reject, accept]) {
55
+ for (const el of controls) {
56
+ const label = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
57
+ if (!label || label.length > 40) continue;
58
+ if (patterns.some(re => re.test(label))) { el.click(); return label; }
59
+ }
60
+ }
61
+ return null;
62
+ };
63
+
64
+ async function dismissConsent(page, { selector = null, timeout = 2500 } = {}) {
65
+ const results = [];
66
+ if (selector) {
67
+ const el = page.locator(selector).first();
68
+ try {
69
+ await el.waitFor({ state: 'visible', timeout });
70
+ await el.click({ timeout: 2000 });
71
+ results.push(`custom: ${selector}`);
72
+ } catch { /* nothing matched the override */ }
73
+ }
74
+
75
+ const src = [
76
+ REJECT.map(r => [r.source, r.flags]),
77
+ ACCEPT.map(r => [r.source, r.flags]),
78
+ ];
79
+
80
+ // Banners often mount late, and some sites stack two of them.
81
+ for (let attempt = 0; attempt < 3; attempt++) {
82
+ let clicked = null;
83
+ try {
84
+ clicked = await page.evaluate(dismissInPage, src);
85
+ for (const frame of page.frames()) {
86
+ if (clicked || frame === page.mainFrame()) continue;
87
+ clicked = await frame.evaluate(dismissInPage, src).catch(() => null);
88
+ }
89
+ } catch { /* page navigated mid-check */ }
90
+ if (clicked) results.push(clicked);
91
+ await page.waitForTimeout(attempt === 0 ? 700 : 500);
92
+ if (!clicked && attempt > 0) break;
93
+ }
94
+ return results;
95
+ }
96
+
97
+ module.exports = { dismissConsent };
package/doctor.js ADDED
@@ -0,0 +1,118 @@
1
+ // Pre-flight: tell people what voila needs, what is already on disk, and
2
+ // download the rest with visible progress. The first run used to be several
3
+ // silent minutes, which reads as a hang.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const { execFile, execFileSync } = require('child_process');
9
+
10
+ const MB = n => `${(n / 1e6).toFixed(0)}MB`;
11
+
12
+ function chromiumPath() {
13
+ try {
14
+ const { chromium } = require('playwright');
15
+ return chromium.executablePath();
16
+ } catch { return null; }
17
+ }
18
+
19
+ function chromiumReady() {
20
+ const p = chromiumPath();
21
+ return !!(p && fs.existsSync(p));
22
+ }
23
+
24
+ // transformers.js caches models under ~/.cache/huggingface by default.
25
+ function kokoroCacheDir() {
26
+ return require('./modelcache').CACHE_DIR;
27
+ }
28
+
29
+ function kokoroReady() {
30
+ const dir = kokoroCacheDir();
31
+ if (!fs.existsSync(dir)) return false;
32
+ const hit = [];
33
+ const walk = (d, depth = 0) => {
34
+ if (depth > 4 || hit.length) return;
35
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
36
+ if (hit.length) return;
37
+ const full = path.join(d, e.name);
38
+ if (e.isDirectory()) walk(full, depth + 1);
39
+ else if (/\.onnx(_data)?$/.test(e.name) && fs.statSync(full).size > 5e6) hit.push(full);
40
+ }
41
+ };
42
+ try { walk(dir); } catch { /* unreadable cache */ }
43
+ return hit.length > 0;
44
+ }
45
+
46
+ function ffmpegReady() {
47
+ try { return fs.existsSync(require('ffmpeg-static')); } catch { return false; }
48
+ }
49
+
50
+ // Install Chromium with its progress bar visible instead of swallowed.
51
+ function installChromium({ quiet = false } = {}) {
52
+ let cliPath;
53
+ try { cliPath = require.resolve('playwright/cli'); }
54
+ catch { cliPath = path.join(path.dirname(require.resolve('playwright')), 'cli.js'); }
55
+ execFileSync(process.execPath, [cliPath, 'install', 'chromium'], {
56
+ stdio: quiet ? 'pipe' : ['ignore', 'inherit', 'inherit'],
57
+ timeout: 900000,
58
+ });
59
+ }
60
+
61
+ async function warmKokoro(onStatus = () => {}) {
62
+ require('./modelcache').useStableCache();
63
+ const { KokoroTTS } = require('kokoro-js');
64
+ let lastPct = -5;
65
+ const tts = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', {
66
+ dtype: 'q8',
67
+ progress_callback: p => {
68
+ if (p.status === 'download') return onStatus(`fetching ${p.file}`);
69
+ if (p.status !== 'progress') return;
70
+ // Hugging Face omits content-length on some files, so fall back to bytes.
71
+ if (typeof p.total === 'number' && p.total > 0) {
72
+ const pct = Math.floor((p.progress || 0) / 5) * 5;
73
+ if (pct > lastPct) { lastPct = pct; onStatus(`${p.file || 'model'} ${pct}% of ${MB(p.total)}`); }
74
+ } else if (typeof p.loaded === 'number') {
75
+ const step = Math.floor(p.loaded / 1e7);
76
+ if (step > lastPct) { lastPct = step; onStatus(`${p.file || 'model'} ${MB(p.loaded)} downloaded`); }
77
+ }
78
+ },
79
+ });
80
+ // Force one tiny synthesis so the voice files are fetched too.
81
+ await tts.generate('Ready.', { voice: 'af_heart' });
82
+ return true;
83
+ }
84
+
85
+ async function doctor({ fix = true, log = console.error } = {}) {
86
+ const nodeOk = Number(process.versions.node.split('.')[0]) >= 20;
87
+ log(`node ${process.versions.node} ${nodeOk ? 'ok' : 'TOO OLD, voila needs >= 20'}`);
88
+ log(`ffmpeg ${ffmpegReady() ? 'bundled, ok' : 'MISSING (reinstall voila-recorder)'}`);
89
+
90
+ let chrome = chromiumReady();
91
+ log(`chromium ${chrome ? 'installed' : 'not installed (~150MB download)'}`);
92
+ if (!chrome && fix) {
93
+ log('\ndownloading chromium...');
94
+ installChromium();
95
+ chrome = chromiumReady();
96
+ log(`chromium ${chrome ? 'installed' : 'FAILED'}`);
97
+ }
98
+
99
+ let voice = kokoroReady();
100
+ log(`voice model ${voice ? `cached in ${kokoroCacheDir()}` : 'not cached (~90MB download, first narration only)'}`);
101
+ if (!voice && fix) {
102
+ log('\nfetching the voice model...');
103
+ try {
104
+ await warmKokoro(m => log(` ${m}`));
105
+ voice = true;
106
+ log('voice model cached');
107
+ } catch (e) {
108
+ log(`voice model FAILED: ${e.message.slice(0, 120)}`);
109
+ log('(recording still works with --no-narrate)');
110
+ }
111
+ }
112
+
113
+ const ready = nodeOk && ffmpegReady() && chrome;
114
+ log(`\n${ready ? 'voila is ready. Try: voila record https://example.com' : 'voila is not ready yet, see above.'}`);
115
+ return { nodeOk, ffmpeg: ffmpegReady(), chromium: chrome, voice, ready };
116
+ }
117
+
118
+ module.exports = { doctor, chromiumReady, kokoroReady, installChromium, warmKokoro };
package/mcp.js CHANGED
@@ -13,6 +13,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
13
13
  const { VoilaSession } = require('./recorder');
14
14
  const { produceDemo, outline } = require('./pipeline');
15
15
  const { reviewDemo } = require('./review');
16
+ const voiceCatalogue = require('./voices');
16
17
 
17
18
  // One persistent Chromium profile can't be opened twice, so browser work is
18
19
  // serialized through a queue: concurrent tool calls wait instead of colliding.
@@ -36,7 +37,7 @@ function getSession(device) {
36
37
  return sessions.get(key);
37
38
  }
38
39
 
39
- const server = new McpServer({ name: 'voila', version: '0.4.0' });
40
+ const server = new McpServer({ name: 'voila', version: '0.6.0' });
40
41
  const deviceParam = z.enum(['desktop', 'mobile', 'tablet']).optional().default('desktop');
41
42
 
42
43
  server.tool(
@@ -55,7 +56,8 @@ server.tool(
55
56
  'Record a crisp, auto-zoomed MP4 demo of a website — no screen capture, no permissions. ' +
56
57
  'Without steps_yaml it runs a generic auto-tour. For a proper demo, pass steps_yaml: a YAML list of ' +
57
58
  '{action, selector?, url?, text?, title?, subtitle?, accent?, level?, ms?, caption?, narration?, optional?}. ' +
58
- 'Actions: goto, click, hover, type, scroll, scroll_to, slide (animated full-screen title card: title/subtitle/accent), zoom, wait. ' +
59
+ 'Actions: goto, click, hover, type, scroll, scroll_to, slide (animated full-screen title card: title/subtitle/accent), zoom, wait. ' +
60
+ 'zoom accepts either level (1-3) or, better, selector: it then frames that element, choosing the level and camera centre for you. ' +
59
61
  'caption is burned into the video as a lower-third; narration is spoken via on-device TTS (Kokoro) at that step, ' +
60
62
  'and segment pacing automatically stretches to fit each narration clip — no need to pad waits. ' +
61
63
  'Steps marked optional:true are skipped on failure instead of aborting. ' +
@@ -66,15 +68,16 @@ server.tool(
66
68
  url: z.string().url(),
67
69
  steps_yaml: z.string().optional(),
68
70
  narrate: z.boolean().optional().default(true),
69
- voice: z.string().optional(),
71
+ voice: z.string().optional().describe('narration voice id, e.g. af_heart (A), af_bella (A-), bf_emma (B-, British). Call voila_voices for the full list.'),
72
+ speed: z.number().min(0.5).max(1.6).optional().default(1).describe('narration speed; 0.9 reads calmer'),
70
73
  device: deviceParam,
71
74
  },
72
- async ({ url, steps_yaml, narrate, voice, device }) => enqueue(async () => {
75
+ async ({ url, steps_yaml, narrate, voice, speed, device }) => enqueue(async () => {
73
76
  const steps = steps_yaml ? yaml.load(steps_yaml) : null;
74
77
  const workDir = path.join(__dirname, 'recordings', `mcp-${Date.now()}`);
75
78
  fs.mkdirSync(workDir, { recursive: true });
76
79
  const result = await produceDemo(getSession(device), {
77
- url, steps, workDir, narrate, voice: voice || null,
80
+ url, steps, workDir, narrate, voice: voice || null, speed,
78
81
  onStatus: () => {},
79
82
  });
80
83
  return {
@@ -111,6 +114,37 @@ server.tool(
111
114
  })
112
115
  );
113
116
 
117
+ server.tool(
118
+ 'voila_voices',
119
+ 'List every narration voice with its quality grade, best first. Use before voila_record when the ' +
120
+ 'user asks for a different voice, an accent, or a male or female narrator.',
121
+ {},
122
+ async () => ({
123
+ content: [{ type: 'text', text: JSON.stringify(voiceCatalogue.ranked(), null, 2) }],
124
+ })
125
+ );
126
+
127
+ server.tool(
128
+ 'voila_login',
129
+ 'Open a real browser window so the PERSON can sign in to their product themselves. The session is ' +
130
+ 'saved to a local Chromium profile and every later recording of that site is already logged in. ' +
131
+ 'Call this when voila_record fails saying it hit a sign-in page. voila never sees or types ' +
132
+ 'credentials: you are only opening the window for the human. Requires a desktop session; tell the ' +
133
+ 'user to watch for the window.',
134
+ { url: z.string().url(), device: deviceParam },
135
+ async ({ url, device }) => enqueue(async () => {
136
+ const s = getSession(device);
137
+ const { login } = require('./auth');
138
+ const r = await login(s, url);
139
+ return {
140
+ content: [{ type: 'text', text: JSON.stringify({
141
+ result: r.reason, profile: r.profileDir, endedOn: r.finalUrl,
142
+ next: 'Re-run voila_record; the recording will reuse this signed-in profile.',
143
+ }, null, 2) }],
144
+ };
145
+ })
146
+ );
147
+
114
148
  (async () => {
115
149
  await server.connect(new StdioServerTransport());
116
150
  })().catch(e => { console.error(e); process.exit(1); });
package/modelcache.js ADDED
@@ -0,0 +1,22 @@
1
+ // transformers.js caches models inside its own node_modules folder by default,
2
+ // so every voila upgrade (and every fresh npx hash) would re-download ~90MB.
3
+ // Pin the cache to a stable per-user directory instead.
4
+
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const CACHE_DIR = process.env.VOILA_MODEL_DIR
9
+ || path.join(os.homedir(), '.cache', 'voila', 'models');
10
+
11
+ let applied = false;
12
+ function useStableCache() {
13
+ if (applied) return CACHE_DIR;
14
+ try {
15
+ const { env } = require('@huggingface/transformers');
16
+ env.cacheDir = CACHE_DIR;
17
+ applied = true;
18
+ } catch { /* transformers not resolvable; kokoro will use its default */ }
19
+ return CACHE_DIR;
20
+ }
21
+
22
+ module.exports = { useStableCache, CACHE_DIR };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "voila-recorder",
3
- "version": "0.4.1",
4
- "description": "Permission-free product demo recorder: URL in, narrated auto-zoomed MP4 out — with the recipe embedded in the video. Agent-native (MCP), fully on-device.",
3
+ "version": "0.6.0",
4
+ "description": "Permission-free product demo recorder: URL in, narrated auto-zoomed MP4 out \u2014 with the recipe embedded in the video. Agent-native (MCP), fully on-device.",
5
5
  "license": "MIT",
6
6
  "main": "pipeline.js",
7
7
  "bin": {
@@ -18,11 +18,17 @@
18
18
  "render.js",
19
19
  "audio.js",
20
20
  "review.js",
21
+ "modelcache.js",
22
+ "consent.js",
23
+ "doctor.js",
24
+ "voices.js",
25
+ "auth.js",
21
26
  "public/",
22
27
  "skills/",
23
28
  "AGENTS.md",
24
29
  "README.md",
25
- "RECIPE.md"
30
+ "RECIPE.md",
31
+ "scripts/"
26
32
  ],
27
33
  "repository": {
28
34
  "type": "git",
@@ -56,4 +62,4 @@
56
62
  "playwright": "^1.48.0",
57
63
  "sharp": "^0.33.5"
58
64
  }
59
- }
65
+ }
package/pipeline.js CHANGED
@@ -44,7 +44,7 @@ function embedRecipe(videoIn, videoOut, recipe) {
44
44
  });
45
45
  }
46
46
 
47
- async function produceDemo(session, { url, mode = 'auto', steps = null, workDir, voice = null, narrate = true, onStatus = () => {} }) {
47
+ async function produceDemo(session, { url, mode = 'auto', steps = null, workDir, voice = null, speed = 1, narrate = true, keepFrames = false, onStatus = () => {} }) {
48
48
  // Steps mode: synthesize narration BEFORE recording so segment pacing and
49
49
  // caption lifetimes match the spoken clip durations exactly.
50
50
  let prepared = null;
@@ -52,7 +52,7 @@ async function produceDemo(session, { url, mode = 'auto', steps = null, workDir,
52
52
  const texts = steps.filter(s => s.narration).map(s => s.narration);
53
53
  if (texts.length) {
54
54
  try {
55
- prepared = await prepareNarration(texts, path.join(workDir, 'tts'), voice, onStatus);
55
+ prepared = await prepareNarration(texts, path.join(workDir, 'tts'), voice, onStatus, speed);
56
56
  let i = 0;
57
57
  for (const s of steps) if (s.narration) s._narrDurMs = prepared.clips[i++].durMs;
58
58
  } catch (e) {
@@ -69,7 +69,7 @@ async function produceDemo(session, { url, mode = 'auto', steps = null, workDir,
69
69
  await render(meta, raw, { onStatus });
70
70
 
71
71
  let narration = { narrated: false };
72
- if (narrate) narration = await addNarration(meta, raw, narrated, { voice, prepared, onStatus });
72
+ if (narrate) narration = await addNarration(meta, raw, narrated, { voice, speed, prepared, onStatus });
73
73
  else fs.copyFileSync(raw, narrated);
74
74
 
75
75
  const recipe = buildRecipe({ url, mode, steps, meta });
@@ -77,10 +77,40 @@ async function produceDemo(session, { url, mode = 'auto', steps = null, workDir,
77
77
  onStatus('embedding recipe');
78
78
  await embedRecipe(narrated, out, recipe);
79
79
 
80
- fs.rmSync(meta.framesDir, { recursive: true, force: true });
80
+ // Frames are the expensive part to recreate: keeping them lets `voila
81
+ // rerender` change the voice, speed or captions in seconds instead of
82
+ // re-driving the browser. They are large, so it is opt-in.
83
+ if (!keepFrames) fs.rmSync(meta.framesDir, { recursive: true, force: true });
81
84
  fs.rmSync(raw, { force: true });
82
85
  fs.rmSync(narrated, { force: true });
83
- return { video: out, recipe: path.join(workDir, 'recipe.json'), meta, narration };
86
+ return { video: out, recipe: path.join(workDir, 'recipe.json'), meta, narration, framesKept: keepFrames };
87
+ }
88
+
89
+ // Re-produce the video from frames already on disk: no browser, no re-driving
90
+ // the page. Used to swap the narration voice or speed after the fact.
91
+ async function rerender(workDir, { voice = null, speed = 1, narrate = true, onStatus = () => {} } = {}) {
92
+ const metaPath = path.join(workDir, 'meta.json');
93
+ if (!fs.existsSync(metaPath)) throw new Error(`no meta.json in ${workDir}`);
94
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
95
+ if (!fs.existsSync(meta.framesDir) || !fs.readdirSync(meta.framesDir).length) {
96
+ throw new Error(`frames were not kept for this recording. Re-record with --keep-frames to enable rerender.`);
97
+ }
98
+
99
+ const raw = path.join(workDir, 'raw.mp4');
100
+ const narrated = path.join(workDir, 'narrated.mp4');
101
+ const out = path.join(workDir, 'demo.mp4');
102
+ await render(meta, raw, { onStatus });
103
+
104
+ let narration = { narrated: false };
105
+ if (narrate) narration = await addNarration(meta, raw, narrated, { voice, speed, onStatus });
106
+ else fs.copyFileSync(raw, narrated);
107
+
108
+ const recipe = JSON.parse(fs.readFileSync(path.join(workDir, 'recipe.json'), 'utf8'));
109
+ onStatus('embedding recipe');
110
+ await embedRecipe(narrated, out, recipe);
111
+ fs.rmSync(raw, { force: true });
112
+ fs.rmSync(narrated, { force: true });
113
+ return { video: out, meta, narration };
84
114
  }
85
115
 
86
116
  async function outline(session, url) {
@@ -88,4 +118,4 @@ async function outline(session, url) {
88
118
  return extractOutline(page);
89
119
  }
90
120
 
91
- module.exports = { produceDemo, outline };
121
+ module.exports = { produceDemo, rerender, outline };
package/recorder.js CHANGED
@@ -38,6 +38,7 @@ class Timeline {
38
38
  this.warnings = [];
39
39
  this.pos = { x: -60, y: -60 };
40
40
  this.zoom = 1;
41
+ this.center = null;
41
42
  }
42
43
  recordSegment(caption, narration, dur = null) {
43
44
  this.segments.push({ t: Date.now(), caption: caption || null, narration: narration || null, dur });
@@ -46,14 +47,21 @@ class Timeline {
46
47
  this.moves.push({ t: Date.now(), from: { ...this.pos }, to: { ...to }, dur });
47
48
  this.pos = { ...to };
48
49
  }
49
- recordZoom(level, dur) {
50
- this.zooms.push({ t: Date.now(), from: this.zoom, to: level, dur });
50
+ // center: {x,y} in CSS px to frame on, or null to follow the cursor.
51
+ recordZoom(level, dur, center = null) {
52
+ this.zooms.push({
53
+ t: Date.now(), from: this.zoom, to: level, dur,
54
+ fromCenter: this.center, toCenter: center,
55
+ });
51
56
  this.zoom = level;
57
+ this.center = center;
52
58
  }
53
59
  }
54
60
 
55
61
  class VoilaSession {
56
- constructor({ profileDir, headless = false, device = 'desktop' } = {}) {
62
+ constructor({ profileDir, headless = false, device = 'desktop', dismiss = true, dismissSelector = null } = {}) {
63
+ this.dismiss = dismiss;
64
+ this.dismissSelector = dismissSelector;
57
65
  this.profileDir = profileDir || path.join(__dirname, 'profile');
58
66
  this.headless = headless;
59
67
  this.device = DEVICES[device] ? device : 'desktop';
@@ -79,11 +87,9 @@ class VoilaSession {
79
87
  // Zero-install path: fetch Chromium on first use instead of making the
80
88
  // user run `npx playwright install` themselves.
81
89
  if (!/Executable doesn't exist|missing dependencies|browser.*not found/i.test(String(e.message))) throw e;
82
- const { execFileSync } = require('child_process');
83
- let cliPath;
84
- try { cliPath = require.resolve('playwright/cli'); }
85
- catch { cliPath = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'); }
86
- execFileSync(process.execPath, [cliPath, 'install', 'chromium'], { stdio: 'pipe', timeout: 600000 });
90
+ // Visible progress: a silent 150MB download reads as a hang.
91
+ process.stderr.write('[voila] first run: downloading Chromium (~150MB, one time)\n');
92
+ require('./doctor').installChromium();
87
93
  this.context = await launch();
88
94
  }
89
95
  await this.context.addInitScript(OVERLAY_SOURCE);
@@ -93,6 +99,10 @@ class VoilaSession {
93
99
  this.page.on('close', () => { this.page = null; });
94
100
  await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
95
101
  await this.page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
102
+ if (this.dismiss) {
103
+ const { dismissConsent } = require('./consent');
104
+ this.dismissed = await dismissConsent(this.page, { selector: this.dismissSelector });
105
+ }
96
106
  return this.page;
97
107
  }
98
108
 
@@ -108,6 +118,19 @@ class VoilaSession {
108
118
  this.page = page;
109
119
  await page.evaluate(OVERLAY_SOURCE);
110
120
 
121
+ // Recording a login screen is never what anyone wanted. Say so up front.
122
+ const { detectAuthWall } = require('./auth');
123
+ const wall = await detectAuthWall(page);
124
+ if (wall.isWall) {
125
+ throw new Error(
126
+ `this looks like a sign-in page, not your product (${wall.url}). ` +
127
+ `Sign in once with: voila login ${new URL(url).origin} ` +
128
+ `(a real browser window opens, you sign in yourself, the session is saved to ` +
129
+ `${this.profileDir}). Then re-run the recording. Pass --profile <dir> to keep ` +
130
+ `separate logins per product.`
131
+ );
132
+ }
133
+
111
134
  const client = await this.context.newCDPSession(page);
112
135
  const frames = [];
113
136
  const writes = [];
@@ -154,6 +177,7 @@ class VoilaSession {
154
177
  frames: frames.sort((a, b) => a.t - b.t),
155
178
  moves: tl.moves, zooms: tl.zooms, segments: tl.segments,
156
179
  warnings: tl.warnings || [],
180
+ dismissed: this.dismissed || [],
157
181
  framesDir,
158
182
  };
159
183
  fs.writeFileSync(path.join(workDir, 'meta.json'), JSON.stringify(meta));
package/render.js CHANGED
@@ -20,6 +20,22 @@ function scalarAt(events, t, initial) {
20
20
  return v;
21
21
  }
22
22
 
23
+ // Camera center: an explicit zoom target when one is set, otherwise the cursor.
24
+ function centerAt(zooms, t, cursor) {
25
+ let active = null;
26
+ for (const e of zooms) {
27
+ if (t < e.t) break;
28
+ if (!e.toCenter) { active = null; continue; }
29
+ const p = cubicInOut(Math.min(1, (t - e.t) / e.dur));
30
+ const from = e.fromCenter || cursor;
31
+ active = {
32
+ x: from.x + (e.toCenter.x - from.x) * p,
33
+ y: from.y + (e.toCenter.y - from.y) * p,
34
+ };
35
+ }
36
+ return active || cursor;
37
+ }
38
+
23
39
  function pointAt(events, t, initial) {
24
40
  let v = { ...initial };
25
41
  for (const e of events) {
@@ -125,10 +141,11 @@ async function render(meta, outFile, { fps = 30, outW = null, outH = null, onSta
125
141
 
126
142
  const z = Math.max(1, Math.min(3, scalarAt(zooms, t, 1)));
127
143
  const cur = pointAt(moves, t, { x: viewport.width / 2, y: viewport.height / 2 });
144
+ const cam = centerAt(zooms, t, cur);
128
145
  const caption = captionAt(meta.segments, t);
129
146
 
130
147
  const cropW = W / z, cropH = H / z;
131
- let cx = cur.x * sx, cy = cur.y * sy;
148
+ let cx = cam.x * sx, cy = cam.y * sy;
132
149
  cx = Math.max(cropW / 2, Math.min(W - cropW / 2, cx));
133
150
  cy = Math.max(cropH / 2, Math.min(H - cropH / 2, cy));
134
151
 
@@ -0,0 +1,14 @@
1
+ // CI helper: assert the rendered demo actually carries a narration track.
2
+ const { execFileSync } = require('child_process');
3
+ const ffmpeg = require('ffmpeg-static');
4
+
5
+ const file = process.argv[2];
6
+ let out = '';
7
+ try { execFileSync(ffmpeg, ['-i', file], { stdio: 'pipe' }); }
8
+ catch (e) { out = String(e.stderr || ''); }
9
+
10
+ if (!/Audio: aac/.test(out)) {
11
+ console.error(out || '(no ffmpeg output)');
12
+ throw new Error(`no narration track in ${file}`);
13
+ }
14
+ console.log(`narration track OK in ${file}`);
@@ -14,9 +14,12 @@ Prefer the MCP tools if registered (`voila_outline`, `voila_record`,
14
14
  `voila_review`); otherwise use the CLI via npx:
15
15
 
16
16
  ```bash
17
+ npx -y voila-recorder doctor # first run: pre-downloads chromium + voice model
17
18
  npx -y voila-recorder outline <url>
18
19
  npx -y voila-recorder record <url> --steps steps.yaml [--device mobile]
19
20
  npx -y voila-recorder review demo.mp4
21
+ npx -y voila-recorder fork demo.mp4 --url https://other.example
22
+ npx -y voila-recorder rerender <dir> --voice bf_emma
20
23
  ```
21
24
 
22
25
  Register the MCP server once with: `claude mcp add voila -- npx -y voila-recorder mcp`
@@ -31,7 +34,8 @@ Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/vo
31
34
  clip's length — do NOT pad waits for narration). Open and close with a
32
35
  `slide` (animated title card: `title`, `subtitle`, `accent` hex).
33
36
  Actions: goto, click, hover, type, scroll (y), scroll_to (selector),
34
- slide, zoom (level), wait. Mark risky steps `optional: true`.
37
+ slide, zoom (level OR selector to frame an element), wait. Mark risky steps
38
+ `optional: true`.
35
39
  3. **Record** (`voila_record` / `record --steps`).
36
40
  4. **Review your own output** (`voila_review`) → frames + timeline. Read the
37
41
  frames. Check: cursor near what narration discusses; captions not covering
@@ -52,8 +56,24 @@ Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/vo
52
56
  (834x1112). Zoom is auto-disabled on mobile and tablet because narrow
53
57
  layouts crop badly; both output portrait.
54
58
  - Zoom levels 1.3–1.6 on desktop; always return to 1 before ending.
55
- - Login-protected apps: ask the user to run the record once with `--headful`,
56
- or open the web UI (`npx -y voila-recorder serve`, port 4477) and sign in —
57
- the session persists in the local profile. NEVER type credentials yourself.
59
+ - Login-protected apps: recording a sign-in page is refused with a clear error.
60
+ Run `voila login <url>` (or the voila_login tool): a real browser window
61
+ opens, the USER signs in themselves, and the session is saved to a local
62
+ Chromium profile every later recording reuses. Use `--profile <dir>` for
63
+ separate logins per product. NEVER type credentials yourself and never ask
64
+ for them in chat.
65
+ - Prefer `zoom` with a `selector` over a bare `level`: voila measures the
66
+ element and picks the level and camera centre, so nothing is cropped.
67
+ - Voices: `voila voices` lists 28 English voices with quality grades. af_heart
68
+ (A) default, af_bella (A-), af_nicole (B-), bf_emma (B-, British). `--speed`
69
+ or the speed param (0.5-1.6) changes pace; 0.9 reads calmer.
70
+ - A failing step is retried once automatically; warnings appear in the result.
71
+ - Iterating on narration? Record once with `--keep-frames`, then `rerender <dir>
72
+ --voice x --speed n`. It skips the browser entirely and finishes in seconds.
73
+ - Consent banners are auto-dismissed (reject preferred over accept). Use
74
+ `--dismiss <selector>` for an unusual one, `--no-dismiss` to leave it alone.
75
+ - First run on a new machine downloads ~240MB. Run `voila doctor` first and tell
76
+ the user it is downloading, so it does not look frozen.
77
+ - `fork <video.mp4>` rebuilds any voila demo from the recipe inside the file.
58
78
  - Narration style: short sentences, product language, no "as you can see".
59
79
  8–15 words per beat reads best at Kokoro's pace.
package/tour.js CHANGED
@@ -15,11 +15,33 @@ async function moveCursor(page, tl, x, y, dur = 700) {
15
15
  await page.evaluate(([x, y, d]) => window.__voila.moveTo(x, y, d), [x, y, dur]);
16
16
  }
17
17
 
18
- async function zoomTo(page, tl, level, dur = 800, { sleep, maxZoom = 3 }) {
19
- tl.recordZoom(Math.min(level, maxZoom), dur);
18
+ async function zoomTo(page, tl, level, dur = 800, { sleep, maxZoom = 3 }, center = null) {
19
+ tl.recordZoom(Math.min(level, maxZoom), dur, center);
20
20
  await sleep(dur);
21
21
  }
22
22
 
23
+ // Frame an element: pick the zoom level that fits its box with breathing room,
24
+ // and centre the camera on the element instead of wherever the cursor happens
25
+ // to be. Returns {level, center} clamped so the crop never leaves the viewport.
26
+ function frameElement(box, viewport, maxZoom, fill = 0.72) {
27
+ const padX = viewport.width * 0.06, padY = viewport.height * 0.06;
28
+ const level = Math.max(1, Math.min(
29
+ maxZoom,
30
+ Math.min(
31
+ (viewport.width * fill) / Math.max(80, box.width + padX * 2),
32
+ (viewport.height * fill) / Math.max(60, box.height + padY * 2)
33
+ )
34
+ ));
35
+ const halfW = viewport.width / level / 2, halfH = viewport.height / level / 2;
36
+ return {
37
+ level,
38
+ center: {
39
+ x: Math.max(halfW, Math.min(viewport.width - halfW, box.x + box.width / 2)),
40
+ y: Math.max(halfH, Math.min(viewport.height - halfH, box.y + box.height / 2)),
41
+ },
42
+ };
43
+ }
44
+
23
45
  async function smoothScroll(page, tl, y, dur = 1100) {
24
46
  await ensureOverlay(page, tl);
25
47
  await page.evaluate(([y, d]) => window.__voila.scrollToY(y, d), [y, dur]);
@@ -181,11 +203,26 @@ async function autoTour(page, tl, opts) {
181
203
  // YAML: a list of {action, selector?, text?, url?, ms?, level?}
182
204
  // actions: goto, click, hover, type, scroll_to, wait, zoom
183
205
 
184
- async function targetBox(page, selector) {
185
- let box = await page.locator(selector).first().boundingBox().catch(() => null);
186
- if (!box) box = await page.locator(`${selector} >> visible=true`).first().boundingBox().catch(() => null);
187
- if (!box) throw new Error(`selector not found or not visible: ${selector}`);
188
- return { box, c: { x: box.x + box.width / 2, y: box.y + box.height / 2 } };
206
+ // Resolve a selector into an on-screen box, giving the page a fair chance:
207
+ // wait for it to attach and become visible, try the visible-only variant, and
208
+ // scroll it into view. Only then give up.
209
+ async function targetBox(page, selector, { timeout = 6000 } = {}) {
210
+ const tries = [selector, `${selector} >> visible=true`];
211
+ for (const sel of tries) {
212
+ const loc = page.locator(sel).first();
213
+ try {
214
+ await loc.waitFor({ state: 'visible', timeout: timeout / tries.length });
215
+ } catch { continue; }
216
+ let box = await loc.boundingBox().catch(() => null);
217
+ if (!box || box.y < 0 || box.y > page.viewportSize().height) {
218
+ await loc.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
219
+ box = await loc.boundingBox().catch(() => null);
220
+ }
221
+ if (box && box.width > 0 && box.height > 0) {
222
+ return { box, c: { x: box.x + box.width / 2, y: box.y + box.height / 2 } };
223
+ }
224
+ }
225
+ throw new Error(`selector not found or not visible: ${selector}`);
189
226
  }
190
227
 
191
228
  async function runSteps(page, tl, steps, opts) {
@@ -209,7 +246,7 @@ async function runSteps(page, tl, steps, opts) {
209
246
  segStart = Date.now();
210
247
  segMinMs = (step._narrDurMs || 0) + 600;
211
248
  }
212
- try {
249
+ const runStep = async () => {
213
250
  switch (step.action) {
214
251
  case 'goto':
215
252
  await page.goto(step.url, { waitUntil: 'domcontentloaded' });
@@ -268,9 +305,19 @@ async function runSteps(page, tl, steps, opts) {
268
305
  });
269
306
  break;
270
307
  }
271
- case 'zoom':
272
- await zoomTo(page, tl, step.level || 1.5, step.ms || 800, opts);
308
+ case 'zoom': {
309
+ if (step.selector) {
310
+ const { box } = await targetBox(page, step.selector);
311
+ const vp = page.viewportSize();
312
+ const f = frameElement(box, vp, opts.maxZoom ?? 3, step.fill || 0.72);
313
+ await moveCursor(page, tl, f.center.x, f.center.y, 600);
314
+ await zoomTo(page, tl, step.level || f.level, step.ms || 900, opts, f.center);
315
+ } else {
316
+ // no selector: keep following the cursor
317
+ await zoomTo(page, tl, step.level || 1.5, step.ms || 800, opts, null);
318
+ }
273
319
  break;
320
+ }
274
321
  case 'wait': {
275
322
  const ms = step.ms || 1000;
276
323
  if (ms > 2600) {
@@ -287,6 +334,19 @@ async function runSteps(page, tl, steps, opts) {
287
334
  default:
288
335
  throw new Error(`unknown action: ${step.action}`);
289
336
  }
337
+ };
338
+
339
+ try {
340
+ try {
341
+ await runStep();
342
+ } catch (first) {
343
+ // Recovery: pages settle late, hydrate, animate. Give the step one
344
+ // more go after a beat before calling it a failure.
345
+ tl.warnings.push(`step ${si + 1} (${step.action}) retried after: ${first.message.slice(0, 120)}`);
346
+ await sleep(1200);
347
+ await ensureOverlay(page, tl).catch(() => {});
348
+ await runStep();
349
+ }
290
350
  } catch (e) {
291
351
  if (step.optional) {
292
352
  tl.warnings.push(`step ${si + 1} (${step.action}) skipped: ${e.message}`);
package/voices.js ADDED
@@ -0,0 +1,58 @@
1
+ // The voice catalogue, read straight from the installed Kokoro package so it
2
+ // can never drift from what the model can actually speak.
3
+
4
+ let cache = null;
5
+
6
+ function allVoices() {
7
+ if (cache) return cache;
8
+ const fs = require('fs');
9
+ // kokoro-js blocks deep subpath resolution, so locate the CJS bundle that
10
+ // `require` itself resolves to.
11
+ const bundle = require.resolve('kokoro-js');
12
+ const src = fs.readFileSync(bundle, 'utf8');
13
+ const re = /([a-z]{2}_[a-z]+):\{name:"([^"]+)",language:"([^"]+)",gender:"([^"]+)"(?:,traits:"[^"]*")?,targetQuality:"([^"]+)",overallGrade:"([^"]+)"\}/g;
14
+ const out = [];
15
+ let m;
16
+ while ((m = re.exec(src))) {
17
+ out.push({ id: m[1], name: m[2], language: m[3], gender: m[4], grade: m[6] });
18
+ }
19
+ cache = out;
20
+ return out;
21
+ }
22
+
23
+ const gradeRank = g => {
24
+ const base = { A: 0, B: 1, C: 2, D: 3, F: 4 }[g[0]] ?? 5;
25
+ const mod = g[1] === '+' ? -0.3 : g[1] === '-' ? 0.3 : 0;
26
+ return base + mod;
27
+ };
28
+
29
+ // Best first, so `voila voices` reads as a recommendation list.
30
+ function ranked() {
31
+ return allVoices().slice().sort((a, b) => gradeRank(a.grade) - gradeRank(b.grade) || a.id.localeCompare(b.id));
32
+ }
33
+
34
+ function isValid(id) {
35
+ return allVoices().some(v => v.id === id);
36
+ }
37
+
38
+ function suggest(id) {
39
+ const near = allVoices().filter(v => v.id.includes(String(id).replace(/^[a-z]{2}_/, '')));
40
+ return (near.length ? near : ranked().slice(0, 4)).map(v => v.id);
41
+ }
42
+
43
+ function format() {
44
+ const rows = ranked();
45
+ const byLang = {};
46
+ for (const v of rows) (byLang[v.language] = byLang[v.language] || []).push(v);
47
+ const lines = [];
48
+ for (const [lang, vs] of Object.entries(byLang)) {
49
+ lines.push(`\n${lang} (${vs.length} voices, best first)`);
50
+ for (const v of vs) {
51
+ lines.push(` ${v.id.padEnd(13)} ${v.grade.padEnd(3)} ${v.gender.padEnd(7)} ${v.name}`);
52
+ }
53
+ }
54
+ lines.push('\nUse with: --voice af_bella (also --speed 0.9 to slow the delivery)');
55
+ return lines.join('\n');
56
+ }
57
+
58
+ module.exports = { allVoices, ranked, isValid, suggest, format };