voila-recorder 0.4.0 → 0.5.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,8 @@ 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>
25
27
  ```
26
28
 
27
29
  ## The loop — always follow it
@@ -45,13 +47,19 @@ npx -y voila-recorder review demo.mp4
45
47
  duplicate elements.
46
48
  - Selector failures name the failing step and include the live page outline —
47
49
  patch, don't retry blindly.
48
- - `--device mobile` records a real iPhone-class viewport (portrait; zoom is
49
- disabled on purpose — mobile layouts crop badly).
50
+ - `--device mobile` and `--device tablet` record real portrait viewports. Zoom is disabled on both because narrow layouts crop badly.
50
51
  - Login-protected apps: ask the human to sign in once
51
52
  (`npx -y voila-recorder record <url> --headful` or the web UI via
52
53
  `npx -y voila-recorder serve`). The session persists in a local browser
53
54
  profile. **Never type credentials yourself.**
54
- - Narration style: short sentences, product language, 8–15 words per beat.
55
+ - Narration style: short sentences, product language, 8-15 words per beat.
56
+ - Prefer `zoom` with a `selector` over a raw `level`: voila measures the element
57
+ and picks the level and camera centre so nothing gets cropped.
58
+ - Voices: 28 English (US/UK). af_heart (A) is the default, af_bella (A-) and
59
+ bf_emma (British) are the other good ones. `speed` 0.5-1.6 sets pace.
60
+ - Sign-in walls: recording refuses to film a login page. Run `voila login <url>`
61
+ (or the voila_login tool), let the HUMAN sign in in the window that opens, and
62
+ the session persists in a local profile for every later recording.
55
63
 
56
64
  ## Working on this repo
57
65
 
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # voila.
2
2
 
3
+ [![ci](https://github.com/anzal1/voila/actions/workflows/ci.yml/badge.svg)](https://github.com/anzal1/voila/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/voila-recorder)](https://www.npmjs.com/package/voila-recorder)
5
+
6
+ Verified on Linux, macOS, and Windows in CI: every push records a real demo on
7
+ all three and checks the on-device narration track.
8
+
3
9
  One-click, permission-free product demo recorder. Paste a URL → get a crisp,
4
10
  auto-zoomed, cursor-animated MP4. No OS screen-recording permission, ever —
5
11
  nothing captures your screen. The page is rendered inside a Chromium instance
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) =>
@@ -29,15 +30,19 @@ async function getKokoro() {
29
30
  return kokoroInstance;
30
31
  }
31
32
 
32
- async function synthKokoro(texts, dir, voice, onStatus) {
33
+ async function synthKokoro(texts, dir, voice, onStatus, speed = 1) {
34
+ // Fail loudly on a bad voice name rather than silently using the default.
35
+ if (voice && /^[a-z]{2}_/.test(voice) && !voices.isValid(voice)) {
36
+ throw new Error(`unknown voice "${voice}". Try: ${voices.suggest(voice).join(', ')} (run \`voila voices\` for all ${voices.ranked().length})`);
37
+ }
33
38
  onStatus('loading Kokoro TTS');
34
39
  const tts = await getKokoro();
35
- const v = voice && /^[a-z]{2}_/.test(voice) ? voice : 'af_heart';
36
- onStatus(`narrating with Kokoro (${v})`);
40
+ const v = voice && voices.isValid(voice) ? voice : 'af_heart';
41
+ onStatus(`narrating with Kokoro (${v}${speed !== 1 ? ` @${speed}x` : ''})`);
37
42
  const clips = [];
