@vosjs/render-core 0.2.9 → 0.3.1

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.
@@ -0,0 +1,1324 @@
1
+ // src/record/driver.ts
2
+ var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
3
+
4
+ // src/record/cap.ts
5
+ function capReached(elapsedMs, capSeconds) {
6
+ return elapsedMs >= capSeconds * 1e3;
7
+ }
8
+ function clampWait(waitMs, elapsedMs, capSeconds) {
9
+ return Math.max(0, Math.min(waitMs, capSeconds * 1e3 - elapsedMs));
10
+ }
11
+ function formatDurationCap(seconds) {
12
+ if (seconds < 60) return `${seconds} s`;
13
+ const m = Math.round(seconds / 60);
14
+ if (m < 60) return `${m} min`;
15
+ const h = Math.floor(m / 60);
16
+ const rest = m % 60;
17
+ return rest ? `${h} h ${rest} min` : `${h} h`;
18
+ }
19
+ function cappedLine(capSeconds) {
20
+ return `stopped at ${formatDurationCap(capSeconds)} (--max-duration ${capSeconds}); the remaining steps did not run`;
21
+ }
22
+
23
+ // src/record/pace.ts
24
+ var POINTER_MS_PER_PX = 0.7;
25
+ var POINTER_MIN_MS = 250;
26
+ var POINTER_MAX_MS = 800;
27
+ var MOTION_TICK_MS = 16;
28
+ var SETTLE_MS = {
29
+ click: 150,
30
+ type: 150,
31
+ scroll: 200,
32
+ drag: 80
33
+ };
34
+ var PRESS_LEAD_MS = 80;
35
+ var PRESS_HOLD_MS = 70;
36
+ var SCROLL_SETTLE_MS = 120;
37
+ var TRAILING_HOLD_MS = 400;
38
+ function pointerTravelMs(dist) {
39
+ return Math.min(
40
+ POINTER_MAX_MS,
41
+ Math.max(POINTER_MIN_MS, Math.round(dist * POINTER_MS_PER_PX))
42
+ );
43
+ }
44
+ var RESPONSE_MS = 400;
45
+ var QUIET_MS = 250;
46
+ var SETTLE_CAP_MS = 1200;
47
+ function settleVerdict(t, c = { response: RESPONSE_MS, quiet: QUIET_MS, cap: SETTLE_CAP_MS }) {
48
+ if (t.sincePress >= c.cap) return "settled";
49
+ if (t.sinceChange === null)
50
+ return t.sincePress >= c.response ? "settled" : "wait";
51
+ return t.sinceChange >= c.quiet ? "settled" : "wait";
52
+ }
53
+ function holdLeftMs(holdMs, sinceChange) {
54
+ return Math.max(0, holdMs - (sinceChange ?? 0));
55
+ }
56
+ function settleMs(step) {
57
+ if (typeof step.ms === "number" && step.ms >= 0) return step.ms;
58
+ const d = step.do;
59
+ return SETTLE_MS[d] ?? 0;
60
+ }
61
+ var easeInOutCubic = (u) => u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
62
+ async function clockMotion(dur, at, clock) {
63
+ const start = clock.now();
64
+ let samples = 0;
65
+ for (; ; ) {
66
+ const elapsed = clock.now() - start;
67
+ if (elapsed >= dur) break;
68
+ await at(easeInOutCubic(Math.min(1, elapsed / dur)));
69
+ samples++;
70
+ const next = start + Math.ceil((clock.now() - start) / MOTION_TICK_MS) * MOTION_TICK_MS;
71
+ const wait = Math.min(next, start + dur) - clock.now();
72
+ if (wait > 0) await clock.sleep(wait);
73
+ }
74
+ await at(1);
75
+ return samples + 1;
76
+ }
77
+ async function clockTyping(chars, delay, type, clock) {
78
+ const start = clock.now();
79
+ for (let i = 0; i < chars.length; i++) {
80
+ await type(chars[i], i);
81
+ const due = start + (i + 1) * delay;
82
+ const wait = due - clock.now();
83
+ if (wait > 0 && i < chars.length - 1) await clock.sleep(wait);
84
+ }
85
+ }
86
+ function askedMs(step) {
87
+ switch (step.do) {
88
+ case "wait":
89
+ return step.ms ?? 0;
90
+ // A click's ms is the read after the page settled: the author's.
91
+ case "click":
92
+ return step.ms ?? SETTLE_MS.click;
93
+ case "hover":
94
+ return step.ms ?? 700;
95
+ case "drag":
96
+ return step.ms ?? 700;
97
+ case "type":
98
+ return (step.text?.length ?? 0) * (step.delayMs ?? 40);
99
+ default:
100
+ return 0;
101
+ }
102
+ }
103
+ function paceReport(steps) {
104
+ const askedMs2 = steps.reduce((a, s) => a + s.askedMs, 0);
105
+ const gestureMs = steps.reduce((a, s) => a + s.gestureMs, 0);
106
+ const wallMs = steps.reduce((a, s) => a + s.wallMs, 0);
107
+ const overheadMs = Math.max(0, wallMs - askedMs2 - gestureMs);
108
+ const slow = steps.filter((s) => s.wallMs > (s.askedMs + s.gestureMs) * 1.34 + 60).map((s) => ({
109
+ step: s.step,
110
+ do: s.do,
111
+ askedMs: s.askedMs,
112
+ wallMs: s.wallMs
113
+ }));
114
+ return {
115
+ askedMs: askedMs2,
116
+ gestureMs,
117
+ wallMs,
118
+ overheadMs,
119
+ overheadPct: wallMs > 0 ? Math.round(overheadMs / wallMs * 100) : 0,
120
+ slow
121
+ };
122
+ }
123
+ function paceLine(r) {
124
+ const s = (ms) => `${(ms / 1e3).toFixed(1)} s`;
125
+ const slow = r.slow.length ? `; slow: ${r.slow.map((x) => `#${x.step} ${x.do} ${s(x.wallMs)} for ${s(x.askedMs)} asked`).join(", ")}` : "";
126
+ return `pace: the script asked ${s(r.askedMs)}, the gestures added ${s(r.gestureMs)}, the take ran ${s(r.wallMs)} (${r.overheadPct} % overhead${slow})`;
127
+ }
128
+ var READ_BEAT_MS = { page: 1e3, control: 600 };
129
+ var DEAD_STRETCH_WARN_MS = 1500;
130
+ var DEAD_SHARE_WARN_PCT = 20;
131
+ function deadTime(steps, frames, events, durationMs) {
132
+ const out = [];
133
+ let ms = 0;
134
+ let longestMs = 0;
135
+ for (const s of steps) {
136
+ const t0 = s.tStart * 1e3;
137
+ const t1 = s.tEnd * 1e3;
138
+ if (!(t1 > t0)) continue;
139
+ const press = events.find(
140
+ (e) => e.type === "down" && e.t >= t0 && e.t <= t1
141
+ );
142
+ const act = press ? press.t : t0;
143
+ let last = act;
144
+ for (const f of frames) {
145
+ if (f.tMs > act && f.tMs <= t1) last = f.tMs;
146
+ }
147
+ const settledMs = Math.round(last - act);
148
+ const heldMs = Math.round(t1 - last);
149
+ const moved = events.some(
150
+ (e) => (e.type === "move" || e.type === "scroll") && e.t > last && e.t <= t1
151
+ );
152
+ const beat = s.navigated || s.step === 0 ? READ_BEAT_MS.page : READ_BEAT_MS.control;
153
+ const deadMs = moved ? 0 : Math.max(0, heldMs - beat);
154
+ if (deadMs > 0) {
155
+ out.push({
156
+ step: s.step,
157
+ ...s.id ? { id: s.id } : {},
158
+ do: s.do,
159
+ settledMs,
160
+ heldMs,
161
+ deadMs
162
+ });
163
+ ms += deadMs;
164
+ longestMs = Math.max(longestMs, deadMs);
165
+ }
166
+ }
167
+ return {
168
+ ms,
169
+ pct: durationMs > 0 ? Math.round(ms / durationMs * 100) : 0,
170
+ steps: out,
171
+ longestMs
172
+ };
173
+ }
174
+ function deadWarns(r) {
175
+ return r.pct >= DEAD_SHARE_WARN_PCT || r.longestMs >= DEAD_STRETCH_WARN_MS;
176
+ }
177
+ function deadLine(r) {
178
+ const s = (ms) => `${(ms / 1e3).toFixed(1)} s`;
179
+ if (!r.steps.length) return "dead time: none";
180
+ const parts = r.steps.map(
181
+ (d) => `#${d.step}${d.id ? ` (${d.id})` : ""} ${d.do} held ${s(d.heldMs)} after it settled, ${s(d.deadMs)} past the beat`
182
+ );
183
+ return `dead time: ${s(r.ms)} (${r.pct} %), a still frame under a parked cursor past the reading beat: ${parts.join("; ")}. Cut those steps' ms; a hold is what it takes to read what changed.`;
184
+ }
185
+
186
+ // src/record/wall.ts
187
+ var IDP_HOSTS = [
188
+ "accounts.google.com",
189
+ "login.microsoftonline.com",
190
+ "login.live.com",
191
+ "appleid.apple.com",
192
+ "auth0.com",
193
+ "okta.com",
194
+ "onelogin.com",
195
+ "clerk.accounts.dev",
196
+ "accounts.dev",
197
+ "b2clogin.com",
198
+ "amazoncognito.com",
199
+ "workos.com",
200
+ "stytch.com"
201
+ ];
202
+ var SIGNIN_SEGMENTS = /* @__PURE__ */ new Set([
203
+ "login",
204
+ "log-in",
205
+ "signin",
206
+ "sign-in",
207
+ "sign_in",
208
+ "sso",
209
+ "auth",
210
+ "authenticate",
211
+ "authorize",
212
+ "oauth",
213
+ "oauth2",
214
+ "saml"
215
+ ]);
216
+ function parse(url) {
217
+ try {
218
+ return new URL(url);
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+ var bareHost = (h) => h.replace(/^www\./, "").toLowerCase();
224
+ var trimSlash = (p) => p.length > 1 ? p.replace(/\/+$/, "") : p;
225
+ var place = (u) => `${u.origin}${trimSlash(u.pathname)}`;
226
+ function isIdpHost(host) {
227
+ const h = host.toLowerCase();
228
+ if (h.startsWith("clerk.")) return true;
229
+ return IDP_HOSTS.some((idp) => h === idp || h.endsWith(`.${idp}`));
230
+ }
231
+ function isSignInPath(pathname) {
232
+ const segs = pathname.toLowerCase().split("/").filter(Boolean);
233
+ return segs.some((s) => SIGNIN_SEGMENTS.has(s));
234
+ }
235
+ function redirectedAway(asked, landed) {
236
+ if (bareHost(asked.hostname) !== bareHost(landed.hostname)) return true;
237
+ const a = trimSlash(asked.pathname).toLowerCase();
238
+ const l = trimSlash(landed.pathname).toLowerCase();
239
+ if (a === l || a === "/") return false;
240
+ if (l.startsWith(`${a}/`)) return false;
241
+ if (l.endsWith(a)) return false;
242
+ return true;
243
+ }
244
+ var LADDER = 'This is a missing or expired session, not a script bug. Ways past, cheapest first: mint a session from the test auth the project already has; script the sign-in off camera and save the storage state; or have a person sign in once (npx playwright open --channel chrome --save-storage=<file> <url>). Then pass --storage-state <file>. The ladder: https://vos.so/llms-full.txt, "Sessions". A take OF this page is --allow-wall.';
245
+ function wallVerdict(a) {
246
+ const asked = parse(a.askedUrl);
247
+ const landed = parse(a.landedUrl);
248
+ if (!asked || !landed) return null;
249
+ if (!/^https?:$/.test(asked.protocol)) return null;
250
+ const away = redirectedAway(asked, landed);
251
+ const where = { asked: place(asked), landed: place(landed) };
252
+ const host = bareHost(asked.hostname);
253
+ if (a.status === 401 || a.status === 403) {
254
+ return {
255
+ kind: "status",
256
+ level: "hard",
257
+ ...where,
258
+ message: `asked for ${asked.pathname}, answered ${a.status}: no session for ${host}. ${LADDER}`
259
+ };
260
+ }
261
+ const askedForSignIn = isSignInPath(asked.pathname) || isIdpHost(asked.hostname);
262
+ if (askedForSignIn && !away) return null;
263
+ if (away && isIdpHost(landed.hostname) && !isIdpHost(asked.hostname)) {
264
+ return {
265
+ kind: "idp",
266
+ level: "hard",
267
+ ...where,
268
+ message: `asked for ${asked.pathname}, landed on ${landed.hostname}: no session for ${host}. ${LADDER}`
269
+ };
270
+ }
271
+ const signInForm = a.passwordFields === 1 && !a.newPasswordField || a.oneTimeCodeField;
272
+ if (away && isSignInPath(landed.pathname) || signInForm && !askedForSignIn) {
273
+ const seen = away ? `landed on ${landed.pathname}` : "the page is a sign-in form";
274
+ return {
275
+ kind: "signin",
276
+ level: "hard",
277
+ ...where,
278
+ message: `asked for ${asked.pathname}, ${seen}: no session for ${host}. ${LADDER}`
279
+ };
280
+ }
281
+ if (away) {
282
+ return {
283
+ kind: "redirect",
284
+ level: "soft",
285
+ ...where,
286
+ message: `asked for ${asked.pathname}, landed on ${landed.pathname}: the site sent the recorder elsewhere, which is what a page behind a login does to a stranger. If that is the page you meant, record it by its own URL. ${LADDER}`
287
+ };
288
+ }
289
+ return null;
290
+ }
291
+ var WallError = class extends Error {
292
+ constructor(verdict) {
293
+ super(verdict.message);
294
+ this.verdict = verdict;
295
+ this.name = "WallError";
296
+ }
297
+ verdict;
298
+ };
299
+ var WALL_PROBE = `(() => {
300
+ const shown = (el) => {
301
+ const r = el.getBoundingClientRect()
302
+ if (r.width < 2 || r.height < 2) return false
303
+ const s = getComputedStyle(el)
304
+ return s.visibility !== 'hidden' && s.display !== 'none'
305
+ }
306
+ const pw = [...document.querySelectorAll('input[type="password"]')].filter(shown)
307
+ const otp = [...document.querySelectorAll('input[autocomplete="one-time-code"]')].filter(shown)
308
+ return {
309
+ passwordFields: pw.length,
310
+ newPasswordField: pw.some((el) => (el.getAttribute('autocomplete') || '').includes('new-password')),
311
+ oneTimeCodeField: otp.length > 0,
312
+ }
313
+ })()`;
314
+
315
+ // src/record/exposure.ts
316
+ var EXPOSURE_MATCHERS_SRC = `(() => {
317
+ const EMAIL = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\\.)+[A-Za-z]{2,})/g
318
+ const SAFE_HOST = /(^|\\.)example\\.(com|org|net)$|\\.(test|example|invalid|localhost)$/i
319
+ const KEY = new RegExp([
320
+ '\\\\b[srp]k_(?:live|test)_[A-Za-z0-9]{8,}',
321
+ '\\\\bgh[pousr]_[A-Za-z0-9]{20,}',
322
+ '\\\\bgithub_pat_[A-Za-z0-9_]{20,}',
323
+ '\\\\bxox[abprs]-[A-Za-z0-9-]{10,}',
324
+ '\\\\bAKIA[0-9A-Z]{16}\\\\b',
325
+ '\\\\bAIza[0-9A-Za-z_-]{30,}',
326
+ '\\\\bvos_(?:sk|rg|ho)_[A-Za-z0-9]{8,}',
327
+ '\\\\beyJ[A-Za-z0-9_-]{8,}\\\\.eyJ[A-Za-z0-9_-]{8,}\\\\.[A-Za-z0-9_-]{8,}',
328
+ ].join('|'))
329
+ const CARD = /(?:^|[^0-9])((?:[0-9][ -]?){12,18}[0-9])(?![0-9])/g
330
+ const TAIL = /[\\u2022\\u00b7*xX]{2,}[ -]?[0-9]{4}(?![0-9])/
331
+ const luhn = (digits) => {
332
+ let sum = 0, alt = false
333
+ for (let i = digits.length - 1; i >= 0; i--) {
334
+ let d = digits.charCodeAt(i) - 48
335
+ if (alt) { d *= 2; if (d > 9) d -= 9 }
336
+ sum += d; alt = !alt
337
+ }
338
+ return sum % 10 === 0
339
+ }
340
+ const kindsIn = (text) => {
341
+ const kinds = []
342
+ if (!text || text.length < 6) return kinds
343
+ EMAIL.lastIndex = 0
344
+ for (let m; (m = EMAIL.exec(text)); ) {
345
+ if (!SAFE_HOST.test(m[1])) { kinds.push('email'); break }
346
+ }
347
+ if (KEY.test(text)) kinds.push('key')
348
+ CARD.lastIndex = 0
349
+ for (let m; (m = CARD.exec(text)); ) {
350
+ const digits = m[1].replace(/[ -]/g, '')
351
+ if (digits.length >= 13 && digits.length <= 19 && !/^0+$/.test(digits) && luhn(digits)) { kinds.push('card'); break }
352
+ }
353
+ if (TAIL.test(text)) kinds.push('card-tail')
354
+ return kinds
355
+ }
356
+ return { kindsIn }
357
+ })()`;
358
+ var EXPOSURE_PROBE = `(() => {
359
+ const { kindsIn } = ${EXPOSURE_MATCHERS_SRC}
360
+ const vw = innerWidth, vh = innerHeight
361
+ // A selector that reaches THIS element and no other, so it can be pasted
362
+ // into "mask" as it is: climb until the path is unique, and say which
363
+ // sibling wherever a tag-and-class alone would match several.
364
+ const segment = (el) => {
365
+ const tid = el.getAttribute('data-testid')
366
+ if (tid) return el.tagName.toLowerCase() + '[data-testid="' + tid + '"]'
367
+ if (el.id && /^[A-Za-z_][\\w-]*$/.test(el.id)) return el.tagName.toLowerCase() + '#' + el.id
368
+ const cls = [...el.classList].filter((c) => /^[A-Za-z_-][\\w-]*$/.test(c)).slice(0, 2)
369
+ let seg = el.tagName.toLowerCase() + (cls.length ? '.' + cls.join('.') : '')
370
+ const p = el.parentElement
371
+ if (p && [...p.children].filter((c) => c.matches(seg)).length > 1)
372
+ seg += ':nth-child(' + ([...p.children].indexOf(el) + 1) + ')'
373
+ return seg
374
+ }
375
+ const name = (el) => {
376
+ const parts = []
377
+ for (let cur = el; cur && cur !== document.body && cur !== document.documentElement; cur = cur.parentElement) {
378
+ parts.unshift(segment(cur))
379
+ let n = 2
380
+ try { n = document.querySelectorAll(parts.join(' > ')).length } catch {}
381
+ // a bare tag is unique HERE and nowhere else: give it its parent too
382
+ const bare = parts.length === 1 && !/[#.\\[]/.test(parts[0])
383
+ if ((n === 1 && !bare) || parts.length >= 5) break
384
+ }
385
+ return parts.join(' > ')
386
+ }
387
+ const seen = (el) => {
388
+ if (el.closest('[data-vos-masked]')) return null
389
+ const r = el.getBoundingClientRect()
390
+ if (r.width < 2 || r.height < 2) return null
391
+ if (r.bottom <= 0 || r.right <= 0 || r.top >= vh || r.left >= vw) return null
392
+ const s = getComputedStyle(el)
393
+ if (s.visibility === 'hidden' || s.display === 'none' || +s.opacity === 0) return null
394
+ return r
395
+ }
396
+ const out = [], keys = new Set()
397
+ const add = (el, text) => {
398
+ const kinds = kindsIn(text)
399
+ if (!kinds.length) return
400
+ const r = seen(el)
401
+ if (!r) return
402
+ const selector = name(el)
403
+ for (const kind of kinds) {
404
+ const k = kind + '|' + selector
405
+ if (keys.has(k) || out.length >= 40) continue
406
+ keys.add(k)
407
+ out.push({ kind, selector, rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
408
+ }
409
+ }
410
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT)
411
+ for (let n; (n = walker.nextNode()); ) {
412
+ const el = n.parentElement
413
+ if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(el.tagName)) continue
414
+ add(el, n.nodeValue)
415
+ }
416
+ for (const el of document.querySelectorAll('input, textarea')) {
417
+ if (/^(password|hidden|file|checkbox|radio)$/.test(el.type)) continue
418
+ add(el, el.value)
419
+ }
420
+ const hits = {}
421
+ for (const [sel, n] of Object.entries(window.__vosMaskHits || {})) hits[sel] = n
422
+ return { exposures: out, maskHits: hits }
423
+ })()`;
424
+ function maskInitScript(masks) {
425
+ const rules = masks.map((m) => ({
426
+ selector: m.selector,
427
+ as: m.as === "text" ? "text" : "blur",
428
+ text: m.text ?? ""
429
+ }));
430
+ return `(() => {
431
+ const RULES = ${JSON.stringify(rules)}
432
+ const hits = (window.__vosMaskHits = window.__vosMaskHits || {})
433
+ for (const r of RULES) hits[r.selector] = hits[r.selector] || 0
434
+ const css = RULES.map((r) => r.selector + '[data-vos-masked="blur"]{filter:blur(7px) !important;user-select:none !important}').join('\\n')
435
+ const paint = () => {
436
+ const root = document.head || document.documentElement
437
+ if (!root || document.getElementById('__vos-mask-style')) return
438
+ const style = document.createElement('style')
439
+ style.id = '__vos-mask-style'
440
+ style.textContent = css
441
+ root.appendChild(style)
442
+ }
443
+ const apply = () => {
444
+ paint()
445
+ for (const r of RULES) {
446
+ let found
447
+ try { found = document.querySelectorAll(r.selector) } catch { continue }
448
+ if (found.length > hits[r.selector]) hits[r.selector] = found.length
449
+ for (const el of found) {
450
+ const form = /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)
451
+ const mode = r.as === 'text' && !form ? 'text' : 'blur'
452
+ if (el.getAttribute('data-vos-masked') !== mode) el.setAttribute('data-vos-masked', mode)
453
+ if (mode !== 'text') continue
454
+ const texts = []
455
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
456
+ for (let n; (n = w.nextNode()); ) texts.push(n)
457
+ if (!texts.length) { if (el.textContent !== r.text) el.textContent = r.text; continue }
458
+ texts.forEach((n, i) => { const want = i === 0 ? r.text : ''; if (n.nodeValue !== want) n.nodeValue = want })
459
+ }
460
+ }
461
+ }
462
+ new MutationObserver(apply).observe(document, { childList: true, subtree: true, characterData: true })
463
+ document.addEventListener('DOMContentLoaded', apply)
464
+ apply()
465
+ })()`;
466
+ }
467
+ var ExposureLog = class {
468
+ byKey = /* @__PURE__ */ new Map();
469
+ maskHits = /* @__PURE__ */ new Map();
470
+ constructor(masks = []) {
471
+ for (const m of masks) this.maskHits.set(m.selector, 0);
472
+ }
473
+ add(step, scan) {
474
+ if (!scan) return;
475
+ for (const e of scan.exposures ?? []) {
476
+ const key = `${e.kind}|${e.selector}`;
477
+ const had = this.byKey.get(key);
478
+ if (had) had.seen += 1;
479
+ else this.byKey.set(key, { step, ...e, seen: 1 });
480
+ }
481
+ for (const [sel, n] of Object.entries(scan.maskHits ?? {})) {
482
+ if (n > (this.maskHits.get(sel) ?? 0)) this.maskHits.set(sel, n);
483
+ }
484
+ }
485
+ exposures() {
486
+ return [...this.byKey.values()];
487
+ }
488
+ masks(rules) {
489
+ return rules.map((m) => ({
490
+ selector: m.selector,
491
+ as: m.as === "text" ? "text" : "blur",
492
+ hits: this.maskHits.get(m.selector) ?? 0
493
+ }));
494
+ }
495
+ };
496
+ var KIND_WORDS = {
497
+ email: "an email address",
498
+ key: "something shaped like an API key or token",
499
+ card: "a card number",
500
+ "card-tail": "a card's last digits"
501
+ };
502
+ function exposureLine(e) {
503
+ const when = e.step < 0 ? "as the page opened" : `at step #${e.step}`;
504
+ return `${KIND_WORDS[e.kind]} in ${e.selector} (${when}, ${e.rect.x},${e.rect.y} ${e.rect.w}\xD7${e.rect.h})`;
505
+ }
506
+ var EXPOSURE_ADVICE = 'The recording shows these. Record from a demo account, or hide them before the camera rolls with "mask" in actions.json: { "selector": "\u2026", "as": "text", "text": "demo@acme.test" } swaps the words, "as": "blur" blurs. Then re-record.';
507
+
508
+ // src/record/setup.ts
509
+ var VERBS = /* @__PURE__ */ new Set(["wait", "click", "type", "press", "goto"]);
510
+ function validateSetup(value) {
511
+ const errors = [];
512
+ if (!Array.isArray(value)) return ["setup must be an array of steps"];
513
+ value.forEach((raw, i) => {
514
+ const at = `setup[${i}]`;
515
+ if (typeof raw !== "object" || raw === null) {
516
+ errors.push(`${at}: must be an object`);
517
+ return;
518
+ }
519
+ const s = raw;
520
+ if (typeof s.do !== "string" || !VERBS.has(s.do)) {
521
+ errors.push(`${at}: "do" must be one of ${[...VERBS].join(", ")}`);
522
+ return;
523
+ }
524
+ if ((s.do === "click" || s.do === "type") && typeof s.selector !== "string")
525
+ errors.push(`${at}: ${s.do} needs a selector`);
526
+ if (s.do === "wait" && typeof s.ms !== "number")
527
+ errors.push(`${at}: wait needs ms`);
528
+ if (s.do === "press" && typeof s.key !== "string")
529
+ errors.push(`${at}: press needs a key`);
530
+ if (s.do === "goto" && typeof s.url !== "string")
531
+ errors.push(`${at}: goto needs a url`);
532
+ if (s.do === "type") {
533
+ const t = s.text;
534
+ const isEnv = typeof t === "object" && t !== null && typeof t.env === "string" && Object.keys(t).length === 1;
535
+ if (typeof t !== "string" && !isEnv)
536
+ errors.push(
537
+ `${at}: type needs text, a string or { "env": "NAME" } read at run time`
538
+ );
539
+ if (typeof t === "string" && /passw|secret|token/i.test(String(s.selector)) && t.length > 0)
540
+ errors.push(
541
+ `${at}: a literal value typed into ${String(s.selector)} would live in actions.json, which is committed and pushed. Use { "env": "NAME" }`
542
+ );
543
+ }
544
+ });
545
+ return errors;
546
+ }
547
+ function setupEnvNames(steps) {
548
+ const out = /* @__PURE__ */ new Set();
549
+ for (const s of steps ?? []) {
550
+ if (s.do === "type" && typeof s.text === "object") out.add(s.text.env);
551
+ }
552
+ return [...out];
553
+ }
554
+ function resolveSetupText(t, env) {
555
+ if (typeof t === "string") return { value: t, secret: false };
556
+ const v = env[t.env];
557
+ if (v === void 0 || v === "")
558
+ throw new SetupEnvError(
559
+ `setup: the environment variable ${t.env} is not set, and a type step reads it. Export it in the shell that runs vos record; it is never written to a file`
560
+ );
561
+ return { value: v, secret: true };
562
+ }
563
+ async function runSetup(page, steps, opts) {
564
+ const env = opts.env ?? {};
565
+ const lines = [];
566
+ for (const [i, step] of steps.entries()) {
567
+ const settle = "ms" in step && typeof step.ms === "number" ? step.ms : 150;
568
+ try {
569
+ switch (step.do) {
570
+ case "wait":
571
+ await opts.sleep(step.ms);
572
+ lines.push(`setup #${i} wait ${step.ms}ms`);
573
+ break;
574
+ case "goto":
575
+ await page.goto(step.url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
576
+ lines.push(`setup #${i} goto ${step.url}`);
577
+ break;
578
+ case "press":
579
+ await page.keyboard.press(step.key);
580
+ await opts.sleep(settle);
581
+ lines.push(`setup #${i} press ${step.key}`);
582
+ break;
583
+ case "click": {
584
+ const loc = page.locator(step.selector).first();
585
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
586
+ await loc.click();
587
+ await opts.sleep(settle);
588
+ lines.push(`setup #${i} click ${step.selector}`);
589
+ break;
590
+ }
591
+ case "type": {
592
+ const loc = page.locator(step.selector).first();
593
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
594
+ const { value, secret } = resolveSetupText(step.text, env);
595
+ await loc.fill(value);
596
+ await opts.sleep(settle);
597
+ lines.push(
598
+ `setup #${i} type ${secret ? `\${${step.text.env}}` : `${value.length} chars`} into ${step.selector}`
599
+ );
600
+ break;
601
+ }
602
+ }
603
+ } catch (e) {
604
+ if (e instanceof SetupEnvError) throw e;
605
+ return {
606
+ ran: i,
607
+ lines,
608
+ failed: {
609
+ step: i,
610
+ do: step.do,
611
+ ..."selector" in step ? { selector: step.selector } : {}
612
+ }
613
+ };
614
+ }
615
+ }
616
+ return { ran: steps.length, lines };
617
+ }
618
+ var SetupEnvError = class extends Error {
619
+ constructor(message) {
620
+ super(message);
621
+ this.name = "SetupEnvError";
622
+ }
623
+ };
624
+ var SetupError = class extends Error {
625
+ constructor(failed) {
626
+ super(
627
+ `setup #${failed.step} ${failed.do}${failed.selector ? ` ${failed.selector}` : ""}: the selector never appeared, so the take would begin at a half-finished setup. Nothing was recorded. Fix the setup step (rehearse with --dry-run), then record.`
628
+ );
629
+ this.failed = failed;
630
+ this.name = "SetupError";
631
+ }
632
+ failed;
633
+ };
634
+ function parseHeaders(given) {
635
+ const out = {};
636
+ for (const h of given ?? []) {
637
+ const i = h.indexOf("=");
638
+ if (i <= 0) throw new Error(`--header wants name=value, got "${h}"`);
639
+ out[h.slice(0, i).trim()] = h.slice(i + 1);
640
+ }
641
+ return out;
642
+ }
643
+
644
+ // src/record/recorder.ts
645
+ var DRY_PAUSE_MS = 120;
646
+ function jpegDims(buf) {
647
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
648
+ let i = 2;
649
+ while (i < buf.length - 9) {
650
+ if (buf[i] !== 255) return null;
651
+ const marker = buf[i + 1];
652
+ const len = view.getUint16(i + 2);
653
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
654
+ return { h: view.getUint16(i + 5), w: view.getUint16(i + 7) };
655
+ }
656
+ i += 2 + len;
657
+ }
658
+ return null;
659
+ }
660
+ function base64Bytes(data) {
661
+ const bin = atob(data);
662
+ const out = new Uint8Array(bin.length);
663
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
664
+ return out;
665
+ }
666
+ async function recordTake(browser, url, actions, sink, log, opts = {}) {
667
+ const dry = opts.dryRun === true;
668
+ const sleep = dry ? (ms) => realSleep(Math.min(ms, DRY_PAUSE_MS)) : realSleep;
669
+ const maxSeconds = dry ? Infinity : opts.maxDurationSeconds ?? Infinity;
670
+ const vw = actions.viewport?.width ?? 1280;
671
+ const vh = actions.viewport?.height ?? 720;
672
+ const context = opts.context ?? await browser.newContext({
673
+ viewport: { width: vw, height: vh },
674
+ deviceScaleFactor: 1,
675
+ ...opts.storageState ? { storageState: opts.storageState } : {},
676
+ ...opts.headers && Object.keys(opts.headers).length ? { extraHTTPHeaders: opts.headers } : {}
677
+ });
678
+ if (opts.context && opts.headers && Object.keys(opts.headers).length)
679
+ await context.setExtraHTTPHeaders(opts.headers);
680
+ const masks = actions.mask ?? [];
681
+ if (masks.length) await context.addInitScript(maskInitScript(masks));
682
+ const exposureLog = new ExposureLog(masks);
683
+ const page = await context.newPage();
684
+ const scanExposures = async (step) => {
685
+ const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
686
+ exposureLog.add(step, scan);
687
+ };
688
+ page.on("console", (m) => {
689
+ if (m.type() === "error") log(` [page error] ${m.text()}`);
690
+ });
691
+ log(`goto ${url}`);
692
+ let navTimeout = false;
693
+ let response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => {
694
+ navTimeout = true;
695
+ log(" (networkidle timeout \u2014 continuing)");
696
+ return null;
697
+ });
698
+ await sleep(800);
699
+ const setup = actions.setup ?? [];
700
+ if (setup.length) {
701
+ log(`setup: ${setup.length} step(s), off camera`);
702
+ const result = await runSetup(page, setup, {
703
+ sleep: realSleep,
704
+ env: opts.env ?? {}
705
+ });
706
+ for (const line of result.lines) log(` ${line}`);
707
+ if (result.failed) {
708
+ await context.close().catch(() => {
709
+ });
710
+ throw new SetupError(result.failed);
711
+ }
712
+ log(`goto ${url} (after setup)`);
713
+ response = await page.goto(url, { waitUntil: "networkidle", timeout: 45e3 }).catch(() => null);
714
+ await sleep(800);
715
+ }
716
+ let wall = null;
717
+ if (opts.onArrival) {
718
+ const seen = await page.evaluate(WALL_PROBE).catch(() => null);
719
+ try {
720
+ wall = await opts.onArrival({
721
+ askedUrl: url,
722
+ landedUrl: page.url(),
723
+ ...response ? { status: response.status() } : {},
724
+ passwordFields: seen?.passwordFields ?? 0,
725
+ newPasswordField: seen?.newPasswordField ?? false,
726
+ oneTimeCodeField: seen?.oneTimeCodeField ?? false
727
+ }) ?? null;
728
+ } catch (e) {
729
+ await context.close().catch(() => {
730
+ });
731
+ throw e;
732
+ }
733
+ }
734
+ await scanExposures(-1);
735
+ const cdp = await context.newCDPSession(page);
736
+ const frames = [];
737
+ let frameIdx = 0;
738
+ let t0 = 0;
739
+ const first = { dims: null };
740
+ let sinkChain = Promise.resolve();
741
+ if (!dry)
742
+ cdp.on("Page.screencastFrame", (ev) => {
743
+ const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
744
+ const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
745
+ const bytes = base64Bytes(ev.data);
746
+ if (!first.dims) first.dims = jpegDims(bytes);
747
+ frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
748
+ sinkChain = sinkChain.then(() => sink.frame(file, bytes)).catch(() => {
749
+ });
750
+ cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
751
+ });
752
+ });
753
+ t0 = Date.now();
754
+ if (!dry)
755
+ await cdp.send("Page.startScreencast", {
756
+ format: "jpeg",
757
+ quality: 90,
758
+ maxWidth: vw,
759
+ maxHeight: vh,
760
+ everyNthFrame: 1
761
+ });
762
+ const events = [];
763
+ const cur = { x: 48, y: 48 };
764
+ const now = () => Date.now() - t0;
765
+ const emit = (e) => events.push({ t: now(), ...e });
766
+ await page.mouse.move(cur.x, cur.y);
767
+ emit({ x: cur.x, y: cur.y, type: "move" });
768
+ const clock = { now: () => Date.now(), sleep };
769
+ const moveTo = async (tx, ty) => {
770
+ const dist = Math.hypot(tx - cur.x, ty - cur.y);
771
+ const from = { ...cur };
772
+ await clockMotion(
773
+ dry ? 0 : pointerTravelMs(dist),
774
+ async (u) => {
775
+ cur.x = from.x + (tx - from.x) * u;
776
+ cur.y = from.y + (ty - from.y) * u;
777
+ await page.mouse.move(cur.x, cur.y);
778
+ emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
779
+ },
780
+ clock
781
+ );
782
+ };
783
+ const boxOf = async (selector) => {
784
+ const loc = page.locator(selector).first();
785
+ try {
786
+ await loc.waitFor({ state: "visible", timeout: 8e3 });
787
+ const before = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => [0, 0]);
788
+ await loc.scrollIntoViewIfNeeded();
789
+ const after = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => before);
790
+ if (before[0] !== after[0] || before[1] !== after[1])
791
+ await sleep(SCROLL_SETTLE_MS);
792
+ const bb = await loc.boundingBox();
793
+ if (!bb) return null;
794
+ return { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
795
+ } catch {
796
+ log(` (selector not found, skipping: ${selector})`);
797
+ return null;
798
+ }
799
+ };
800
+ const clickAt = async (rect) => {
801
+ await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
802
+ await sleep(PRESS_LEAD_MS);
803
+ emit({
804
+ x: Math.round(cur.x),
805
+ y: Math.round(cur.y),
806
+ type: "down",
807
+ button: 0,
808
+ rect
809
+ });
810
+ await page.mouse.down();
811
+ await sleep(PRESS_HOLD_MS);
812
+ await page.mouse.up();
813
+ emit({
814
+ x: Math.round(cur.x),
815
+ y: Math.round(cur.y),
816
+ type: "up",
817
+ button: 0,
818
+ rect
819
+ });
820
+ };
821
+ const waitSettled = async () => {
822
+ if (dry) return null;
823
+ const pressAt = now();
824
+ const before = frames.length;
825
+ for (; ; ) {
826
+ const t = now();
827
+ const last = frames.length > before ? frames[frames.length - 1] : null;
828
+ const sinceChange = last ? t - last.tMs : null;
829
+ if (settleVerdict({ sincePress: t - pressAt, sinceChange }) === "settled")
830
+ return sinceChange;
831
+ await sleep(40);
832
+ }
833
+ };
834
+ const skipped = [];
835
+ const stepSpans = [];
836
+ const paces = [];
837
+ let capped = false;
838
+ for (const [stepIdx, step] of actions.steps.entries()) {
839
+ if (capReached(now(), maxSeconds)) {
840
+ capped = true;
841
+ break;
842
+ }
843
+ const stepStart = now();
844
+ let gestureMs = 0;
845
+ const gesture = async (fn) => {
846
+ const t = now();
847
+ const out = await fn();
848
+ gestureMs += now() - t;
849
+ return out;
850
+ };
851
+ const skippedBefore = skipped.length;
852
+ const urlBefore = page.url();
853
+ let stepRect = null;
854
+ switch (step.do) {
855
+ case "wait":
856
+ await sleep(clampWait(step.ms, now(), maxSeconds));
857
+ break;
858
+ case "move":
859
+ await moveTo(step.x, step.y);
860
+ break;
861
+ case "hover": {
862
+ const rect = await boxOf(step.selector);
863
+ stepRect = rect;
864
+ if (rect) {
865
+ await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
866
+ log(`hover ${step.selector}`);
867
+ await sleep(step.ms ?? 700);
868
+ } else
869
+ skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
870
+ break;
871
+ }
872
+ case "click": {
873
+ const rect = await boxOf(step.selector);
874
+ stepRect = rect;
875
+ if (rect) {
876
+ await gesture(() => clickAt(rect));
877
+ log(`click ${step.selector}`);
878
+ const sinceChange = await gesture(() => waitSettled());
879
+ await sleep(holdLeftMs(settleMs(step), sinceChange));
880
+ } else
881
+ skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
882
+ break;
883
+ }
884
+ case "type": {
885
+ const rect = await boxOf(step.selector);
886
+ stepRect = rect;
887
+ if (rect) {
888
+ if (step.focus !== false) await gesture(() => clickAt(rect));
889
+ const delay = step.delayMs ?? 40;
890
+ const ping = () => emit({
891
+ x: Math.round(rect.x + rect.w / 2),
892
+ y: Math.round(rect.y + rect.h / 2),
893
+ type: "key",
894
+ rect
895
+ });
896
+ let lastPing = -Infinity;
897
+ await clockTyping(
898
+ [...step.text],
899
+ delay,
900
+ async (ch) => {
901
+ if (now() - lastPing >= 350) {
902
+ lastPing = now();
903
+ ping();
904
+ }
905
+ await page.keyboard.type(ch);
906
+ },
907
+ clock
908
+ );
909
+ ping();
910
+ log(`type "${step.text}" into ${step.selector}`);
911
+ await gesture(() => sleep(settleMs(step)));
912
+ } else
913
+ skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
914
+ break;
915
+ }
916
+ case "scroll": {
917
+ const total = Math.abs(step.dy);
918
+ const dir = Math.sign(step.dy);
919
+ for (let done = 0; done < total; done += 120) {
920
+ await page.mouse.wheel(0, dir * Math.min(120, total - done));
921
+ emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "scroll" });
922
+ await sleep(40);
923
+ }
924
+ await gesture(() => sleep(settleMs(step)));
925
+ break;
926
+ }
927
+ case "drag": {
928
+ let start = null;
929
+ let rect;
930
+ if (step.selector) {
931
+ const box = await boxOf(step.selector);
932
+ stepRect = box;
933
+ if (box) {
934
+ rect = box;
935
+ start = { x: box.x + box.w / 2, y: box.y + box.h / 2 };
936
+ } else {
937
+ skipped.push({
938
+ step: stepIdx,
939
+ do: step.do,
940
+ selector: step.selector
941
+ });
942
+ break;
943
+ }
944
+ } else if (typeof step.x === "number" && typeof step.y === "number") {
945
+ start = { x: step.x, y: step.y };
946
+ }
947
+ if (!start) break;
948
+ await gesture(() => moveTo(start.x, start.y));
949
+ await gesture(() => sleep(PRESS_LEAD_MS));
950
+ emit({
951
+ x: Math.round(cur.x),
952
+ y: Math.round(cur.y),
953
+ type: "down",
954
+ button: 0,
955
+ rect
956
+ });
957
+ await page.mouse.down();
958
+ await gesture(() => sleep(PRESS_HOLD_MS));
959
+ const dur = step.ms ?? 700;
960
+ const from = { ...cur };
961
+ await clockMotion(
962
+ dur,
963
+ async (u) => {
964
+ cur.x = from.x + (step.tx - from.x) * u;
965
+ cur.y = from.y + (step.ty - from.y) * u;
966
+ await page.mouse.move(cur.x, cur.y);
967
+ emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
968
+ },
969
+ clock
970
+ );
971
+ await gesture(() => sleep(settleMs({ do: "drag" })));
972
+ await page.mouse.up();
973
+ emit({
974
+ x: Math.round(cur.x),
975
+ y: Math.round(cur.y),
976
+ type: "up",
977
+ button: 0,
978
+ rect
979
+ });
980
+ log(
981
+ `drag ${step.selector ?? `${start.x},${start.y}`} \u2192 ${step.tx},${step.ty}`
982
+ );
983
+ await sleep(400);
984
+ break;
985
+ }
986
+ }
987
+ const stepInfo = step;
988
+ stepSpans.push({
989
+ step: stepIdx,
990
+ ...stepInfo.id ? { id: stepInfo.id } : {},
991
+ do: step.do,
992
+ ...stepInfo.selector ? { selector: stepInfo.selector } : {},
993
+ tStart: +(stepStart / 1e3).toFixed(3),
994
+ tEnd: +(now() / 1e3).toFixed(3),
995
+ ...skipped.length > skippedBefore ? { skipped: true } : {},
996
+ ...page.url() !== urlBefore ? { navigated: true } : {},
997
+ ...stepRect ? {
998
+ rect: {
999
+ x: +stepRect.x.toFixed(1),
1000
+ y: +stepRect.y.toFixed(1),
1001
+ w: +stepRect.w.toFixed(1),
1002
+ h: +stepRect.h.toFixed(1)
1003
+ }
1004
+ } : {}
1005
+ });
1006
+ paces.push({
1007
+ step: stepIdx,
1008
+ do: step.do,
1009
+ askedMs: askedMs(step),
1010
+ gestureMs,
1011
+ wallMs: now() - stepStart
1012
+ });
1013
+ await scanExposures(stepIdx);
1014
+ }
1015
+ if (!capped) await sleep(TRAILING_HOLD_MS);
1016
+ if (capped || capReached(now(), maxSeconds)) {
1017
+ capped = true;
1018
+ log(` ${cappedLine(maxSeconds)}`);
1019
+ }
1020
+ const durationMs = capped ? Math.min(now(), maxSeconds * 1e3) : now();
1021
+ await cdp.send("Page.stopScreencast").catch(() => {
1022
+ });
1023
+ await sleep(200);
1024
+ const pageTitle = await page.title().catch(() => "");
1025
+ await context.close();
1026
+ await sinkChain;
1027
+ const meta = {
1028
+ dpr: 1,
1029
+ zoom: 1,
1030
+ t0,
1031
+ durationMs,
1032
+ width: vw,
1033
+ height: vh,
1034
+ fps: 30,
1035
+ hasAudio: false,
1036
+ captureWidth: first.dims?.w ?? vw,
1037
+ captureHeight: first.dims?.h ?? vh,
1038
+ captureSurface: "tab",
1039
+ pageUrl: url,
1040
+ pageTitle,
1041
+ platform: opts.platform ?? "linux",
1042
+ producer: "cli",
1043
+ steps: stepSpans,
1044
+ ...wall ? { wall: { kind: wall.kind, asked: wall.asked, landed: wall.landed } } : {},
1045
+ ...exposureLog.exposures().length ? { exposures: exposureLog.exposures() } : {},
1046
+ ...masks.length ? { masks: exposureLog.masks(masks) } : {}
1047
+ };
1048
+ const freezes = [];
1049
+ for (let i = 1; i < frames.length; i++) {
1050
+ const gap = frames[i].tMs - frames[i - 1].tMs;
1051
+ if (gap >= 400) {
1052
+ freezes.push({
1053
+ from: +(frames[i - 1].tMs / 1e3).toFixed(2),
1054
+ to: +(frames[i].tMs / 1e3).toFixed(2),
1055
+ ms: gap
1056
+ });
1057
+ }
1058
+ }
1059
+ const frozenMs = freezes.reduce((sum, f) => sum + f.ms, 0);
1060
+ const freezePct = durationMs > 0 ? Math.round(frozenMs / durationMs * 100) : 0;
1061
+ const dead = deadTime(stepSpans, frames, events, durationMs);
1062
+ if (deadWarns(dead)) log(` WARNING: ${deadLine(dead)}`);
1063
+ const pace = paceReport(paces);
1064
+ if (!dry) log(` ${paceLine(pace)}`);
1065
+ return {
1066
+ events,
1067
+ frames,
1068
+ meta,
1069
+ pace,
1070
+ skipped,
1071
+ navTimeout,
1072
+ wall,
1073
+ exposures: exposureLog.exposures(),
1074
+ masks: exposureLog.masks(masks),
1075
+ freezes,
1076
+ freezePct,
1077
+ dead,
1078
+ capped,
1079
+ actions: { ...actions, url }
1080
+ };
1081
+ }
1082
+
1083
+ // src/record/actions.ts
1084
+ var VERBS2 = /* @__PURE__ */ new Set([
1085
+ "wait",
1086
+ "hover",
1087
+ "click",
1088
+ "type",
1089
+ "scroll",
1090
+ "move",
1091
+ "drag"
1092
+ ]);
1093
+ function validateActions(value) {
1094
+ const errors = [];
1095
+ if (typeof value !== "object" || value === null)
1096
+ return ["actions file must be a JSON object"];
1097
+ const obj = value;
1098
+ if (obj.url !== void 0 && typeof obj.url !== "string")
1099
+ errors.push("url must be a string");
1100
+ if (obj.viewport !== void 0) {
1101
+ const v = obj.viewport;
1102
+ if (typeof v !== "object" || v === null) {
1103
+ errors.push("viewport must be { width, height }");
1104
+ } else {
1105
+ const vp = v;
1106
+ if (typeof vp.width !== "number" || typeof vp.height !== "number") {
1107
+ errors.push("viewport must be { width, height }");
1108
+ }
1109
+ }
1110
+ }
1111
+ if (obj.setup !== void 0) errors.push(...validateSetup(obj.setup));
1112
+ if (obj.mask !== void 0) {
1113
+ if (!Array.isArray(obj.mask)) {
1114
+ errors.push("mask must be an array of { selector, as?, text? }");
1115
+ } else {
1116
+ obj.mask.forEach((raw, i) => {
1117
+ const at = `mask[${i}]`;
1118
+ const m = raw ?? {};
1119
+ if (typeof m.selector !== "string" || !m.selector.trim())
1120
+ errors.push(`${at}: needs a selector`);
1121
+ if (m.as !== void 0 && m.as !== "blur" && m.as !== "text")
1122
+ errors.push(`${at}: as must be "blur" or "text"`);
1123
+ if (m.as === "text" && (typeof m.text !== "string" || !m.text))
1124
+ errors.push(`${at}: as "text" needs the text to show instead`);
1125
+ if (m.as !== "text" && m.text !== void 0)
1126
+ errors.push(`${at}: text is only read with as "text"`);
1127
+ });
1128
+ }
1129
+ }
1130
+ if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
1131
+ errors.push("steps must be a non-empty array");
1132
+ return errors;
1133
+ }
1134
+ const seenIds = /* @__PURE__ */ new Set();
1135
+ obj.steps.forEach((raw, i) => {
1136
+ const at = `steps[${i}]`;
1137
+ if (typeof raw !== "object" || raw === null) {
1138
+ errors.push(`${at}: must be an object`);
1139
+ return;
1140
+ }
1141
+ const s = raw;
1142
+ if (typeof s.do !== "string" || !VERBS2.has(s.do)) {
1143
+ errors.push(`${at}: "do" must be one of ${[...VERBS2].join(", ")}`);
1144
+ return;
1145
+ }
1146
+ if (s.id !== void 0) {
1147
+ if (typeof s.id !== "string" || !s.id.trim()) {
1148
+ errors.push(`${at}: id must be a non-empty string`);
1149
+ } else if (seenIds.has(s.id)) {
1150
+ errors.push(
1151
+ `${at}: duplicate id "${s.id}" \u2014 an anchor could not tell the two steps apart`
1152
+ );
1153
+ } else {
1154
+ seenIds.add(s.id);
1155
+ }
1156
+ }
1157
+ const needSelector = s.do === "hover" || s.do === "click" || s.do === "type";
1158
+ if (needSelector && typeof s.selector !== "string")
1159
+ errors.push(`${at}: ${s.do} needs a selector`);
1160
+ if (s.do === "wait" && typeof s.ms !== "number")
1161
+ errors.push(`${at}: wait needs ms`);
1162
+ if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
1163
+ errors.push(`${at}: ms must be a number of milliseconds \u2265 0`);
1164
+ if (s.do === "type" && typeof s.text !== "string")
1165
+ errors.push(`${at}: type needs text`);
1166
+ if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
1167
+ errors.push(`${at}: type focus must be true or false`);
1168
+ if (s.do === "scroll" && typeof s.dy !== "number")
1169
+ errors.push(`${at}: scroll needs dy`);
1170
+ if (s.do === "move" && (typeof s.x !== "number" || typeof s.y !== "number")) {
1171
+ errors.push(`${at}: move needs x and y (CSS px)`);
1172
+ }
1173
+ if (s.do === "drag") {
1174
+ if (typeof s.tx !== "number" || typeof s.ty !== "number") {
1175
+ errors.push(`${at}: drag needs tx and ty (CSS px)`);
1176
+ }
1177
+ const hasStart = typeof s.selector === "string" || typeof s.x === "number" && typeof s.y === "number";
1178
+ if (!hasStart)
1179
+ errors.push(
1180
+ `${at}: drag needs a selector OR x and y as the start point`
1181
+ );
1182
+ }
1183
+ });
1184
+ return errors;
1185
+ }
1186
+
1187
+ // src/record/encodePage.ts
1188
+ var MEDIABUNNY_URL = "https://esm.sh/mediabunny@1.55.7?target=es2022";
1189
+ function buildEncodePage(o) {
1190
+ const q = o.frameQuery ? `?${o.frameQuery}` : "";
1191
+ return `<!doctype html><html><head><meta charset="utf-8"></head><body>
1192
+ <script type="module">
1193
+ try {
1194
+ const MB = await import('${MEDIABUNNY_URL}')
1195
+ const frames = await (await fetch(${JSON.stringify(o.framesIndexUrl)})).json()
1196
+ const meta = await (await fetch(${JSON.stringify(o.metaUrl)})).json()
1197
+ const W = meta.captureWidth ?? meta.width, H = meta.captureHeight ?? meta.height
1198
+ const FPS = 30
1199
+ const durSec = meta.durationMs / 1000
1200
+ const cv = document.createElement('canvas')
1201
+ cv.width = W; cv.height = H
1202
+ const c2 = cv.getContext('2d')
1203
+ const output = new MB.Output({ format: new MB.WebMOutputFormat(), target: new MB.BufferTarget() })
1204
+ const src = new MB.CanvasSource(cv, { codec: 'vp9', bitrate: 8_000_000 })
1205
+ output.addVideoTrack(src, { frameRate: FPS })
1206
+ await output.start()
1207
+ let fi = -1
1208
+ const total = Math.ceil(durSec * FPS)
1209
+ for (let i = 0; i < total; i++) {
1210
+ const t = i / FPS
1211
+ let next = fi
1212
+ while (next + 1 < frames.length && frames[next + 1].tMs <= t * 1000) next++
1213
+ if (next < 0) next = 0
1214
+ if (next !== fi) {
1215
+ fi = next
1216
+ const blob = await (await fetch(${JSON.stringify(o.framesBaseUrl)} + frames[fi].file + ${JSON.stringify(q)})).blob()
1217
+ const bmp = await createImageBitmap(blob)
1218
+ c2.drawImage(bmp, 0, 0, W, H)
1219
+ bmp.close()
1220
+ }
1221
+ await src.add(t, 1 / FPS)
1222
+ window.__progress = i / total
1223
+ }
1224
+ await output.finalize()
1225
+ const buf = output.target.buffer
1226
+ const res = await fetch(${JSON.stringify(o.save.url)}, { method: ${JSON.stringify(o.save.method)}, headers: { 'content-type': 'video/webm' }, body: buf })
1227
+ if (!res.ok) throw new Error('save failed: ' + res.status)
1228
+ // Two hosts, two completion words: the CLI's take server polls __done,
1229
+ // the fleet's page runner polls __renderComplete.
1230
+ window.__done = { ok: true, bytes: buf.byteLength }
1231
+ window.__renderComplete = { success: true, size: buf.byteLength, uploaded: true }
1232
+ } catch (e) {
1233
+ window.__error = String(e && e.stack || e)
1234
+ window.__renderComplete = { success: false, error: String(e && e.message || e) }
1235
+ }
1236
+ </script></body></html>`;
1237
+ }
1238
+
1239
+ // src/record/plan.ts
1240
+ import {
1241
+ DEFAULT_FRAME_STYLE,
1242
+ planAutoSpeed,
1243
+ planAutoZoom,
1244
+ projectFromArtifact,
1245
+ withBackdrop
1246
+ } from "@vosjs/studio-core";
1247
+ function planFreshTake(artifact, recordingName, opts = {}) {
1248
+ const ingest = opts.backdrop ? { frame: withBackdrop(DEFAULT_FRAME_STYLE, opts.backdrop) } : {};
1249
+ const doc = projectFromArtifact(artifact, recordingName, ingest).doc;
1250
+ doc.zoom = planAutoZoom(doc.source.cursor, {
1251
+ width: doc.source.meta.width,
1252
+ height: doc.source.meta.height,
1253
+ duration: doc.source.meta.durationMs / 1e3,
1254
+ style: doc.zoomStyle,
1255
+ params: doc.zoomParams
1256
+ });
1257
+ doc.speed = planAutoSpeed(doc.source.cursor, {
1258
+ durationMs: doc.source.meta.durationMs,
1259
+ params: doc.speedParams,
1260
+ activity: opts.activity
1261
+ });
1262
+ return doc;
1263
+ }
1264
+ export {
1265
+ DEAD_SHARE_WARN_PCT,
1266
+ DEAD_STRETCH_WARN_MS,
1267
+ EXPOSURE_ADVICE,
1268
+ EXPOSURE_MATCHERS_SRC,
1269
+ EXPOSURE_PROBE,
1270
+ ExposureLog,
1271
+ MOTION_TICK_MS,
1272
+ POINTER_MAX_MS,
1273
+ POINTER_MIN_MS,
1274
+ POINTER_MS_PER_PX,
1275
+ PRESS_HOLD_MS,
1276
+ PRESS_LEAD_MS,
1277
+ QUIET_MS,
1278
+ READ_BEAT_MS,
1279
+ RESPONSE_MS,
1280
+ SCROLL_SETTLE_MS,
1281
+ SETTLE_CAP_MS,
1282
+ SETTLE_MS,
1283
+ SetupEnvError,
1284
+ SetupError,
1285
+ TRAILING_HOLD_MS,
1286
+ WALL_PROBE,
1287
+ WallError,
1288
+ askedMs,
1289
+ base64Bytes,
1290
+ buildEncodePage,
1291
+ capReached,
1292
+ cappedLine,
1293
+ clampWait,
1294
+ clockMotion,
1295
+ clockTyping,
1296
+ deadLine,
1297
+ deadTime,
1298
+ deadWarns,
1299
+ easeInOutCubic,
1300
+ exposureLine,
1301
+ formatDurationCap,
1302
+ holdLeftMs,
1303
+ isIdpHost,
1304
+ isSignInPath,
1305
+ jpegDims,
1306
+ maskInitScript,
1307
+ paceLine,
1308
+ paceReport,
1309
+ parseHeaders,
1310
+ planFreshTake,
1311
+ pointerTravelMs,
1312
+ realSleep,
1313
+ recordTake,
1314
+ redirectedAway,
1315
+ resolveSetupText,
1316
+ runSetup,
1317
+ settleMs,
1318
+ settleVerdict,
1319
+ setupEnvNames,
1320
+ validateActions,
1321
+ validateSetup,
1322
+ wallVerdict
1323
+ };
1324
+ //# sourceMappingURL=index.js.map