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 +15 -3
- package/README.md +9 -0
- package/RECIPE.md +1 -1
- package/audio.js +15 -8
- package/auth.js +64 -0
- package/cli.js +84 -3
- package/consent.js +97 -0
- package/doctor.js +118 -0
- package/mcp.js +39 -5
- package/modelcache.js +22 -0
- package/package.json +10 -4
- package/pipeline.js +36 -6
- package/recorder.js +32 -8
- package/render.js +18 -1
- package/scripts/check-audio.js +14 -0
- package/skills/voila/SKILL.md +24 -4
- package/tour.js +70 -10
- package/voices.js +58 -0
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
|
|
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
|
|
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 &&
|
|
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 === '
|
|
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.
|
|
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
|
-
"description": "Permission-free product demo recorder: URL in, narrated auto-zoomed MP4 out
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 =
|
|
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}`);
|
package/skills/voila/SKILL.md
CHANGED
|
@@ -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
|
|
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:
|
|
56
|
-
|
|
57
|
-
the
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|