38
43
  for (let i = 0; i < texts.length; i++) {
39
44
  const file = path.join(dir, `seg${i}.wav`);
40
- const audio = await tts.generate(texts[i], { voice: v });
45
+ const audio = await tts.generate(texts[i], { voice: v, speed });
41
46
  await audio.save(file);
42
47
  const durMs = audio.audio && audio.sampling_rate
43
48
  ? Math.round((audio.audio.length / audio.sampling_rate) * 1000)
@@ -87,13 +92,14 @@ async function synthSay(texts, dir, voice, onStatus) {
87
92
 
88
93
  // Synthesize narration clips up front so the recorder can pace segments to the
89
94
  // spoken durations. Returns {clips: [{file, durMs}], voice, backend}.
90
- async function prepareNarration(texts, dir, voice, onStatus = () => {}) {
95
+ async function prepareNarration(texts, dir, voice, onStatus = () => {}, speed = 1) {
91
96
  fs.mkdirSync(dir, { recursive: true });
92
97
  const backend = process.env.VOILA_TTS || 'kokoro';
93
98
  if (backend === 'kokoro') {
94
99
  try {
95
- return await synthKokoro(texts, dir, voice, onStatus);
100
+ return await synthKokoro(texts, dir, voice, onStatus, speed);
96
101
  } catch (e) {
102
+ if (/unknown voice/.test(e.message)) throw e; // user error, not a fallback case
97
103
  onStatus(`kokoro unavailable (${e.message.slice(0, 80)})`);
98
104
  }
99
105
  }
@@ -101,7 +107,7 @@ async function prepareNarration(texts, dir, voice, onStatus = () => {}) {
101
107
  throw new Error('no TTS backend available');
102
108
  }
103
109
 
104
- async function addNarration(meta, videoIn, videoOut, { voice = null, prepared = null, onStatus = () => {} } = {}) {
110
+ async function addNarration(meta, videoIn, videoOut, { voice = null, speed = 1, prepared = null, onStatus = () => {} } = {}) {
105
111
  const segs = (meta.segments || []).filter(s => s.narration);
106
112
  if (!segs.length) {
107
113
  fs.copyFileSync(videoIn, videoOut);
@@ -113,7 +119,7 @@ async function addNarration(meta, videoIn, videoOut, { voice = null, prepared =
113
119
  let synth = prepared && prepared.clips.length === segs.length ? prepared : null;
114
120
  if (!synth) {
115
121
  try {
116
- synth = await prepareNarration(segs.map(s => s.narration), dir, voice, onStatus);
122
+ synth = await prepareNarration(segs.map(s => s.narration), dir, voice, onStatus, speed);
117
123
  } catch (e) {
118
124
  onStatus(`narration skipped: ${e.message}`);
119
125
  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
 
@@ -17,8 +19,10 @@ function arg(name, fallback = null) {
17
19
 
18
20
  const USAGE = `usage:
19
21
  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]
22
+ voila record <url> [--steps f.yaml] [--device mobile] [--voice name] [--speed 1] [--no-narrate] [--headful] [--out dir] [--profile dir]
21
23
  voila review <video.mp4> [--frames 12] [--out dir]
24
+ voila login <url> [--profile dir] (sign in yourself; session is saved locally)
25
+ voila voices (list every narration voice, best first)
22
26
  voila skill (install the voila skill into ~/.claude/skills)
23
27
  voila serve (web UI, PORT env or --port)
24
28
  voila mcp (stdio MCP server)`;
@@ -35,6 +39,10 @@ const USAGE = `usage:
35
39
  require('./mcp');
36
40
  return;
37
41
  }
42
+ if (cmd === 'voices') {
43
+ console.log(require('./voices').format());
44
+ return;
45
+ }
38
46
  if (cmd === 'skill') {
39
47
  // Install the agent skill the way Clipy does: one command, lands in the
40
48
  // user's skills directory, every future session knows how to demo.
@@ -61,7 +69,7 @@ const USAGE = `usage:
61
69
  }
62
70
 
63
71
  const url = process.argv[3];
64
- if (!cmd || !url || !['outline', 'record'].includes(cmd)) {
72
+ if (!cmd || !url || !['outline', 'record', 'login'].includes(cmd)) {
65
73
  console.error(USAGE);
66
74
  process.exit(1);
67
75
  }
@@ -75,7 +83,13 @@ const USAGE = `usage:
75
83
  });
76
84
 
77
85
  try {
78
- if (cmd === 'outline') {
86
+ if (cmd === 'login') {
87
+ const { login } = require('./auth');
88
+ console.error('[voila] opening a browser window. Sign in there, then press Enter here.');
89
+ const r = await login(session, url, { onStatus: m => console.error('[voila]', m) });
90
+ console.error(`[voila] ${r.reason}. Session saved to ${r.profileDir}`);
91
+ console.log(r.profileDir);
92
+ } else if (cmd === 'outline') {
79
93
  console.log(JSON.stringify(await outline(session, url), null, 2));
80
94
  } else {
81
95
  const stepsFile = arg('--steps');
@@ -86,6 +100,7 @@ const USAGE = `usage:
86
100
  url, steps, workDir,
87
101
  narrate: !process.argv.includes('--no-narrate'),
88
102
  voice: arg('--voice'),
103
+ speed: Number(arg('--speed', '1')) || 1,
89
104
  onStatus: s => console.error('[voila]', s),
90
105
  });
91
106
  console.log(result.video);
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.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voila-recorder",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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.",
5
5
  "license": "MIT",
6
6
  "main": "pipeline.js",
@@ -8,9 +8,23 @@
8
8
  "voila": "./cli.js"
9
9
  },
10
10
  "files": [
11
- "cli.js", "server.js", "mcp.js", "pipeline.js", "recorder.js", "tour.js",
12
- "overlay.js", "render.js", "audio.js", "review.js", "public/", "skills/",
13
- "AGENTS.md", "README.md", "RECIPE.md"
11
+ "cli.js",
12
+ "server.js",
13
+ "mcp.js",
14
+ "pipeline.js",
15
+ "recorder.js",
16
+ "tour.js",
17
+ "overlay.js",
18
+ "render.js",
19
+ "audio.js",
20
+ "review.js",
21
+ "voices.js",
22
+ "auth.js",
23
+ "public/",
24
+ "skills/",
25
+ "AGENTS.md",
26
+ "README.md",
27
+ "RECIPE.md"
14
28
  ],
15
29
  "repository": {
16
30
  "type": "git",
@@ -25,7 +39,16 @@
25
39
  "start": "node server.js",
26
40
  "test:record": "node test.js"
27
41
  },
28
- "keywords": ["demo", "screen-recording", "playwright", "mcp", "agent", "tts", "product-demo", "demos-as-code"],
42
+ "keywords": [
43
+ "demo",
44
+ "screen-recording",
45
+ "playwright",
46
+ "mcp",
47
+ "agent",
48
+ "tts",
49
+ "product-demo",
50
+ "demos-as-code"
51
+ ],
29
52
  "dependencies": {
30
53
  "@modelcontextprotocol/sdk": "^1.30.0",
31
54
  "express": "^4.19.2",
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, 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 });
package/recorder.js CHANGED
@@ -23,7 +23,7 @@ const DEVICES = {
23
23
  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
24
24
  },
25
25
  tablet: {
26
- viewport: { width: 834, height: 1112 }, dpr: 2, isMobile: true, hasTouch: true, maxZoom: 1.25,
26
+ viewport: { width: 834, height: 1112 }, dpr: 2, isMobile: true, hasTouch: true, maxZoom: 1,
27
27
  userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
28
28
  },
29
29
  };
@@ -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,9 +47,14 @@ 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
 
@@ -108,6 +114,19 @@ class VoilaSession {
108
114
  this.page = page;
109
115
  await page.evaluate(OVERLAY_SOURCE);
110
116
 
117
+ // Recording a login screen is never what anyone wanted. Say so up front.
118
+ const { detectAuthWall } = require('./auth');
119
+ const wall = await detectAuthWall(page);
120
+ if (wall.isWall) {
121
+ throw new Error(
122
+ `this looks like a sign-in page, not your product (${wall.url}). ` +
123
+ `Sign in once with: voila login ${new URL(url).origin} ` +
124
+ `(a real browser window opens, you sign in yourself, the session is saved to ` +
125
+ `${this.profileDir}). Then re-run the recording. Pass --profile <dir> to keep ` +
126
+ `separate logins per product.`
127
+ );
128
+ }
129
+
111
130
  const client = await this.context.newCDPSession(page);
112
131
  const frames = [];
113
132
  const writes = [];
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
 
@@ -31,7 +31,8 @@ Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/vo
31
31
  clip's length — do NOT pad waits for narration). Open and close with a
32
32
  `slide` (animated title card: `title`, `subtitle`, `accent` hex).
33
33
  Actions: goto, click, hover, type, scroll (y), scroll_to (selector),
34
- slide, zoom (level), wait. Mark risky steps `optional: true`.
34
+ slide, zoom (level OR selector to frame an element), wait. Mark risky steps
35
+ `optional: true`.
35
36
  3. **Record** (`voila_record` / `record --steps`).
36
37
  4. **Review your own output** (`voila_review`) → frames + timeline. Read the
37
38
  frames. Check: cursor near what narration discusses; captions not covering
@@ -48,12 +49,21 @@ Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/vo
48
49
  (desktop + mobile nav).
49
50
  - On selector failure the error includes the live page outline — use it to
50
51
  patch, don't retry blindly.
51
- - Devices: desktop (1280×800), mobile (390×844, iPhone emulation, zoom
52
- auto-disabled — mobile layouts crop badly), tablet (834×1112). Portrait
53
- output on mobile.
52
+ - Devices: desktop (1280x800), mobile (390x844, iPhone emulation), tablet
53
+ (834x1112). Zoom is auto-disabled on mobile and tablet because narrow
54
+ layouts crop badly; both output portrait.
54
55
  - 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.
56
+ - Login-protected apps: recording a sign-in page is refused with a clear error.
57
+ Run `voila login <url>` (or the voila_login tool): a real browser window
58
+ opens, the USER signs in themselves, and the session is saved to a local
59
+ Chromium profile every later recording reuses. Use `--profile <dir>` for
60
+ separate logins per product. NEVER type credentials yourself and never ask
61
+ for them in chat.
62
+ - Prefer `zoom` with a `selector` over a bare `level`: voila measures the
63
+ element and picks the level and camera centre, so nothing is cropped.
64
+ - Voices: `voila voices` lists 28 English voices with quality grades. af_heart
65
+ (A) default, af_bella (A-), af_nicole (B-), bf_emma (B-, British). `--speed`
66
+ or the speed param (0.5-1.6) changes pace; 0.9 reads calmer.
67
+ - A failing step is retried once automatically; warnings appear in the result.
58
68
  - Narration style: short sentences, product language, no "as you can see".
59
69
  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 };