voila-recorder 0.4.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/overlay.js ADDED
@@ -0,0 +1,157 @@
1
+ // In-page overlay: synthetic cursor, click ripple, eased scrolling.
2
+ // Injected via addInitScript (every navigation) and page.evaluate (idempotent).
3
+
4
+ const OVERLAY_SOURCE = `(() => {
5
+ if (window.__voila) return;
6
+
7
+ const CURSOR_SVG = '<svg width="26" height="30" viewBox="0 0 26 30" xmlns="http://www.w3.org/2000/svg">' +
8
+ '<path d="M3 1.5 L3 24 L8.7 18.8 L12.6 27.6 L16.4 25.9 L12.5 17.2 L20 16.6 Z" ' +
9
+ 'fill="#1b1b1f" stroke="#ffffff" stroke-width="1.7" stroke-linejoin="round"/></svg>';
10
+
11
+ const ease = t => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
12
+
13
+ let cursorEl = null;
14
+ let cur = { x: -60, y: -60 };
15
+
16
+ function install() {
17
+ if (cursorEl && document.documentElement.contains(cursorEl)) return;
18
+ cursorEl = document.createElement('div');
19
+ cursorEl.id = '__voila_cursor';
20
+ cursorEl.innerHTML = CURSOR_SVG;
21
+ Object.assign(cursorEl.style, {
22
+ position: 'fixed', left: '0', top: '0', zIndex: '2147483647',
23
+ pointerEvents: 'none', filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.35))',
24
+ transform: 'translate3d(' + cur.x + 'px,' + cur.y + 'px,0)'
25
+ });
26
+ (document.body || document.documentElement).appendChild(cursorEl);
27
+ }
28
+
29
+ function render() {
30
+ if (cursorEl) cursorEl.style.transform = 'translate3d(' + cur.x + 'px,' + cur.y + 'px,0)';
31
+ }
32
+
33
+ window.__voila = {
34
+ jump(x, y) { cur = { x, y }; install(); render(); },
35
+
36
+ moveTo(x, y, dur) {
37
+ install();
38
+ return new Promise(res => {
39
+ const sx = cur.x, sy = cur.y, t0 = performance.now();
40
+ const step = now => {
41
+ const p = Math.min(1, (now - t0) / dur), e = ease(p);
42
+ cur = { x: sx + (x - sx) * e, y: sy + (y - sy) * e };
43
+ render();
44
+ if (p < 1) requestAnimationFrame(step); else res();
45
+ };
46
+ requestAnimationFrame(step);
47
+ });
48
+ },
49
+
50
+ ripple() {
51
+ install();
52
+ const ring = (size, delay, width, color) => {
53
+ const r = document.createElement('div');
54
+ Object.assign(r.style, {
55
+ position: 'fixed', left: (cur.x - size / 2) + 'px', top: (cur.y - size / 2) + 'px',
56
+ width: size + 'px', height: size + 'px', borderRadius: '50%',
57
+ border: width + 'px solid ' + color, zIndex: '2147483646',
58
+ pointerEvents: 'none', opacity: '0'
59
+ });
60
+ (document.body || document.documentElement).appendChild(r);
61
+ r.animate(
62
+ [{ transform: 'scale(0.35)', opacity: 0.95 }, { transform: 'scale(1.9)', opacity: 0 }],
63
+ { duration: 700, delay, easing: 'cubic-bezier(.2,.7,.3,1)' }
64
+ ).onfinish = () => r.remove();
65
+ };
66
+ ring(58, 0, 3.5, 'rgba(255,255,255,0.95)');
67
+ ring(58, 130, 3, 'rgba(110,165,255,0.9)');
68
+ },
69
+
70
+ press() {
71
+ install();
72
+ const svg = cursorEl.firstElementChild;
73
+ if (!svg) return;
74
+ svg.style.transformOrigin = '6px 4px';
75
+ svg.animate(
76
+ [{ transform: 'scale(1)' }, { transform: 'scale(0.76)' }, { transform: 'scale(1)' }],
77
+ { duration: 280, easing: 'ease-out' }
78
+ );
79
+ },
80
+
81
+ highlight(x, y, w, h) {
82
+ const pad = 6;
83
+ const el = document.createElement('div');
84
+ Object.assign(el.style, {
85
+ position: 'fixed', left: (x - pad) + 'px', top: (y - pad) + 'px',
86
+ width: (w + pad * 2) + 'px', height: (h + pad * 2) + 'px',
87
+ border: '2.5px solid rgba(110,165,255,0.95)', borderRadius: '10px',
88
+ boxShadow: '0 0 0 5px rgba(110,165,255,0.18)',
89
+ zIndex: '2147483645', pointerEvents: 'none', opacity: '0'
90
+ });
91
+ (document.body || document.documentElement).appendChild(el);
92
+ el.animate(
93
+ [{ opacity: 0, transform: 'scale(1.06)' }, { opacity: 1, transform: 'scale(1)', offset: 0.25 },
94
+ { opacity: 1, transform: 'scale(1)', offset: 0.75 }, { opacity: 0, transform: 'scale(1)' }],
95
+ { duration: 1200, easing: 'ease-out' }
96
+ ).onfinish = () => el.remove();
97
+ },
98
+
99
+ slide(o) {
100
+ install();
101
+ const esc = s => (s || '').replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
102
+ const accent = /^#[0-9a-fA-F]{3,8}$/.test(o.accent || '') ? o.accent : '#d97d43';
103
+ const titleWords = esc(o.title).split(' ');
104
+ const words = titleWords.map((w, i) =>
105
+ '<span style="display:inline-block;opacity:0;transform:translateY(36px);' +
106
+ 'animation:__vRise .65s cubic-bezier(.16,1,.3,1) ' + (0.15 + i * 0.09).toFixed(2) + 's forwards">' + w + '</span>'
107
+ ).join(' ');
108
+ const after = (0.15 + titleWords.length * 0.09);
109
+ const el = document.createElement('div');
110
+ el.style.cssText = 'position:fixed;inset:0;z-index:2147483645;display:flex;flex-direction:column;' +
111
+ 'align-items:center;justify-content:center;opacity:0;transition:opacity .45s ease;' +
112
+ 'background:linear-gradient(135deg,#0d0d12 0%,#161119 55%,#0f0f16 100%)';
113
+ el.innerHTML =
114
+ '<style>@keyframes __vRise{to{opacity:1;transform:translateY(0)}}@keyframes __vBar{to{transform:scaleX(1)}}</style>' +
115
+ '<div style="font:700 62px/1.15 -apple-system,Helvetica,Arial,sans-serif;color:#f4f2ec;text-align:center;max-width:82%;letter-spacing:-0.02em">' + words + '</div>' +
116
+ '<div style="width:86px;height:4px;border-radius:2px;background:' + accent + ';transform:scaleX(0);' +
117
+ 'animation:__vBar .5s ease ' + (after + 0.2).toFixed(2) + 's forwards;margin:28px 0 18px"></div>' +
118
+ (o.subtitle
119
+ ? '<div style="font:400 26px/1.45 -apple-system,Helvetica,Arial,sans-serif;color:#b9b5c0;opacity:0;transform:translateY(20px);' +
120
+ 'animation:__vRise .6s ease ' + (after + 0.35).toFixed(2) + 's forwards;text-align:center;max-width:70%">' + esc(o.subtitle) + '</div>'
121
+ : '');
122
+ document.documentElement.appendChild(el);
123
+ if (cursorEl) cursorEl.style.opacity = '0';
124
+ return new Promise(res => {
125
+ requestAnimationFrame(() => { el.style.opacity = '1'; });
126
+ setTimeout(() => {
127
+ el.style.opacity = '0';
128
+ setTimeout(() => {
129
+ el.remove();
130
+ if (cursorEl) cursorEl.style.opacity = '1';
131
+ res();
132
+ }, 500);
133
+ }, o.ms || 3200);
134
+ });
135
+ },
136
+
137
+ scrollToY(y, dur) {
138
+ return new Promise(res => {
139
+ const sy = window.scrollY, t0 = performance.now();
140
+ const step = now => {
141
+ const p = Math.min(1, (now - t0) / dur);
142
+ window.scrollTo(0, sy + (y - sy) * ease(p));
143
+ if (p < 1) requestAnimationFrame(step); else res();
144
+ };
145
+ requestAnimationFrame(step);
146
+ });
147
+ }
148
+ };
149
+
150
+ if (document.readyState === 'loading') {
151
+ document.addEventListener('DOMContentLoaded', install);
152
+ } else {
153
+ install();
154
+ }
155
+ })();`;
156
+
157
+ module.exports = { OVERLAY_SOURCE };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "voila-recorder",
3
+ "version": "0.4.0",
4
+ "description": "Permission-free product demo recorder: URL in, narrated auto-zoomed MP4 out — with the recipe embedded in the video. Agent-native (MCP), fully on-device.",
5
+ "license": "MIT",
6
+ "main": "pipeline.js",
7
+ "bin": {
8
+ "voila": "./cli.js"
9
+ },
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"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/anzal1/voila.git"
18
+ },
19
+ "homepage": "https://voila.anzalabidi.dev",
20
+ "author": "Anzal Abidi (https://anzalabidi.dev)",
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "scripts": {
25
+ "start": "node server.js",
26
+ "test:record": "node test.js"
27
+ },
28
+ "keywords": ["demo", "screen-recording", "playwright", "mcp", "agent", "tts", "product-demo", "demos-as-code"],
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.30.0",
31
+ "express": "^4.19.2",
32
+ "ffmpeg-static": "^5.2.0",
33
+ "js-yaml": "^4.1.0",
34
+ "kokoro-js": "^1.2.1",
35
+ "playwright": "^1.48.0",
36
+ "sharp": "^0.33.5"
37
+ }
38
+ }
package/pipeline.js ADDED
@@ -0,0 +1,91 @@
1
+ // One entry point for the full record -> render -> narrate pipeline,
2
+ // shared by the web server, CLI, and MCP server.
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawn } = require('child_process');
7
+ const ffmpegPath = require('ffmpeg-static');
8
+ const { VoilaSession } = require('./recorder');
9
+ const { render } = require('./render');
10
+ const { addNarration, prepareNarration } = require('./audio');
11
+ const { extractOutline } = require('./tour');
12
+
13
+ // The recipe is the demo's source code: enough for any agent to recreate or
14
+ // fork the video. Written as a sidecar (recipe.json) AND embedded in the
15
+ // MP4's comment metadata, so the recipe travels with the shared file.
16
+ function buildRecipe({ url, mode, steps, meta }) {
17
+ return {
18
+ tool: 'voila',
19
+ spec: 'voila-recipe/1',
20
+ url,
21
+ mode: steps ? 'steps' : mode,
22
+ steps: steps ? steps.map(s => Object.fromEntries(Object.entries(s).filter(([k]) => !k.startsWith('_')))) : null,
23
+ durationSec: Math.round((meta.tEnd - meta.tStart) / 1000),
24
+ segments: (meta.segments || []).map(s => ({
25
+ at: +((s.t - meta.tStart) / 1000).toFixed(2),
26
+ caption: s.caption,
27
+ narration: s.narration,
28
+ })),
29
+ howToRecreate: 'Run the steps against the url with voila (github.com/anzal1/voila) — voila_record(url, steps_yaml) via MCP, or `voila record <url> --steps <file>`.',
30
+ };
31
+ }
32
+
33
+ function embedRecipe(videoIn, videoOut, recipe) {
34
+ return new Promise((res, rej) => {
35
+ const ff = spawn(ffmpegPath, [
36
+ '-y', '-i', videoIn, '-c', 'copy', '-movflags', '+faststart',
37
+ '-metadata', `comment=voila-recipe:${JSON.stringify(recipe)}`,
38
+ videoOut,
39
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
40
+ let err = '';
41
+ ff.stderr.on('data', d => { err += d; if (err.length > 20000) err = err.slice(-10000); });
42
+ ff.on('close', code => (code === 0 ? res() : rej(new Error(`ffmpeg embed exited ${code}\n${err.slice(-1000)}`))));
43
+ ff.on('error', rej);
44
+ });
45
+ }
46
+
47
+ async function produceDemo(session, { url, mode = 'auto', steps = null, workDir, voice = null, narrate = true, onStatus = () => {} }) {
48
+ // Steps mode: synthesize narration BEFORE recording so segment pacing and
49
+ // caption lifetimes match the spoken clip durations exactly.
50
+ let prepared = null;
51
+ if (steps && narrate) {
52
+ const texts = steps.filter(s => s.narration).map(s => s.narration);
53
+ if (texts.length) {
54
+ try {
55
+ prepared = await prepareNarration(texts, path.join(workDir, 'tts'), voice, onStatus);
56
+ let i = 0;
57
+ for (const s of steps) if (s.narration) s._narrDurMs = prepared.clips[i++].durMs;
58
+ } catch (e) {
59
+ onStatus(`narration pre-synth failed: ${e.message.slice(0, 80)}`);
60
+ }
61
+ }
62
+ }
63
+
64
+ const meta = await session.record({ url, mode: steps ? 'steps' : mode, steps, workDir, onStatus });
65
+
66
+ const raw = path.join(workDir, 'raw.mp4');
67
+ const narrated = path.join(workDir, 'narrated.mp4');
68
+ const out = path.join(workDir, 'demo.mp4');
69
+ await render(meta, raw, { onStatus });
70
+
71
+ let narration = { narrated: false };
72
+ if (narrate) narration = await addNarration(meta, raw, narrated, { voice, prepared, onStatus });
73
+ else fs.copyFileSync(raw, narrated);
74
+
75
+ const recipe = buildRecipe({ url, mode, steps, meta });
76
+ fs.writeFileSync(path.join(workDir, 'recipe.json'), JSON.stringify(recipe, null, 2));
77
+ onStatus('embedding recipe');
78
+ await embedRecipe(narrated, out, recipe);
79
+
80
+ fs.rmSync(meta.framesDir, { recursive: true, force: true });
81
+ fs.rmSync(raw, { force: true });
82
+ fs.rmSync(narrated, { force: true });
83
+ return { video: out, recipe: path.join(workDir, 'recipe.json'), meta, narration };
84
+ }
85
+
86
+ async function outline(session, url) {
87
+ const page = await session.open(url);
88
+ return extractOutline(page);
89
+ }
90
+
91
+ module.exports = { produceDemo, outline };
@@ -0,0 +1,117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>voila</title>
7
+ <style>
8
+ :root {
9
+ --bg: #101014; --card: #17171d; --border: #2a2a33;
10
+ --text: #f2f0ea; --muted: #9a97a0; --accent: #d97d43; --accent-hover: #e8905a;
11
+ }
12
+ * { box-sizing: border-box; margin: 0; }
13
+ body {
14
+ background: radial-gradient(1200px 600px at 50% -10%, #1d1a24, var(--bg));
15
+ color: var(--text); min-height: 100vh; display: grid; place-items: center;
16
+ font: 16px/1.5 -apple-system, "SF Pro Text", Inter, system-ui, sans-serif;
17
+ }
18
+ main { width: min(680px, 92vw); }
19
+ h1 { font-size: 2.1rem; letter-spacing: -0.02em; }
20
+ h1 span { color: var(--accent); }
21
+ p.tag { color: var(--muted); margin: 6px 0 28px; }
22
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 24px; }
23
+ .row { display: flex; gap: 10px; }
24
+ input[type=url] {
25
+ flex: 1; background: #0d0d11; color: var(--text); border: 1px solid var(--border);
26
+ border-radius: 10px; padding: 13px 16px; font-size: 1rem; outline: none;
27
+ }
28
+ input[type=url]:focus { border-color: var(--accent); }
29
+ button {
30
+ border: 0; border-radius: 10px; padding: 13px 20px; font-size: 1rem; font-weight: 600;
31
+ cursor: pointer; transition: background .15s;
32
+ }
33
+ .primary { background: var(--accent); color: #16120d; }
34
+ .primary:hover { background: var(--accent-hover); }
35
+ .primary:disabled { opacity: .5; cursor: default; }
36
+ .ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
37
+ .ghost:hover { color: var(--text); }
38
+ .hint { color: var(--muted); font-size: .85rem; margin-top: 14px; }
39
+ #status { margin-top: 18px; color: var(--muted); font-size: .95rem; min-height: 1.4em; }
40
+ #status.working::before {
41
+ content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 9px;
42
+ border: 2px solid var(--accent); border-top-color: transparent; border-radius: 50%;
43
+ animation: spin .8s linear infinite; vertical-align: -1px;
44
+ }
45
+ @keyframes spin { to { transform: rotate(360deg); } }
46
+ #result { margin-top: 20px; display: none; }
47
+ video { width: 100%; border-radius: 12px; border: 1px solid var(--border); }
48
+ a.dl { color: var(--accent); font-size: .9rem; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <main>
53
+ <h1>voila<span>.</span></h1>
54
+ <p class="tag">Paste a URL. Get a crisp, auto-zoomed demo video. No screen-recording permission — ever.</p>
55
+ <div class="card">
56
+ <div class="row">
57
+ <input id="url" type="url" placeholder="https://yourproduct.com" spellcheck="false">
58
+ <button id="record" class="primary">⏺ Record demo</button>
59
+ </div>
60
+ <div class="row" style="margin-top:10px">
61
+ <button id="open" class="ghost">Open browser to sign in first</button>
62
+ </div>
63
+ <p class="hint">Needs a login? Click “Open browser”, sign in in the window that appears (your credentials stay in a local browser profile on this machine), then hit Record. Next time it’s truly one click.</p>
64
+ <div id="status"></div>
65
+ <div id="result">
66
+ <video id="video" controls autoplay muted loop></video>
67
+ <p style="margin-top:8px"><a id="dl" class="dl" download>Download MP4</a></p>
68
+ </div>
69
+ </div>
70
+ </main>
71
+ <script>
72
+ const $ = id => document.getElementById(id);
73
+ const status = (msg, working) => {
74
+ $('status').textContent = msg;
75
+ $('status').className = working ? 'working' : '';
76
+ };
77
+
78
+ $('open').onclick = async () => {
79
+ const url = $('url').value.trim();
80
+ if (!url) return status('Enter a URL first.');
81
+ status('Opening browser…', true);
82
+ const r = await fetch('/api/open', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url }) });
83
+ const j = await r.json();
84
+ status(j.message || j.error || 'Browser open.');
85
+ };
86
+
87
+ $('record').onclick = async () => {
88
+ const url = $('url').value.trim();
89
+ if (!url) return status('Enter a URL first.');
90
+ $('record').disabled = true;
91
+ $('result').style.display = 'none';
92
+ status('Starting…', true);
93
+ const r = await fetch('/api/record', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ url }) });
94
+ if (!r.ok) {
95
+ const j = await r.json().catch(() => ({}));
96
+ status(j.error || 'Failed to start.');
97
+ $('record').disabled = false;
98
+ return;
99
+ }
100
+ const poll = setInterval(async () => {
101
+ const s = await (await fetch('/api/status')).json();
102
+ if (s.state === 'working') return status(s.detail + '…', true);
103
+ clearInterval(poll);
104
+ $('record').disabled = false;
105
+ if (s.state === 'done') {
106
+ status('Done — voila.');
107
+ $('video').src = s.video;
108
+ $('dl').href = s.video;
109
+ $('result').style.display = 'block';
110
+ } else {
111
+ status('Error: ' + s.detail);
112
+ }
113
+ }, 900);
114
+ };
115
+ </script>
116
+ </body>
117
+ </html>
package/recorder.js ADDED
@@ -0,0 +1,171 @@
1
+ // VoilaSession: owns a persistent Chromium profile (login survives restarts),
2
+ // records the viewport via CDP screencast (no OS screen-recording permission),
3
+ // and logs a cursor/zoom timeline the renderer replays for the zoom effect.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { chromium } = require('playwright');
8
+ const { OVERLAY_SOURCE } = require('./overlay');
9
+ const { autoTour, runSteps } = require('./tour');
10
+
11
+ // Device presets: what viewport the demo is recorded at. Mobile emulates an
12
+ // iPhone-class device (touch, mobile UA) so responsive sites render for real.
13
+ // maxZoom keeps the zoom camera sane per screen size: a 1.5x zoom that looks
14
+ // great on desktop crops words off a 390px phone viewport.
15
+ const DEVICES = {
16
+ desktop: {
17
+ viewport: { width: 1280, height: 800 }, dpr: 2, isMobile: false, hasTouch: false, maxZoom: 3,
18
+ },
19
+ mobile: {
20
+ // maxZoom 1: mobile layouts are edge-to-edge, any horizontal crop cuts
21
+ // text — scrolling carries the motion instead.
22
+ viewport: { width: 390, height: 844 }, dpr: 3, isMobile: true, hasTouch: true, maxZoom: 1,
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
+ },
25
+ tablet: {
26
+ viewport: { width: 834, height: 1112 }, dpr: 2, isMobile: true, hasTouch: true, maxZoom: 1.25,
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
+ },
29
+ };
30
+
31
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
32
+
33
+ class Timeline {
34
+ constructor() {
35
+ this.moves = [];
36
+ this.zooms = [];
37
+ this.segments = [];
38
+ this.warnings = [];
39
+ this.pos = { x: -60, y: -60 };
40
+ this.zoom = 1;
41
+ }
42
+ recordSegment(caption, narration, dur = null) {
43
+ this.segments.push({ t: Date.now(), caption: caption || null, narration: narration || null, dur });
44
+ }
45
+ recordMove(to, dur) {
46
+ this.moves.push({ t: Date.now(), from: { ...this.pos }, to: { ...to }, dur });
47
+ this.pos = { ...to };
48
+ }
49
+ recordZoom(level, dur) {
50
+ this.zooms.push({ t: Date.now(), from: this.zoom, to: level, dur });
51
+ this.zoom = level;
52
+ }
53
+ }
54
+
55
+ class VoilaSession {
56
+ constructor({ profileDir, headless = false, device = 'desktop' } = {}) {
57
+ this.profileDir = profileDir || path.join(__dirname, 'profile');
58
+ this.headless = headless;
59
+ this.device = DEVICES[device] ? device : 'desktop';
60
+ this.preset = DEVICES[this.device];
61
+ this.context = null;
62
+ this.page = null;
63
+ }
64
+
65
+ async open(url) {
66
+ if (!this.context) {
67
+ const launch = () => chromium.launchPersistentContext(this.profileDir, {
68
+ headless: this.headless,
69
+ viewport: this.preset.viewport,
70
+ deviceScaleFactor: this.preset.dpr,
71
+ isMobile: this.preset.isMobile,
72
+ hasTouch: this.preset.hasTouch,
73
+ ...(this.preset.userAgent ? { userAgent: this.preset.userAgent } : {}),
74
+ args: ['--hide-scrollbars'],
75
+ });
76
+ try {
77
+ this.context = await launch();
78
+ } catch (e) {
79
+ // Zero-install path: fetch Chromium on first use instead of making the
80
+ // user run `npx playwright install` themselves.
81
+ if (!/Executable doesn't exist|missing dependencies|browser.*not found/i.test(String(e.message))) throw e;
82
+ const { execFileSync } = require('child_process');
83
+ let cliPath;
84
+ try { cliPath = require.resolve('playwright/cli'); }
85
+ catch { cliPath = path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'); }
86
+ execFileSync(process.execPath, [cliPath, 'install', 'chromium'], { stdio: 'pipe', timeout: 600000 });
87
+ this.context = await launch();
88
+ }
89
+ await this.context.addInitScript(OVERLAY_SOURCE);
90
+ this.context.on('close', () => { this.context = null; this.page = null; });
91
+ }
92
+ this.page = this.context.pages()[0] || await this.context.newPage();
93
+ this.page.on('close', () => { this.page = null; });
94
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
95
+ await this.page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
96
+ return this.page;
97
+ }
98
+
99
+ // Records a demo of `url` into workDir. Returns metadata for the renderer.
100
+ async record({ url, mode = 'auto', steps = null, workDir, onStatus = () => {} }) {
101
+ const framesDir = path.join(workDir, 'frames');
102
+ fs.mkdirSync(framesDir, { recursive: true });
103
+
104
+ onStatus('opening page');
105
+ const page = (this.page && !this.page.isClosed() && this.page.url().startsWith(new URL(url).origin))
106
+ ? this.page
107
+ : await this.open(url);
108
+ this.page = page;
109
+ await page.evaluate(OVERLAY_SOURCE);
110
+
111
+ const client = await this.context.newCDPSession(page);
112
+ const frames = [];
113
+ const writes = [];
114
+ let idx = 0;
115
+
116
+ client.on('Page.screencastFrame', ev => {
117
+ const file = `f${String(idx++).padStart(5, '0')}.jpg`;
118
+ const t = ev.metadata && ev.metadata.timestamp ? ev.metadata.timestamp * 1000 : Date.now();
119
+ writes.push(fs.promises.writeFile(path.join(framesDir, file), Buffer.from(ev.data, 'base64')));
120
+ frames.push({ t, file });
121
+ client.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});
122
+ });
123
+
124
+ await client.send('Page.startScreencast', {
125
+ format: 'jpeg',
126
+ quality: 88,
127
+ maxWidth: this.preset.viewport.width * this.preset.dpr,
128
+ maxHeight: this.preset.viewport.height * this.preset.dpr,
129
+ everyNthFrame: 1,
130
+ });
131
+
132
+ const tl = new Timeline();
133
+ const tStart = Date.now();
134
+
135
+ onStatus('recording tour');
136
+ const tourOpts = { sleep, maxZoom: this.preset.maxZoom || 3 };
137
+ try {
138
+ if (mode === 'steps' && steps) {
139
+ await runSteps(page, tl, steps, tourOpts);
140
+ } else {
141
+ await autoTour(page, tl, tourOpts);
142
+ }
143
+ } finally {
144
+ await sleep(500); // trailing frames
145
+ await client.send('Page.stopScreencast').catch(() => {});
146
+ await Promise.allSettled(writes);
147
+ await client.detach().catch(() => {});
148
+ }
149
+
150
+ const tEnd = Date.now();
151
+ const meta = {
152
+ tStart, tEnd,
153
+ viewport: this.preset.viewport, dpr: this.preset.dpr, device: this.device,
154
+ frames: frames.sort((a, b) => a.t - b.t),
155
+ moves: tl.moves, zooms: tl.zooms, segments: tl.segments,
156
+ warnings: tl.warnings || [],
157
+ framesDir,
158
+ };
159
+ fs.writeFileSync(path.join(workDir, 'meta.json'), JSON.stringify(meta));
160
+ onStatus('tour captured');
161
+ return meta;
162
+ }
163
+
164
+ async close() {
165
+ if (this.context) await this.context.close().catch(() => {});
166
+ this.context = null;
167
+ this.page = null;
168
+ }
169
+ }
170
+
171
+ module.exports = { VoilaSession, DEVICES };