voila-recorder 0.5.0 → 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
@@ -24,6 +24,9 @@ npx -y voila-recorder record <url> --steps steps.yaml [--device mobile]
24
24
  npx -y voila-recorder review demo.mp4
25
25
  npx -y voila-recorder voices
26
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
27
30
  ```
28
31
 
29
32
  ## The loop — always follow it
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/audio.js CHANGED
@@ -22,6 +22,7 @@ async function getKokoro() {
22
22
  // Must load the CJS build (exports map: require → dist/kokoro.cjs): it
23
23
  // resolves bundled voice files via __dirname, while the ESM build loses
24
24
  // __dirname and breaks when cwd isn't the package root.
25
+ require('./modelcache').useStableCache();
25
26
  const { KokoroTTS } = require('kokoro-js');
26
27
  kokoroInstance = await KokoroTTS.from_pretrained(
27
28
  'onnx-community/Kokoro-82M-v1.0-ONNX', { dtype: 'q8' }
package/cli.js CHANGED
@@ -18,11 +18,14 @@ function arg(name, fallback = null) {
18
18
  }
19
19
 
20
20
  const USAGE = `usage:
21
+ voila doctor (check + download everything voila needs)
21
22
  voila outline <url> [--device desktop|mobile|tablet]
22
- voila record <url> [--steps f.yaml] [--device mobile] [--voice name] [--speed 1] [--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]
23
24
  voila review <video.mp4> [--frames 12] [--out dir]
24
25
  voila login <url> [--profile dir] (sign in yourself; session is saved locally)
25
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)
26
29
  voila skill (install the voila skill into ~/.claude/skills)
27
30
  voila serve (web UI, PORT env or --port)
28
31
  voila mcp (stdio MCP server)`;
@@ -39,6 +42,12 @@ const USAGE = `usage:
39
42
  require('./mcp');
40
43
  return;
41
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
+ }
42
51
  if (cmd === 'voices') {
43
52
  console.log(require('./voices').format());
44
53
  return;
@@ -68,6 +77,60 @@ const USAGE = `usage:
68
77
  return;
69
78
  }
70
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
+
71
134
  const url = process.argv[3];
72
135
  if (!cmd || !url || !['outline', 'record', 'login'].includes(cmd)) {
73
136
  console.error(USAGE);
@@ -80,6 +143,8 @@ const USAGE = `usage:
80
143
  headless: !process.argv.includes('--headful'),
81
144
  device: arg('--device', 'desktop'),
82
145
  profileDir: arg('--profile', path.join(__dirname, 'profile')),
146
+ dismiss: !process.argv.includes('--no-dismiss'),
147
+ dismissSelector: arg('--dismiss'),
83
148
  });
84
149
 
85
150
  try {
@@ -101,6 +166,7 @@ const USAGE = `usage:
101
166
  narrate: !process.argv.includes('--no-narrate'),
102
167
  voice: arg('--voice'),
103
168
  speed: Number(arg('--speed', '1')) || 1,
169
+ keepFrames: process.argv.includes('--keep-frames'),
104
170
  onStatus: s => console.error('[voila]', s),
105
171
  });
106
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
@@ -37,7 +37,7 @@ function getSession(device) {
37
37
  return sessions.get(key);
38
38
  }
39
39
 
40
- const server = new McpServer({ name: 'voila', version: '0.5.0' });
40
+ const server = new McpServer({ name: 'voila', version: '0.6.0' });
41
41
  const deviceParam = z.enum(['desktop', 'mobile', 'tablet']).optional().default('desktop');
42
42
 
43
43
  server.tool(
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.5.0",
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,13 +18,17 @@
18
18
  "render.js",
19
19
  "audio.js",
20
20
  "review.js",
21
+ "modelcache.js",
22
+ "consent.js",
23
+ "doctor.js",
21
24
  "voices.js",
22
25
  "auth.js",
23
26
  "public/",
24
27
  "skills/",
25
28
  "AGENTS.md",
26
29
  "README.md",
27
- "RECIPE.md"
30
+ "RECIPE.md",
31
+ "scripts/"
28
32
  ],
29
33
  "repository": {
30
34
  "type": "git",
@@ -58,4 +62,4 @@
58
62
  "playwright": "^1.48.0",
59
63
  "sharp": "^0.33.5"
60
64
  }
61
- }
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, speed = 1, 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;
@@ -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
@@ -59,7 +59,9 @@ class Timeline {
59
59
  }
60
60
 
61
61
  class VoilaSession {
62
- 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;
63
65
  this.profileDir = profileDir || path.join(__dirname, 'profile');
64
66
  this.headless = headless;
65
67
  this.device = DEVICES[device] ? device : 'desktop';
@@ -85,11 +87,9 @@ class VoilaSession {
85
87
  // Zero-install path: fetch Chromium on first use instead of making the
86
88
  // user run `npx playwright install` themselves.
87
89
  if (!/Executable doesn't exist|missing dependencies|browser.*not found/i.test(String(e.message))) throw e;
88
- const { execFileSync } = require('child_process');
89
- let cliPath;
90
- try { cliPath = require.resolve('playwright/cli'); }
91
- catch { cliPath = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'); }
92
- 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();
93
93
  this.context = await launch();
94
94
  }
95
95
  await this.context.addInitScript(OVERLAY_SOURCE);
@@ -99,6 +99,10 @@ class VoilaSession {
99
99
  this.page.on('close', () => { this.page = null; });
100
100
  await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
101
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
+ }
102
106
  return this.page;
103
107
  }
104
108
 
@@ -173,6 +177,7 @@ class VoilaSession {
173
177
  frames: frames.sort((a, b) => a.t - b.t),
174
178
  moves: tl.moves, zooms: tl.zooms, segments: tl.segments,
175
179
  warnings: tl.warnings || [],
180
+ dismissed: this.dismissed || [],
176
181
  framesDir,
177
182
  };
178
183
  fs.writeFileSync(path.join(workDir, 'meta.json'), JSON.stringify(meta));
@@ -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`
@@ -65,5 +68,12 @@ Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/vo
65
68
  (A) default, af_bella (A-), af_nicole (B-), bf_emma (B-, British). `--speed`
66
69
  or the speed param (0.5-1.6) changes pace; 0.9 reads calmer.
67
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.
68
78
  - Narration style: short sentences, product language, no "as you can see".
69
79
  8–15 words per beat reads best at Kokoro's pace.