@vosjs/cli 0.32.0 → 0.34.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.
@@ -6,9 +6,9 @@ import {
6
6
  } from "./chunk-NHKHTIDR.js";
7
7
 
8
8
  // src/plugin/run.ts
9
- import { mkdir as mkdir9, readFile as readFile14, rm as rm4 } from "fs/promises";
10
- import { existsSync as existsSync14 } from "fs";
11
- import { join as join17, resolve as resolve9 } from "path";
9
+ import { mkdir as mkdir9, readFile as readFile15, rm as rm4 } from "fs/promises";
10
+ import { existsSync as existsSync15 } from "fs";
11
+ import { join as join18, resolve as resolve9 } from "path";
12
12
  import {
13
13
  END_CARD_FROM as END_CARD_FROM2,
14
14
  docOutputDuration as docOutputDuration3,
@@ -156,6 +156,8 @@ function validateActions(value) {
156
156
  errors.push(`${at2}: ${s.do} needs a selector`);
157
157
  if (s.do === "wait" && typeof s.ms !== "number")
158
158
  errors.push(`${at2}: wait needs ms`);
159
+ if (s.ms !== void 0 && (typeof s.ms !== "number" || !Number.isFinite(s.ms) || s.ms < 0))
160
+ errors.push(`${at2}: ms must be a number of milliseconds \u2265 0`);
159
161
  if (s.do === "type" && typeof s.text !== "string")
160
162
  errors.push(`${at2}: type needs text`);
161
163
  if (s.do === "type" && s.focus !== void 0 && typeof s.focus !== "boolean")
@@ -214,6 +216,7 @@ import {
214
216
  findStep,
215
217
  outputEnd,
216
218
  pinBox,
219
+ pinCandidates,
217
220
  pinRectOnScreen,
218
221
  pinReferent,
219
222
  ratedSegments,
@@ -1611,6 +1614,17 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1611
1614
  const tf = isObj(o.transform) ? o.transform : null;
1612
1615
  if (!tf || !isNum(tf.x) || !isNum(tf.y)) return;
1613
1616
  const pinned = pins.get(String(o.id));
1617
+ if (!pinned && o.pin === void 0 && (o.kind === "html" || o.kind === "image" || o.kind === "video")) {
1618
+ const cands = pinCandidates(docIn, {
1619
+ start: o.start,
1620
+ duration: o.duration
1621
+ });
1622
+ if (cands.length) {
1623
+ warnings.push(
1624
+ `overlays[${i}] is not pinned, and ${cands.length === 1 ? "a step with an element sits" : "steps with an element sit"} inside its window: ${cands.map((c) => `${String(c.step)} (${c.do}${c.selector ? ` ${c.selector}` : ""})`).join(", ")} \u2014 a callout about one of them says so: pin: { step: "${String(cands[0].step)}" }`
1625
+ );
1626
+ }
1627
+ }
1614
1628
  if (pinned) {
1615
1629
  if (pinned.clamped > 4) {
1616
1630
  warnings.push(
@@ -5294,10 +5308,208 @@ function winRate(verdicts) {
5294
5308
  return { wins, ties, judged, rate: judged ? (wins + ties / 2) / judged : null };
5295
5309
  }
5296
5310
 
5311
+ // src/plugin/callout.ts
5312
+ import { existsSync as existsSync9 } from "fs";
5313
+ import { readFile as readFile9 } from "fs/promises";
5314
+ import { join as join10 } from "path";
5315
+ import { lerpArray, sample } from "@vosjs/timeline";
5316
+ import {
5317
+ CALLOUT_DEFAULT_BODY_PX,
5318
+ CALLOUT_SHAPES,
5319
+ calloutClip,
5320
+ calloutGroundOf,
5321
+ cameraModel as cameraModel2,
5322
+ deltaE,
5323
+ docCardLayout as docCardLayout2,
5324
+ docZoomTrack as docZoomTrack2,
5325
+ hexToRgb,
5326
+ parseCssColour,
5327
+ pinBox as pinBox2,
5328
+ ratedSegments as ratedSegments5,
5329
+ resolvePins as resolvePins2,
5330
+ spanOutputExtent as spanOutputExtent5
5331
+ } from "@vosjs/studio-core";
5332
+ var CALLOUT_DEFAULT_SECONDS = 3;
5333
+ var CALLOUT_LEAD_SECONDS = 0.2;
5334
+ var CALLOUT_DELTA_E_PROBLEM = 8;
5335
+ var CALLOUT_DELTA_E_WARNING = 16;
5336
+ function registerFrom(roles, flags) {
5337
+ const ground = flags.ground ?? roles?.bgA;
5338
+ const accent = flags.accent ?? roles?.accent;
5339
+ if (!ground || !accent) return null;
5340
+ if (!hexToRgb(ground) || !hexToRgb(accent)) return null;
5341
+ const face = flags.face ?? roles?.fontBody;
5342
+ return {
5343
+ ground,
5344
+ accent,
5345
+ ...face && !/^ui-sans-serif$|^system-ui$/.test(face) ? { face } : {},
5346
+ ...flags.mono ? { mono: flags.mono } : {},
5347
+ body: flags.body ?? CALLOUT_DEFAULT_BODY_PX
5348
+ };
5349
+ }
5350
+ function appScaleAt(doc, t) {
5351
+ const layout = docCardLayout2(doc);
5352
+ const meta = doc.source.meta;
5353
+ const base = layout.dw / Math.max(1, meta.width);
5354
+ const track = docZoomTrack2(doc);
5355
+ if (!track || !track.keyframes.length) return base;
5356
+ const z = sample(track, t, lerpArray);
5357
+ return base * Math.max(1, z[0]);
5358
+ }
5359
+ function windowForStep(doc, step, seconds = CALLOUT_DEFAULT_SECONDS) {
5360
+ const steps = doc.source.meta.steps ?? [];
5361
+ const s = steps.find((x) => x.id !== void 0 && x.id === step) ?? steps.find((x) => String(x.step) === String(step));
5362
+ if (!s || s.skipped) return null;
5363
+ const rated = ratedSegments5(doc);
5364
+ const ext = spanOutputExtent5(rated, s.tStart, s.tEnd);
5365
+ if (!ext) return null;
5366
+ const at2 = s.do === "click" || s.do === "drag" ? ext.start : ext.end;
5367
+ const start = +(at2 + CALLOUT_LEAD_SECONDS).toFixed(3);
5368
+ let end = start + seconds;
5369
+ for (const n of steps) {
5370
+ if (n.tStart <= s.tStart) continue;
5371
+ if (n.do !== "scroll" && !n.navigated) continue;
5372
+ const ne = spanOutputExtent5(rated, n.tStart, n.tEnd);
5373
+ if (ne && ne.start > start && ne.start < end) end = ne.start - 0.05;
5374
+ }
5375
+ return { start, duration: +Math.max(0.5, end - start).toFixed(3) };
5376
+ }
5377
+ function composeCallout(doc, register, ask) {
5378
+ let window2 = null;
5379
+ if (ask.step !== void 0) {
5380
+ window2 = windowForStep(doc, ask.step, ask.seconds);
5381
+ if (!window2)
5382
+ throw new Error(
5383
+ `step "${String(ask.step)}" is not in the take's step timeline, was skipped, or falls outside the cut`
5384
+ );
5385
+ } else if (ask.at !== void 0) {
5386
+ window2 = {
5387
+ start: ask.at,
5388
+ duration: ask.seconds ?? CALLOUT_DEFAULT_SECONDS
5389
+ };
5390
+ } else {
5391
+ throw new Error(
5392
+ "name the step the callout is about (--step <id>), or --at <output seconds>"
5393
+ );
5394
+ }
5395
+ const pin = ask.step !== void 0 ? { step: ask.step } : ask.press !== void 0 ? { press: ask.press } : void 0;
5396
+ if (pin) {
5397
+ if (ask.side && ask.side !== "auto") pin.side = ask.side;
5398
+ if (ask.mark && ask.mark !== "none") pin.mark = ask.mark;
5399
+ if (ask.leader) pin.leader = true;
5400
+ if (ask.color) pin.color = ask.color;
5401
+ }
5402
+ const taken = new Set((doc.overlays ?? []).map((o) => o.id));
5403
+ let id = ask.id ?? `${ask.shape}-${ask.step ?? Math.round(window2.start * 10)}`;
5404
+ let n = 2;
5405
+ while (taken.has(id) && !ask.id)
5406
+ id = `${ask.shape}-${ask.step ?? Math.round(window2.start * 10)}-${n++}`;
5407
+ const clip = calloutClip(ask.shape, register, ask.words, {
5408
+ id,
5409
+ start: window2.start,
5410
+ duration: window2.duration,
5411
+ scale: appScaleAt(doc, window2.start + 0.35),
5412
+ pin,
5413
+ // The fallback place, in the margin: the lower third.
5414
+ at: { x: 0.5, y: 0.82 }
5415
+ });
5416
+ return { clip, window: window2 };
5417
+ }
5418
+ function isCalloutShape(s) {
5419
+ return CALLOUT_SHAPES.includes(s);
5420
+ }
5421
+ async function groundFindings(doc, frameAt) {
5422
+ const out = [];
5423
+ const overlays = doc.overlays ?? [];
5424
+ const layout = docCardLayout2(doc);
5425
+ const camera = cameraModel2(doc.frame);
5426
+ const track = docZoomTrack2(doc);
5427
+ const pins = resolvePins2(doc, layout, camera, track);
5428
+ for (let i = 0; i < overlays.length; i++) {
5429
+ const o = overlays[i];
5430
+ if (o.kind !== "html") continue;
5431
+ const card = calloutGroundOf(o.css ?? "");
5432
+ if (!card) {
5433
+ out.push({
5434
+ index: i,
5435
+ id: o.id,
5436
+ level: "note",
5437
+ deltaE: null,
5438
+ card: null,
5439
+ footage: null,
5440
+ message: `overlays[${i}] declares no readable background on its root rule \u2014 the ground check cannot read it`
5441
+ });
5442
+ continue;
5443
+ }
5444
+ const t = o.start + Math.min(0.35, o.duration / 2);
5445
+ const img = await frameAt(t);
5446
+ if (!img) {
5447
+ out.push({
5448
+ index: i,
5449
+ id: o.id,
5450
+ level: "note",
5451
+ deltaE: null,
5452
+ card,
5453
+ footage: null,
5454
+ message: `overlays[${i}]: the frame at ${t.toFixed(2)}s could not be read`
5455
+ });
5456
+ continue;
5457
+ }
5458
+ const box = pinBox2(o, layout);
5459
+ const pinned = pins.get(o.id);
5460
+ let cx = o.transform.x * layout.W;
5461
+ let cy = o.transform.y * layout.H;
5462
+ if (pinned) {
5463
+ const v = sample(pinned.track, t - o.start, lerpArray);
5464
+ cx = v[0] * layout.W;
5465
+ cy = v[1] * layout.H;
5466
+ }
5467
+ const sx = img.w / layout.W;
5468
+ const sy = img.h / layout.H;
5469
+ const rect = {
5470
+ x: (cx - box.w / 2) * sx,
5471
+ y: (cy - box.h / 2) * sy,
5472
+ w: box.w * sx,
5473
+ h: box.h * sy
5474
+ };
5475
+ const footage = medianColour(img, rect);
5476
+ const cardRgb = parseCssColour(card);
5477
+ if (!cardRgb) continue;
5478
+ const d = deltaE(cardRgb, footage);
5479
+ const fh = `#${footage.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
5480
+ const level = d < CALLOUT_DELTA_E_PROBLEM ? "problem" : d < CALLOUT_DELTA_E_WARNING ? "warning" : "note";
5481
+ out.push({
5482
+ index: i,
5483
+ id: o.id,
5484
+ level,
5485
+ deltaE: +d.toFixed(1),
5486
+ card,
5487
+ footage: fh,
5488
+ message: level === "note" ? `overlays[${i}] ground ${card} over footage ${fh}: \u0394E ${d.toFixed(1)}, reads as lifted` : `overlays[${i}] ground ${card} is within \u0394E ${d.toFixed(1)} of the footage it covers (${fh}) at ${t.toFixed(2)}s \u2014 it reads as one more panel of the app; invert its value (vos callout does) or move it to the margin`
5489
+ });
5490
+ }
5491
+ return out;
5492
+ }
5493
+ async function footageFrames(browser, dir, doc, times, framesTake2) {
5494
+ const out = /* @__PURE__ */ new Map();
5495
+ if (!times.length) return out;
5496
+ const bare = { ...doc, overlays: [] };
5497
+ const outDir = join10(dir, ".ground-check");
5498
+ const res = await framesTake2(browser, dir, { times, outDir, doc: bare });
5499
+ for (const f of res.frames) {
5500
+ if (!existsSync9(f.file)) continue;
5501
+ out.set(
5502
+ +f.time.toFixed(3),
5503
+ decodePng(new Uint8Array(await readFile9(f.file)))
5504
+ );
5505
+ }
5506
+ return out;
5507
+ }
5508
+
5297
5509
  // src/plugin/kitPicture.ts
5298
5510
  import { execFile } from "child_process";
5299
5511
  import { promisify } from "util";
5300
- import { readFile as readFile9 } from "fs/promises";
5512
+ import { readFile as readFile10 } from "fs/promises";
5301
5513
  var execFileP = promisify(execFile);
5302
5514
  var SUBJECT_BAND = { min: 0.6, max: 0.92 };
5303
5515
  var BLANK_INK = 0.12;
@@ -5325,7 +5537,7 @@ function apcaContrast(text, bg) {
5325
5537
  return sapc > -0.1 ? 0 : -(sapc + 0.027) * 100;
5326
5538
  }
5327
5539
  var pct = (v) => `${Math.round(v * 100)}%`;
5328
- var hexToRgb = (h) => {
5540
+ var hexToRgb2 = (h) => {
5329
5541
  const m = /^#([0-9a-f]{6})$/i.exec(h.trim());
5330
5542
  if (!m) return null;
5331
5543
  const n = parseInt(m[1], 16);
@@ -5474,7 +5686,7 @@ function textFindings(a, img) {
5474
5686
  });
5475
5687
  }
5476
5688
  }
5477
- const rgb = t.color ? hexToRgb(t.color) : null;
5689
+ const rgb = t.color ? hexToRgb2(t.color) : null;
5478
5690
  if (rgb) {
5479
5691
  const ground = medianColour(img, box);
5480
5692
  const lc = apcaContrast(rgb, ground);
@@ -5573,7 +5785,7 @@ async function pictureChecks(assets) {
5573
5785
  let ffmpeg = null;
5574
5786
  for (const a of assets) {
5575
5787
  if (/\.png$/i.test(a.file)) {
5576
- const img = decodePng(new Uint8Array(await readFile9(a.file)));
5788
+ const img = decodePng(new Uint8Array(await readFile10(a.file)));
5577
5789
  if (!img) {
5578
5790
  findings.push({
5579
5791
  code: "unreadable",
@@ -5666,9 +5878,9 @@ function formatFinding(f) {
5666
5878
  }
5667
5879
 
5668
5880
  // src/plugin/validateKit.ts
5669
- import { existsSync as existsSync9 } from "fs";
5670
- import { readFile as readFile10, stat as stat2 } from "fs/promises";
5671
- import { dirname as dirname2, isAbsolute as isAbsolute4, join as join10 } from "path";
5881
+ import { existsSync as existsSync10 } from "fs";
5882
+ import { readFile as readFile11, stat as stat2 } from "fs/promises";
5883
+ import { dirname as dirname2, isAbsolute as isAbsolute4, join as join11 } from "path";
5672
5884
  import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
5673
5885
  var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5674
5886
  function pngDimensions(bytes) {
@@ -5687,7 +5899,7 @@ async function probeVideo(path) {
5687
5899
  const MB = await import("mediabunny");
5688
5900
  const input = new MB.Input({
5689
5901
  formats: MB.ALL_FORMATS,
5690
- source: new MB.BufferSource(new Uint8Array(await readFile10(path)))
5902
+ source: new MB.BufferSource(new Uint8Array(await readFile11(path)))
5691
5903
  });
5692
5904
  try {
5693
5905
  const track = await input.getPrimaryVideoTrack();
@@ -5708,7 +5920,7 @@ async function validateKit(kitPath, opts = {}) {
5708
5920
  const measured = [];
5709
5921
  let kit;
5710
5922
  try {
5711
- kit = JSON.parse(await readFile10(kitPath, "utf8"));
5923
+ kit = JSON.parse(await readFile11(kitPath, "utf8"));
5712
5924
  } catch (e) {
5713
5925
  return {
5714
5926
  valid: false,
@@ -5727,8 +5939,8 @@ async function validateKit(kitPath, opts = {}) {
5727
5939
  }
5728
5940
  const base = dirname2(kitPath);
5729
5941
  const resolvePath = (p) => {
5730
- if (isAbsolute4(p) && existsSync9(p)) return p;
5731
- const beside = join10(base, p.split("/").pop() ?? p);
5942
+ if (isAbsolute4(p) && existsSync10(p)) return p;
5943
+ const beside = join11(base, p.split("/").pop() ?? p);
5732
5944
  return beside;
5733
5945
  };
5734
5946
  const perDestination = /* @__PURE__ */ new Map();
@@ -5754,7 +5966,7 @@ async function validateKit(kitPath, opts = {}) {
5754
5966
  let h = null;
5755
5967
  let seconds = null;
5756
5968
  if (/\.(png|jpe?g|webp)$/i.test(file)) {
5757
- const head = Buffer.from(await readFile10(file));
5969
+ const head = Buffer.from(await readFile11(file));
5758
5970
  const kind = sniffImage(head);
5759
5971
  if (file.toLowerCase().endsWith(".png") && kind !== "png")
5760
5972
  problems.push(
@@ -5864,7 +6076,7 @@ async function validateKit(kitPath, opts = {}) {
5864
6076
 
5865
6077
  // src/plugin/recorder.ts
5866
6078
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
5867
- import { join as join11 } from "path";
6079
+ import { join as join12 } from "path";
5868
6080
 
5869
6081
  // src/plugin/recordingCap.ts
5870
6082
  var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
@@ -5910,9 +6122,99 @@ function cappedLine(capSeconds) {
5910
6122
  return `stopped at ${formatDurationCap(capSeconds)} (--max-duration ${capSeconds}); the remaining steps did not run`;
5911
6123
  }
5912
6124
 
6125
+ // src/plugin/pace.ts
6126
+ var POINTER_MS_PER_PX = 0.7;
6127
+ var POINTER_MIN_MS = 250;
6128
+ var POINTER_MAX_MS = 800;
6129
+ var MOTION_TICK_MS = 16;
6130
+ var SETTLE_MS = {
6131
+ click: 150,
6132
+ type: 150,
6133
+ scroll: 200,
6134
+ drag: 80
6135
+ };
6136
+ var PRESS_LEAD_MS = 80;
6137
+ var PRESS_HOLD_MS = 70;
6138
+ var SCROLL_SETTLE_MS = 120;
6139
+ var TRAILING_HOLD_MS = 400;
6140
+ function pointerTravelMs(dist) {
6141
+ return Math.min(
6142
+ POINTER_MAX_MS,
6143
+ Math.max(POINTER_MIN_MS, Math.round(dist * POINTER_MS_PER_PX))
6144
+ );
6145
+ }
6146
+ function settleMs(step) {
6147
+ if (typeof step.ms === "number" && step.ms >= 0) return step.ms;
6148
+ const d = step.do;
6149
+ return SETTLE_MS[d] ?? 0;
6150
+ }
6151
+ var easeInOutCubic = (u) => u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
6152
+ async function clockMotion(dur, at2, clock) {
6153
+ const start = clock.now();
6154
+ let samples = 0;
6155
+ for (; ; ) {
6156
+ const elapsed = clock.now() - start;
6157
+ if (elapsed >= dur) break;
6158
+ await at2(easeInOutCubic(Math.min(1, elapsed / dur)));
6159
+ samples++;
6160
+ const next = start + Math.ceil((clock.now() - start) / MOTION_TICK_MS) * MOTION_TICK_MS;
6161
+ const wait = Math.min(next, start + dur) - clock.now();
6162
+ if (wait > 0) await clock.sleep(wait);
6163
+ }
6164
+ await at2(1);
6165
+ return samples + 1;
6166
+ }
6167
+ async function clockTyping(chars, delay, type, clock) {
6168
+ const start = clock.now();
6169
+ for (let i = 0; i < chars.length; i++) {
6170
+ await type(chars[i], i);
6171
+ const due = start + (i + 1) * delay;
6172
+ const wait = due - clock.now();
6173
+ if (wait > 0 && i < chars.length - 1) await clock.sleep(wait);
6174
+ }
6175
+ }
6176
+ function askedMs(step) {
6177
+ switch (step.do) {
6178
+ case "wait":
6179
+ return step.ms ?? 0;
6180
+ case "hover":
6181
+ return step.ms ?? 700;
6182
+ case "drag":
6183
+ return step.ms ?? 700;
6184
+ case "type":
6185
+ return (step.text?.length ?? 0) * (step.delayMs ?? 40);
6186
+ default:
6187
+ return 0;
6188
+ }
6189
+ }
6190
+ function paceReport(steps) {
6191
+ const askedMs2 = steps.reduce((a, s) => a + s.askedMs, 0);
6192
+ const gestureMs = steps.reduce((a, s) => a + s.gestureMs, 0);
6193
+ const wallMs = steps.reduce((a, s) => a + s.wallMs, 0);
6194
+ const overheadMs = Math.max(0, wallMs - askedMs2 - gestureMs);
6195
+ const slow = steps.filter((s) => s.wallMs > (s.askedMs + s.gestureMs) * 1.34 + 60).map((s) => ({
6196
+ step: s.step,
6197
+ do: s.do,
6198
+ askedMs: s.askedMs,
6199
+ wallMs: s.wallMs
6200
+ }));
6201
+ return {
6202
+ askedMs: askedMs2,
6203
+ gestureMs,
6204
+ wallMs,
6205
+ overheadMs,
6206
+ overheadPct: wallMs > 0 ? Math.round(overheadMs / wallMs * 100) : 0,
6207
+ slow
6208
+ };
6209
+ }
6210
+ function paceLine(r) {
6211
+ const s = (ms) => `${(ms / 1e3).toFixed(1)} s`;
6212
+ const slow = r.slow.length ? `; slow: ${r.slow.map((x) => `#${x.step} ${x.do} ${s(x.wallMs)} for ${s(x.askedMs)} asked`).join(", ")}` : "";
6213
+ return `pace: the script asked ${s(r.askedMs)}, the gestures added ${s(r.gestureMs)}, the take ran ${s(r.wallMs)} (${r.overheadPct} % overhead${slow})`;
6214
+ }
6215
+
5913
6216
  // src/plugin/recorder.ts
5914
6217
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5915
- var easeInOutCubic = (u) => u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
5916
6218
  function jpegDims(buf) {
5917
6219
  let i = 2;
5918
6220
  while (i < buf.length - 9) {
@@ -5952,7 +6254,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5952
6254
  cdp.on("Page.screencastFrame", (ev) => {
5953
6255
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
5954
6256
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
5955
- writeFileSync2(join11(paths.framesDir, file), Buffer.from(ev.data, "base64"));
6257
+ writeFileSync2(join12(paths.framesDir, file), Buffer.from(ev.data, "base64"));
5956
6258
  frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
5957
6259
  cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
5958
6260
  });
@@ -5971,26 +6273,30 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5971
6273
  const emit = (e) => events.push({ t: now(), ...e });
5972
6274
  await page.mouse.move(cur.x, cur.y);
5973
6275
  emit({ x: cur.x, y: cur.y, type: "move" });
6276
+ const clock = { now: () => Date.now(), sleep };
5974
6277
  const moveTo = async (tx, ty) => {
5975
6278
  const dist = Math.hypot(tx - cur.x, ty - cur.y);
5976
- const dur = Math.min(1200, Math.max(350, dist * 1.4));
5977
- const steps = Math.max(6, Math.round(dur / 16));
5978
6279
  const from = { ...cur };
5979
- for (let i = 1; i <= steps; i++) {
5980
- const u = easeInOutCubic(i / steps);
5981
- cur.x = from.x + (tx - from.x) * u;
5982
- cur.y = from.y + (ty - from.y) * u;
5983
- await page.mouse.move(cur.x, cur.y);
5984
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
5985
- await sleep(14);
5986
- }
6280
+ await clockMotion(
6281
+ pointerTravelMs(dist),
6282
+ async (u) => {
6283
+ cur.x = from.x + (tx - from.x) * u;
6284
+ cur.y = from.y + (ty - from.y) * u;
6285
+ await page.mouse.move(cur.x, cur.y);
6286
+ emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
6287
+ },
6288
+ clock
6289
+ );
5987
6290
  };
5988
6291
  const boxOf = async (selector) => {
5989
6292
  const loc = page.locator(selector).first();
5990
6293
  try {
5991
6294
  await loc.waitFor({ state: "visible", timeout: 8e3 });
6295
+ const before = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => [0, 0]);
5992
6296
  await loc.scrollIntoViewIfNeeded();
5993
- await sleep(250);
6297
+ const after = await page.evaluate(() => [window.scrollX, window.scrollY]).catch(() => before);
6298
+ if (before[0] !== after[0] || before[1] !== after[1])
6299
+ await sleep(SCROLL_SETTLE_MS);
5994
6300
  const bb = await loc.boundingBox();
5995
6301
  if (!bb) return null;
5996
6302
  return { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
@@ -6001,7 +6307,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6001
6307
  };
6002
6308
  const clickAt = async (rect) => {
6003
6309
  await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
6004
- await sleep(120);
6310
+ await sleep(PRESS_LEAD_MS);
6005
6311
  emit({
6006
6312
  x: Math.round(cur.x),
6007
6313
  y: Math.round(cur.y),
@@ -6010,7 +6316,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6010
6316
  rect
6011
6317
  });
6012
6318
  await page.mouse.down();
6013
- await sleep(90);
6319
+ await sleep(PRESS_HOLD_MS);
6014
6320
  await page.mouse.up();
6015
6321
  emit({
6016
6322
  x: Math.round(cur.x),
@@ -6022,6 +6328,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6022
6328
  };
6023
6329
  const skipped = [];
6024
6330
  const stepSpans = [];
6331
+ const paces = [];
6025
6332
  let capped = false;
6026
6333
  for (const [stepIdx, step] of actions.steps.entries()) {
6027
6334
  if (capReached(now(), maxSeconds)) {
@@ -6029,6 +6336,13 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6029
6336
  break;
6030
6337
  }
6031
6338
  const stepStart = now();
6339
+ let gestureMs = 0;
6340
+ const gesture = async (fn) => {
6341
+ const t = now();
6342
+ const out = await fn();
6343
+ gestureMs += now() - t;
6344
+ return out;
6345
+ };
6032
6346
  const skippedBefore = skipped.length;
6033
6347
  const urlBefore = page.url();
6034
6348
  let stepRect = null;
@@ -6043,7 +6357,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6043
6357
  const rect = await boxOf(step.selector);
6044
6358
  stepRect = rect;
6045
6359
  if (rect) {
6046
- await moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2);
6360
+ await gesture(() => moveTo(rect.x + rect.w / 2, rect.y + rect.h / 2));
6047
6361
  log(`hover ${step.selector}`);
6048
6362
  await sleep(step.ms ?? 700);
6049
6363
  } else
@@ -6054,9 +6368,9 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6054
6368
  const rect = await boxOf(step.selector);
6055
6369
  stepRect = rect;
6056
6370
  if (rect) {
6057
- await clickAt(rect);
6371
+ await gesture(() => clickAt(rect));
6058
6372
  log(`click ${step.selector}`);
6059
- await sleep(500);
6373
+ await gesture(() => sleep(settleMs(step)));
6060
6374
  } else
6061
6375
  skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
6062
6376
  break;
@@ -6065,7 +6379,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6065
6379
  const rect = await boxOf(step.selector);
6066
6380
  stepRect = rect;
6067
6381
  if (rect) {
6068
- if (step.focus !== false) await clickAt(rect);
6382
+ if (step.focus !== false) await gesture(() => clickAt(rect));
6069
6383
  const delay = step.delayMs ?? 40;
6070
6384
  const ping = () => emit({
6071
6385
  x: Math.round(rect.x + rect.w / 2),
@@ -6074,17 +6388,21 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6074
6388
  rect
6075
6389
  });
6076
6390
  let lastPing = -Infinity;
6077
- for (const ch of step.text) {
6078
- if (now() - lastPing >= 350) {
6079
- lastPing = now();
6080
- ping();
6081
- }
6082
- await page.keyboard.type(ch);
6083
- await sleep(delay);
6084
- }
6391
+ await clockTyping(
6392
+ [...step.text],
6393
+ delay,
6394
+ async (ch) => {
6395
+ if (now() - lastPing >= 350) {
6396
+ lastPing = now();
6397
+ ping();
6398
+ }
6399
+ await page.keyboard.type(ch);
6400
+ },
6401
+ clock
6402
+ );
6085
6403
  ping();
6086
6404
  log(`type "${step.text}" into ${step.selector}`);
6087
- await sleep(300);
6405
+ await gesture(() => sleep(settleMs(step)));
6088
6406
  } else
6089
6407
  skipped.push({ step: stepIdx, do: step.do, selector: step.selector });
6090
6408
  break;
@@ -6097,7 +6415,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6097
6415
  emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "scroll" });
6098
6416
  await sleep(40);
6099
6417
  }
6100
- await sleep(400);
6418
+ await gesture(() => sleep(settleMs(step)));
6101
6419
  break;
6102
6420
  }
6103
6421
  case "drag": {
@@ -6121,8 +6439,8 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6121
6439
  start = { x: step.x, y: step.y };
6122
6440
  }
6123
6441
  if (!start) break;
6124
- await moveTo(start.x, start.y);
6125
- await sleep(140);
6442
+ await gesture(() => moveTo(start.x, start.y));
6443
+ await gesture(() => sleep(PRESS_LEAD_MS));
6126
6444
  emit({
6127
6445
  x: Math.round(cur.x),
6128
6446
  y: Math.round(cur.y),
@@ -6131,19 +6449,20 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6131
6449
  rect
6132
6450
  });
6133
6451
  await page.mouse.down();
6134
- await sleep(90);
6452
+ await gesture(() => sleep(PRESS_HOLD_MS));
6135
6453
  const dur = step.ms ?? 700;
6136
- const steps = Math.max(8, Math.round(dur / 16));
6137
6454
  const from = { ...cur };
6138
- for (let i = 1; i <= steps; i++) {
6139
- const u = easeInOutCubic(i / steps);
6140
- cur.x = from.x + (step.tx - from.x) * u;
6141
- cur.y = from.y + (step.ty - from.y) * u;
6142
- await page.mouse.move(cur.x, cur.y);
6143
- emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
6144
- await sleep(14);
6145
- }
6146
- await sleep(100);
6455
+ await clockMotion(
6456
+ dur,
6457
+ async (u) => {
6458
+ cur.x = from.x + (step.tx - from.x) * u;
6459
+ cur.y = from.y + (step.ty - from.y) * u;
6460
+ await page.mouse.move(cur.x, cur.y);
6461
+ emit({ x: Math.round(cur.x), y: Math.round(cur.y), type: "move" });
6462
+ },
6463
+ clock
6464
+ );
6465
+ await gesture(() => sleep(settleMs({ do: "drag" })));
6147
6466
  await page.mouse.up();
6148
6467
  emit({
6149
6468
  x: Math.round(cur.x),
@@ -6178,8 +6497,15 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6178
6497
  }
6179
6498
  } : {}
6180
6499
  });
6500
+ paces.push({
6501
+ step: stepIdx,
6502
+ do: step.do,
6503
+ askedMs: askedMs(step),
6504
+ gestureMs,
6505
+ wallMs: now() - stepStart
6506
+ });
6181
6507
  }
6182
- if (!capped) await sleep(600);
6508
+ if (!capped) await sleep(TRAILING_HOLD_MS);
6183
6509
  if (capped || capReached(now(), maxSeconds)) {
6184
6510
  capped = true;
6185
6511
  log(` ${cappedLine(maxSeconds)}`);
@@ -6190,7 +6516,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6190
6516
  await sleep(200);
6191
6517
  const pageTitle = await page.title().catch(() => "");
6192
6518
  await context.close();
6193
- const firstFrame = frames[0] ? jpegDims(readFileSync3(join11(paths.framesDir, frames[0].file))) : null;
6519
+ const firstFrame = frames[0] ? jpegDims(readFileSync3(join12(paths.framesDir, frames[0].file))) : null;
6194
6520
  const meta = {
6195
6521
  dpr: 1,
6196
6522
  zoom: 1,
@@ -6231,10 +6557,13 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6231
6557
  await writeJson(paths.meta, meta, true);
6232
6558
  await writeJson(paths.framesIndex, frames);
6233
6559
  await writeJson(paths.actions, { ...actions, url }, true);
6560
+ const pace = paceReport(paces);
6561
+ log(` ${paceLine(pace)}`);
6234
6562
  return {
6235
6563
  events,
6236
6564
  frames,
6237
6565
  meta,
6566
+ pace,
6238
6567
  skipped,
6239
6568
  navTimeout,
6240
6569
  freezes,
@@ -6298,9 +6627,9 @@ async function encodeRecording(browser, takeDir, onProgress) {
6298
6627
  }
6299
6628
 
6300
6629
  // src/plugin/plan.ts
6301
- import { existsSync as existsSync10 } from "fs";
6302
- import { readFile as readFile11 } from "fs/promises";
6303
- import { join as join12 } from "path";
6630
+ import { existsSync as existsSync11 } from "fs";
6631
+ import { readFile as readFile12 } from "fs/promises";
6632
+ import { join as join13 } from "path";
6304
6633
  import {
6305
6634
  DEFAULT_FRAME_STYLE,
6306
6635
  STYLE_FIELDS,
@@ -6522,10 +6851,10 @@ function retimeCut(prev, newSteps, newDurationMs) {
6522
6851
  // src/plugin/plan.ts
6523
6852
  var overlaps = (a, b) => a.in < b.out && b.in < a.out;
6524
6853
  async function readDigestActivity(dir) {
6525
- const file = join12(dir, "digest", "digest.json");
6526
- if (!existsSync10(file)) return null;
6854
+ const file = join13(dir, "digest", "digest.json");
6855
+ if (!existsSync11(file)) return null;
6527
6856
  try {
6528
- const d = JSON.parse(await readFile11(file, "utf8"));
6857
+ const d = JSON.parse(await readFile12(file, "utf8"));
6529
6858
  return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
6530
6859
  } catch {
6531
6860
  return null;
@@ -6735,17 +7064,17 @@ async function planTake(dir, opts = {}) {
6735
7064
 
6736
7065
  // src/plugin/sync.ts
6737
7066
  import { createHash } from "crypto";
6738
- import { existsSync as existsSync12 } from "fs";
6739
- import { readFile as readFile12 } from "fs/promises";
7067
+ import { existsSync as existsSync13 } from "fs";
7068
+ import { readFile as readFile13 } from "fs/promises";
6740
7069
  import { createInterface } from "readline/promises";
6741
- import { basename, join as join15 } from "path";
7070
+ import { basename, join as join16 } from "path";
6742
7071
  import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc3 } from "@vosjs/studio-core";
6743
7072
 
6744
7073
  // src/plugin/media.ts
6745
7074
  import { isKeyedOverlay } from "@vosjs/studio-core";
6746
- import { createWriteStream, existsSync as existsSync11 } from "fs";
7075
+ import { createWriteStream, existsSync as existsSync12 } from "fs";
6747
7076
  import { writeFile as writeFile9 } from "fs/promises";
6748
- import { join as join13 } from "path";
7077
+ import { join as join14 } from "path";
6749
7078
  import { Readable } from "stream";
6750
7079
  import { pipeline } from "stream/promises";
6751
7080
  var MIC_NAME = "mic.webm";
@@ -6869,8 +7198,8 @@ async function pullMedia(ctx, dir, doc, log) {
6869
7198
  const url = doc.source[key];
6870
7199
  const assetId = assetIdOf(url);
6871
7200
  if (!assetId) continue;
6872
- const target = join13(dir, file);
6873
- if (existsSync11(target)) {
7201
+ const target = join14(dir, file);
7202
+ if (existsSync12(target)) {
6874
7203
  result.kept.push(file);
6875
7204
  } else {
6876
7205
  const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
@@ -6895,8 +7224,8 @@ async function pullMedia(ctx, dir, doc, log) {
6895
7224
  for (const ref of docMediaRefs(doc, (k) => assetIdOf(k) !== null)) {
6896
7225
  const assetId = assetIdOf(ref.key);
6897
7226
  const abs = /^https?:/.test(ref.key) ? ref.key : `${ctx.origin}${ref.key}`;
6898
- const existing = existsSync11(join13(dir, "media")) ? (await import("fs/promises").then(
6899
- (fs) => fs.readdir(join13(dir, "media"))
7227
+ const existing = existsSync12(join14(dir, "media")) ? (await import("fs/promises").then(
7228
+ (fs) => fs.readdir(join14(dir, "media"))
6900
7229
  )).find((f) => f.startsWith(`${assetId}.`)) : void 0;
6901
7230
  let file;
6902
7231
  if (existing) {
@@ -6913,9 +7242,9 @@ async function pullMedia(ctx, dir, doc, log) {
6913
7242
  }
6914
7243
  const ext = extensionFor(res.headers.get("content-type") ?? "");
6915
7244
  file = `media/${assetId}${ext}`;
6916
- const target = join13(dir, file);
7245
+ const target = join14(dir, file);
6917
7246
  await import("fs/promises").then(
6918
- (fs) => fs.mkdir(join13(dir, "media"), { recursive: true })
7247
+ (fs) => fs.mkdir(join14(dir, "media"), { recursive: true })
6919
7248
  );
6920
7249
  await pipeline(
6921
7250
  Readable.fromWeb(res.body),
@@ -6929,17 +7258,17 @@ async function pullMedia(ctx, dir, doc, log) {
6929
7258
  }
6930
7259
  ref.set(file);
6931
7260
  }
6932
- const metaPath = join13(dir, "meta.json");
6933
- if (!existsSync11(metaPath)) await writeJson(metaPath, doc.source.meta, true);
6934
- const cursorPath = join13(dir, "cursor.json");
6935
- if (!existsSync11(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
6936
- await writeFile9(join13(dir, "doc.json"), JSON.stringify(doc, null, 2));
7261
+ const metaPath = join14(dir, "meta.json");
7262
+ if (!existsSync12(metaPath)) await writeJson(metaPath, doc.source.meta, true);
7263
+ const cursorPath = join14(dir, "cursor.json");
7264
+ if (!existsSync12(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
7265
+ await writeFile9(join14(dir, "doc.json"), JSON.stringify(doc, null, 2));
6937
7266
  return result;
6938
7267
  }
6939
7268
 
6940
7269
  // src/plugin/folder.ts
6941
7270
  import { mkdir as mkdir7, writeFile as writeFile10 } from "fs/promises";
6942
- import { join as join14 } from "path";
7271
+ import { join as join15 } from "path";
6943
7272
  import { recipeHints } from "@vosjs/shared/frontmatter";
6944
7273
  import { migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
6945
7274
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
@@ -7115,18 +7444,18 @@ async function cmdPull(argv) {
7115
7444
  throw new Error(apiError(`pull folder ${folder.slug}`, res));
7116
7445
  const payload = res.body;
7117
7446
  const out = strFlag(flags, "out") ?? folder.slug;
7118
- await mkdir7(join14(out, "recipes"), { recursive: true });
7447
+ await mkdir7(join15(out, "recipes"), { recursive: true });
7119
7448
  const lines = [];
7120
7449
  const recipes = [];
7121
7450
  for (const rec of payload.recipes) {
7122
- const path = join14(out, "recipes", safeName(rec.filename));
7451
+ const path = join15(out, "recipes", safeName(rec.filename));
7123
7452
  await writeFile10(path, rec.body ?? "");
7124
7453
  recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
7125
7454
  }
7126
7455
  for (const rec of payload.inheritedRecipes) {
7127
7456
  const from = safeName(rec.folderSlug ?? "inherited");
7128
- await mkdir7(join14(out, "recipes", "_inherited", from), { recursive: true });
7129
- const path = join14(
7457
+ await mkdir7(join15(out, "recipes", "_inherited", from), { recursive: true });
7458
+ const path = join15(
7130
7459
  out,
7131
7460
  "recipes",
7132
7461
  "_inherited",
@@ -7141,7 +7470,7 @@ async function cmdPull(argv) {
7141
7470
  }
7142
7471
  const members = [];
7143
7472
  for (const v of payload.voses) {
7144
- const dir = join14(out, "members", safeName(v.slug || v.title || v.id));
7473
+ const dir = join15(out, "members", safeName(v.slug || v.title || v.id));
7145
7474
  await mkdir7(dir, { recursive: true });
7146
7475
  const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
7147
7476
  if (cfg.status !== 200) {
@@ -7149,7 +7478,7 @@ async function cmdPull(argv) {
7149
7478
  continue;
7150
7479
  }
7151
7480
  await writeFile10(
7152
- join14(dir, "config.json"),
7481
+ join15(dir, "config.json"),
7153
7482
  JSON.stringify(cfg.body.config, null, 2)
7154
7483
  );
7155
7484
  let take = false;
@@ -7161,7 +7490,7 @@ async function cmdPull(argv) {
7161
7490
  );
7162
7491
  if (doc.status === 200) {
7163
7492
  await writeFile10(
7164
- join14(dir, "doc.json"),
7493
+ join15(dir, "doc.json"),
7165
7494
  JSON.stringify(doc.body, null, 2)
7166
7495
  );
7167
7496
  take = true;
@@ -7278,7 +7607,7 @@ async function pushTake(dir, flags, r) {
7278
7607
  throw new Error(`doc.json fails lint:
7279
7608
  ${lint.problems.join("\n ")}`);
7280
7609
  }
7281
- if (!existsSync12(take.paths.recording)) {
7610
+ if (!existsSync13(take.paths.recording)) {
7282
7611
  throw new Error("no recording.webm in this take");
7283
7612
  }
7284
7613
  const ctx = apiContext(flags);
@@ -7310,7 +7639,7 @@ async function pushTake(dir, flags, r) {
7310
7639
  throw new Error("push cancelled");
7311
7640
  }
7312
7641
  }
7313
- const bytes = await readFile12(take.paths.recording);
7642
+ const bytes = await readFile13(take.paths.recording);
7314
7643
  const hash = createHash("sha256").update(bytes).digest("hex");
7315
7644
  r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
7316
7645
  const upload = await api(ctx, "/assets/recording", {
@@ -7349,12 +7678,12 @@ async function pushTake(dir, flags, r) {
7349
7678
  delete docForPush.source.micKey;
7350
7679
  }
7351
7680
  for (const ref of docMediaRefs(docForPush)) {
7352
- const file = join15(dir, takeRelativeFile(ref.key));
7353
- if (!existsSync12(file)) {
7681
+ const file = join16(dir, takeRelativeFile(ref.key));
7682
+ if (!existsSync13(file)) {
7354
7683
  r.log(` ${ref.where}: ${ref.key} is not in the take \u2014 left as is`);
7355
7684
  continue;
7356
7685
  }
7357
- const media = await readFile12(file);
7686
+ const media = await readFile13(file);
7358
7687
  const mediaHash = createHash("sha256").update(media).digest("hex");
7359
7688
  const type = mediaContentType(file);
7360
7689
  const put = await api(ctx, "/assets/recording", {
@@ -7490,9 +7819,9 @@ async function pullTake(dir, flags, r) {
7490
7819
  };
7491
7820
  }
7492
7821
  let media2;
7493
- if (flags.media && existsSync12(join15(dir, "doc.json"))) {
7822
+ if (flags.media && existsSync13(join16(dir, "doc.json"))) {
7494
7823
  const local = JSON.parse(
7495
- await readFile12(join15(dir, "doc.json"), "utf8")
7824
+ await readFile13(join16(dir, "doc.json"), "utf8")
7496
7825
  );
7497
7826
  media2 = await pullMedia(ctx, dir, local, r.log);
7498
7827
  }
@@ -7552,10 +7881,10 @@ async function pullTake(dir, flags, r) {
7552
7881
  }
7553
7882
  const hostedDoc = migrateHostedDoc3(docRes.json);
7554
7883
  const doc = hostedDoc;
7555
- if (existsSync12(join15(dir, RECORDING_NAME))) {
7884
+ if (existsSync13(join16(dir, RECORDING_NAME))) {
7556
7885
  doc.source.videoKey = RECORDING_NAME;
7557
7886
  }
7558
- await writeJson(join15(dir, "doc.json"), doc, true);
7887
+ await writeJson(join16(dir, "doc.json"), doc, true);
7559
7888
  const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
7560
7889
  const versions = await api(ctx, `/vos/${vosId}/versions`);
7561
7890
  const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
@@ -7574,10 +7903,10 @@ async function pullTake(dir, flags, r) {
7574
7903
  }
7575
7904
 
7576
7905
  // src/plugin/program.ts
7577
- import { existsSync as existsSync13 } from "fs";
7578
- import { mkdir as mkdir8, readFile as readFile13, writeFile as writeFile11 } from "fs/promises";
7906
+ import { existsSync as existsSync14 } from "fs";
7907
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
7579
7908
  import { createInterface as createInterface2 } from "readline/promises";
7580
- import { basename as basename2, dirname as dirname3, join as join16 } from "path";
7909
+ import { basename as basename2, dirname as dirname3, join as join17 } from "path";
7581
7910
  import {
7582
7911
  CURRENT_CONFIG_VERSION,
7583
7912
  migrateConfig,
@@ -7747,8 +8076,8 @@ function preflightConfig(parsed) {
7747
8076
  }
7748
8077
  function resolveConfigPath(target) {
7749
8078
  if (target.endsWith(".json")) return target;
7750
- const inDir = join16(target, "config.json");
7751
- if (existsSync13(inDir)) return inDir;
8079
+ const inDir = join17(target, "config.json");
8080
+ if (existsSync14(inDir)) return inDir;
7752
8081
  throw new UsageError(
7753
8082
  `${target} has no config.json (and is not a take \u2014 no doc.json)`
7754
8083
  );
@@ -7776,7 +8105,7 @@ async function cmdFetch(argv) {
7776
8105
  const out = strFlag(flags, "out") ?? slug;
7777
8106
  await mkdir8(out, { recursive: true });
7778
8107
  await writeFile11(
7779
- join16(out, "config.json"),
8108
+ join17(out, "config.json"),
7780
8109
  JSON.stringify(cfg.body.config, null, 2)
7781
8110
  );
7782
8111
  const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
@@ -7801,12 +8130,12 @@ async function cmdFetch(argv) {
7801
8130
  const hosted = migrateHostedDoc4(doc.body);
7802
8131
  const { config: own } = await writeProgramDoc(out, hosted);
7803
8132
  if (own) {
7804
- await writeFile11(join16(out, "config.json"), JSON.stringify(own, null, 2));
8133
+ await writeFile11(join17(out, "config.json"), JSON.stringify(own, null, 2));
7805
8134
  }
7806
8135
  } else if (doc.status === 200) {
7807
8136
  take = true;
7808
8137
  const hosted = migrateHostedDoc4(doc.body);
7809
- await writeFile11(join16(out, "doc.json"), JSON.stringify(hosted, null, 2));
8138
+ await writeFile11(join17(out, "doc.json"), JSON.stringify(hosted, null, 2));
7810
8139
  if (flags.media === true) {
7811
8140
  const media = await pullMedia({ origin, key }, out, hosted, r.log);
7812
8141
  mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
@@ -7919,7 +8248,7 @@ async function cmdPushProgram(argv) {
7919
8248
  api: strFlag(flags, "api")
7920
8249
  });
7921
8250
  const dir = dirname3(source);
7922
- const parsed = JSON.parse(await readFile13(source, "utf8"));
8251
+ const parsed = JSON.parse(await readFile14(source, "utf8"));
7923
8252
  const pre = preflightConfig(parsed);
7924
8253
  if (!pre.ok || !pre.config) {
7925
8254
  for (const issue of pre.issues) r.log(`error ${issue}`);
@@ -8160,10 +8489,10 @@ async function cmdPullProgram(argv) {
8160
8489
  const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
8161
8490
  if (cfg.status !== 200)
8162
8491
  throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
8163
- const configPath = join16(dir, "config.json");
8492
+ const configPath = join17(dir, "config.json");
8164
8493
  let backedUp = false;
8165
- if (existsSync13(configPath)) {
8166
- await writeFile11(join16(dir, "config.backup.json"), await readFile13(configPath));
8494
+ if (existsSync14(configPath)) {
8495
+ await writeFile11(join17(dir, "config.backup.json"), await readFile14(configPath));
8167
8496
  backedUp = true;
8168
8497
  }
8169
8498
  let headConfig = cfg.body.config;
@@ -8196,7 +8525,7 @@ async function cmdPullProgram(argv) {
8196
8525
  protected: protectedIds,
8197
8526
  changes,
8198
8527
  out: configPath,
8199
- backup: backedUp ? join16(dir, "config.backup.json") : null
8528
+ backup: backedUp ? join17(dir, "config.backup.json") : null
8200
8529
  },
8201
8530
  `Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
8202
8531
  Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
@@ -8258,9 +8587,9 @@ function storedProgramConfig(config, programDoc) {
8258
8587
  return lowerProgramDoc(doc).config;
8259
8588
  }
8260
8589
  async function readProgramDoc(dir, config) {
8261
- const docPath = join16(dir, "doc.json");
8262
- if (!existsSync13(docPath)) return null;
8263
- const parsed = JSON.parse(await readFile13(docPath, "utf8"));
8590
+ const docPath = join17(dir, "doc.json");
8591
+ if (!existsSync14(docPath)) return null;
8592
+ const parsed = JSON.parse(await readFile14(docPath, "utf8"));
8264
8593
  if (typeof parsed !== "object" || parsed === null || "source" in parsed)
8265
8594
  return null;
8266
8595
  const raw = parsed;
@@ -8271,7 +8600,7 @@ async function writeProgramDoc(dir, hosted) {
8271
8600
  const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
8272
8601
  const { config, ...rest } = program;
8273
8602
  const onDisk = { ...hosted, program: rest };
8274
- await writeFile11(join16(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
8603
+ await writeFile11(join17(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
8275
8604
  return {
8276
8605
  config: config && typeof config === "object" ? config : null
8277
8606
  };
@@ -9327,8 +9656,8 @@ async function cmdActions(argv) {
9327
9656
  if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
9328
9657
  viewport = { width: Number(m[1]), height: Number(m[2]) };
9329
9658
  }
9330
- const { readFile: readFile15 } = await import("fs/promises");
9331
- const text = await readFile15(resolve8(input), "utf8");
9659
+ const { readFile: readFile16 } = await import("fs/promises");
9660
+ const text = await readFile16(resolve8(input), "utf8");
9332
9661
  const { records, problems } = parseAgentBrowserLog(text);
9333
9662
  for (const p of problems) r.log(` ${p}`);
9334
9663
  const result = convertAgentBrowser(records, {
@@ -9382,7 +9711,8 @@ var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
9382
9711
  "yes",
9383
9712
  "composed",
9384
9713
  "check",
9385
- "motion"
9714
+ "motion",
9715
+ "leader"
9386
9716
  ]);
9387
9717
  var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override"]);
9388
9718
  var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
@@ -9397,6 +9727,7 @@ Take pipeline
9397
9727
  vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
9398
9728
  vos brand <url> [--out BRAND.md] [--json]
9399
9729
  vos open <take> [--studio <url>] [--print]
9730
+ vos callout <take> <note|tag|code> --step <id> [--title "\u2026"] [--body "\u2026"] [--kicker "\u2026"] [--code "\u2026"|--code-file f] [--seconds 3] [--side auto|right|left|below|above] [--mark ring|underline|none] [--leader] [--color #hex] [--brand BRAND.md] [--ground #hex] [--accent #hex] [--font "\u2026"] [--body-px 14] [--id x] [--print] [--json]
9400
9731
  vos validate <actions.json|take|kit.json> [--picture] [--json]
9401
9732
  vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]
9402
9733
  vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
@@ -9630,7 +9961,7 @@ lint-gated, so a bad override fails like a bad doc.json):
9630
9961
  first ready loop (the house backdrop the studio opens on), or a flat ground offline
9631
9962
  `;
9632
9963
  async function loadActions(file) {
9633
- const raw = JSON.parse(await readFile14(file, "utf8"));
9964
+ const raw = JSON.parse(await readFile15(file, "utf8"));
9634
9965
  const errors = validateActions(raw);
9635
9966
  if (errors.length)
9636
9967
  throw new UsageError(`invalid actions file:
@@ -9689,7 +10020,7 @@ async function cmdRecord(argv) {
9689
10020
  const outDir = resolve9(strFlag(flags, "out") ?? "take");
9690
10021
  const backdrop = await takeBackdrop(flags, r);
9691
10022
  const maxDurationSeconds = await maxDuration(flags, r);
9692
- if (existsSync14(join17(outDir, "meta.json"))) {
10023
+ if (existsSync15(join18(outDir, "meta.json"))) {
9693
10024
  const { prevDoc, kept } = await prepareReRecord(outDir);
9694
10025
  r.log(
9695
10026
  `note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
@@ -9732,11 +10063,12 @@ async function cmdRecord(argv) {
9732
10063
  freezes: rec.freezes,
9733
10064
  freezePct: rec.freezePct,
9734
10065
  capped: rec.capped,
10066
+ pace: rec.pace,
9735
10067
  ...strictFail ? { strictFailed: true } : {}
9736
10068
  },
9737
10069
  strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
9738
10070
  ${(rec.meta.durationMs / 1e3).toFixed(1)}s \xB7 ${rec.frames.length} frames \xB7 ${rec.events.length} cursor events \xB7 ${clicks} clicks \xB7 ${plan.doc.zoom.length} zoom spans planned \xB7 ${rec.freezePct}% frozen${rec.freezePct >= 25 ? " \u26A0 keep motion in frame or trim" : ""}${skippedNote}
9739
- Next: edit ${join17(outDir, "doc.json")} (optional), then: vos render ${outDir}`
10071
+ Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}`
9740
10072
  );
9741
10073
  return strictFail ? EXIT_USAGE : EXIT_OK;
9742
10074
  } finally {
@@ -9765,14 +10097,14 @@ async function cmdCreate2(argv) {
9765
10097
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
9766
10098
  throw new UsageError("--format must be webm or mp4");
9767
10099
  const format = fmtRaw;
9768
- const out = positionals[0] ?? join17(outDir, `out.${format}`);
10100
+ const out = positionals[0] ?? join18(outDir, `out.${format}`);
9769
10101
  const parallel = numFlag(flags, "parallel", 1);
9770
10102
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
9771
10103
  throw new UsageError("--parallel expects an integer between 1 and 16");
9772
10104
  }
9773
10105
  const backdrop = await takeBackdrop(flags, r);
9774
10106
  const maxDurationSeconds = await maxDuration(flags, r);
9775
- if (existsSync14(join17(outDir, "meta.json"))) {
10107
+ if (existsSync15(join18(outDir, "meta.json"))) {
9776
10108
  const { prevDoc, kept } = await prepareReRecord(outDir);
9777
10109
  r.log(
9778
10110
  `note: re-recording ${outDir} \u2014 kept ${kept.length ? kept.join(", ") : "nothing"}` + (prevDoc ? "; apply the previous cut to the new footage with: vos plan " + outDir + " --reuse" : "")
@@ -9863,24 +10195,24 @@ async function cmdPlan(argv) {
9863
10195
  );
9864
10196
  const r = createReporter(flags.json === true);
9865
10197
  if (flags.fresh === true) {
9866
- await rm4(join17(dir, "doc.json"), { force: true });
10198
+ await rm4(join18(dir, "doc.json"), { force: true });
9867
10199
  r.log("note: --fresh discarded the existing doc.json");
9868
10200
  }
9869
10201
  let reuse;
9870
10202
  if (flags.reuse === true) {
9871
- const from = resolve9(strFlag(flags, "from") ?? join17(dir, PREV_DOC_NAME));
9872
- if (!existsSync14(from)) {
10203
+ const from = resolve9(strFlag(flags, "from") ?? join18(dir, PREV_DOC_NAME));
10204
+ if (!existsSync15(from)) {
9873
10205
  throw new UsageError(
9874
10206
  `--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
9875
10207
  );
9876
10208
  }
9877
10209
  reuse = {
9878
10210
  from,
9879
- doc: JSON.parse(await readFile14(from, "utf8"))
10211
+ doc: JSON.parse(await readFile15(from, "utf8"))
9880
10212
  };
9881
10213
  }
9882
10214
  const style = await resolveStyleRef(flags);
9883
- const hasDoc = existsSync14(join17(dir, "doc.json"));
10215
+ const hasDoc = existsSync15(join18(dir, "doc.json"));
9884
10216
  const backdrop = hasDoc ? null : await takeBackdrop(flags, r);
9885
10217
  const release = await releaseInputs(dir, flags, r, multi.with ?? []);
9886
10218
  const motionWanted = !hasDoc || flags.motion === true || flags.fresh === true;
@@ -9938,7 +10270,7 @@ async function cmdPlan(argv) {
9938
10270
  ...s.motion ? { motion: s.motion } : {},
9939
10271
  ...s.reuse ? { reuse: s.reuse } : {}
9940
10272
  },
9941
- `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join17(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${layoutLines}${motionLines}${reuseLines}`
10273
+ `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join18(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${layoutLines}${motionLines}${reuseLines}`
9942
10274
  );
9943
10275
  return EXIT_OK;
9944
10276
  }
@@ -10064,7 +10396,7 @@ async function cmdRender(argv) {
10064
10396
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
10065
10397
  throw new UsageError("--format must be webm or mp4");
10066
10398
  const format = fmtRaw;
10067
- const out = positionals[1] ?? join17(dir, `out.${format}`);
10399
+ const out = positionals[1] ?? join18(dir, `out.${format}`);
10068
10400
  const parallel = numFlag(flags, "parallel", 1);
10069
10401
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
10070
10402
  throw new UsageError("--parallel expects an integer between 1 and 16");
@@ -10311,15 +10643,15 @@ async function resolveStyleRef(flags) {
10311
10643
  }
10312
10644
  async function resolveDocRef(ref, flags, what) {
10313
10645
  const styleRef = ref;
10314
- const file = existsSync14(styleRef) ? resolve9(
10646
+ const file = existsSync15(styleRef) ? resolve9(
10315
10647
  styleRef,
10316
- existsSync14(join17(styleRef, "doc.json")) ? "doc.json" : ""
10648
+ existsSync15(join18(styleRef, "doc.json")) ? "doc.json" : ""
10317
10649
  ) : null;
10318
- if (file && existsSync14(file)) {
10650
+ if (file && existsSync15(file)) {
10319
10651
  return {
10320
10652
  from: file,
10321
10653
  doc: migrateHostedDoc5(
10322
- JSON.parse(await readFile14(file, "utf8"))
10654
+ JSON.parse(await readFile15(file, "utf8"))
10323
10655
  )
10324
10656
  };
10325
10657
  }
@@ -10371,7 +10703,7 @@ async function cmdDigest(argv) {
10371
10703
  const take = await loadTake(dir);
10372
10704
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
10373
10705
  const transcriptPath = strFlag(flags, "transcript");
10374
- const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile14(transcriptPath, "utf8"))) : null;
10706
+ const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile15(transcriptPath, "utf8"))) : null;
10375
10707
  const style = await resolveStyleRef(flags);
10376
10708
  const noFrames = flags["no-frames"] === true;
10377
10709
  let browser = null;
@@ -10402,7 +10734,7 @@ async function cmdDigest(argv) {
10402
10734
  kinds,
10403
10735
  frames: d.images.full,
10404
10736
  crops: d.images.crop,
10405
- sheet: d.images.sheet ? join17(result.outDir, d.images.sheet) : null,
10737
+ sheet: d.images.sheet ? join18(result.outDir, d.images.sheet) : null,
10406
10738
  scenes: kinds.scene ?? 0,
10407
10739
  sourceDuration: d.take.sourceDuration,
10408
10740
  outputDuration: d.take.outputDuration,
@@ -10426,7 +10758,7 @@ async function cmdOpen(argv) {
10426
10758
  if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
10427
10759
  const r = createReporter(flags.json === true);
10428
10760
  const take = await loadTake(dir);
10429
- if (!existsSync14(take.paths.recording)) {
10761
+ if (!existsSync15(take.paths.recording)) {
10430
10762
  throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
10431
10763
  }
10432
10764
  const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
@@ -10467,12 +10799,12 @@ async function cmdJudge(argv) {
10467
10799
  "vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]\n(the manifest names the reference set: id, file, role, layout, facts, rule per asset)"
10468
10800
  );
10469
10801
  const r = createReporter(flags.json === true);
10470
- const kitFile = target.endsWith("kit.json") ? target : join17(target, "kit.json");
10471
- if (!existsSync14(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
10472
- if (!existsSync14(against))
10802
+ const kitFile = target.endsWith("kit.json") ? target : join18(target, "kit.json");
10803
+ if (!existsSync15(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
10804
+ if (!existsSync15(against))
10473
10805
  throw new UsageError(`${against}: no reference manifest`);
10474
10806
  const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
10475
- const verdicts = JSON.parse(await readFile14(result.verdictFile, "utf8")).verdicts;
10807
+ const verdicts = JSON.parse(await readFile15(result.verdictFile, "utf8")).verdicts;
10476
10808
  const rate = winRate(verdicts);
10477
10809
  const lines = result.sheets.map(
10478
10810
  (s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
@@ -10487,12 +10819,109 @@ Win rate so far: ${rate.wins} win(s), ${rate.ties} tie(s) of ${rate.judged} judg
10487
10819
  );
10488
10820
  return EXIT_OK;
10489
10821
  }
10822
+ async function cmdCallout(argv) {
10823
+ const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
10824
+ const dir = positionals[0];
10825
+ const shape = positionals[1];
10826
+ if (!dir || !shape || !isCalloutShape(shape))
10827
+ throw new UsageError(
10828
+ 'vos callout <take> <note|tag|code> --step <id> [--title "\u2026"] [--body "\u2026"] [--kicker "\u2026"] [--code "\u2026"|--code-file f] [--seconds 3] [--side \u2026] [--mark ring|underline|none] [--leader] [--color #hex] [--brand BRAND.md] [--ground #hex --accent #hex] [--font "\u2026"] [--body-px 14] [--id x] [--print] [--json]'
10829
+ );
10830
+ const r = createReporter(flags.json === true);
10831
+ const take = await loadTake(dir);
10832
+ if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
10833
+ const brand = await readBrandBesideTake(dir, strFlag(flags, "brand"));
10834
+ const bodyPx = strFlag(flags, "body-px");
10835
+ const register = registerFrom(brand?.roles ?? null, {
10836
+ ground: strFlag(flags, "ground"),
10837
+ accent: strFlag(flags, "accent"),
10838
+ face: strFlag(flags, "font"),
10839
+ mono: strFlag(flags, "mono"),
10840
+ body: bodyPx !== void 0 ? Number(bodyPx) : void 0
10841
+ });
10842
+ if (!register)
10843
+ throw new UsageError(
10844
+ `no register: put a BRAND.md beside the take (vos brand <url> --out BRAND.md; its bgA and accent are the ground and the accent), or pass --ground #hex --accent #hex`
10845
+ );
10846
+ const codeFile = strFlag(flags, "code-file");
10847
+ const code = codeFile ? await readFile15(codeFile, "utf8") : strFlag(flags, "code");
10848
+ const words = {
10849
+ kicker: strFlag(flags, "kicker"),
10850
+ title: strFlag(flags, "title"),
10851
+ body: strFlag(flags, "body"),
10852
+ code: code?.replace(/\\n/g, "\n")
10853
+ };
10854
+ if (shape === "code" && !words.code)
10855
+ throw new UsageError(
10856
+ 'a code callout needs --code "\u2026" or --code-file <file>'
10857
+ );
10858
+ if (shape === "note" && !words.title)
10859
+ throw new UsageError('a note needs --title "\u2026"');
10860
+ if (shape === "tag" && !words.kicker && !words.title)
10861
+ throw new UsageError('a tag needs --kicker "\u2026"');
10862
+ const stepRaw = strFlag(flags, "step");
10863
+ const atRaw = strFlag(flags, "at");
10864
+ const secondsRaw = strFlag(flags, "seconds");
10865
+ const side = strFlag(flags, "side");
10866
+ const mark = strFlag(flags, "mark");
10867
+ let composed;
10868
+ try {
10869
+ composed = composeCallout(take.doc, register, {
10870
+ shape,
10871
+ words,
10872
+ id: strFlag(flags, "id"),
10873
+ step: stepRaw !== void 0 ? /^\d+$/.test(stepRaw) ? Number(stepRaw) : stepRaw : void 0,
10874
+ at: atRaw !== void 0 ? Number(atRaw) : void 0,
10875
+ seconds: secondsRaw !== void 0 ? Number(secondsRaw) : void 0,
10876
+ side,
10877
+ mark,
10878
+ leader: flags.leader === true,
10879
+ color: strFlag(flags, "color")
10880
+ });
10881
+ } catch (e) {
10882
+ throw new UsageError(e instanceof Error ? e.message : String(e));
10883
+ }
10884
+ const { clip, window: window2 } = composed;
10885
+ if (flags.print === true) {
10886
+ r.done(
10887
+ { clip, window: window2, register, brand: brand?.file ?? null },
10888
+ JSON.stringify(clip, null, 2)
10889
+ );
10890
+ return EXIT_OK;
10891
+ }
10892
+ const doc = take.doc;
10893
+ const overlays = (doc.overlays ?? []).filter((o) => o.id !== clip.id);
10894
+ const next = { ...doc, overlays: [...overlays, clip] };
10895
+ const lint = lintDoc(next);
10896
+ if (lint.problems.length) {
10897
+ r.done(
10898
+ { valid: false, problems: lint.problems, warnings: lint.warnings, clip },
10899
+ `the callout would not validate:
10900
+ ${lint.problems.join("\n ")}`
10901
+ );
10902
+ return EXIT_ERROR;
10903
+ }
10904
+ await writeJson(take.paths.doc, next, true);
10905
+ r.done(
10906
+ {
10907
+ clip,
10908
+ window: window2,
10909
+ register,
10910
+ brand: brand?.file ?? null,
10911
+ warnings: lint.warnings
10912
+ },
10913
+ `${shape} "${clip.id}" at ${window2.start.toFixed(2)}s for ${window2.duration.toFixed(2)}s${clip.pin ? `, pinned to ${clip.pin.step !== void 0 ? `step ${String(clip.pin.step)}` : `the press at ${String(clip.pin.press)}s`}` : ""}, ground ${register.ground} \u2192 card in the product's hue, written to ${take.paths.doc}${lint.warnings.length ? `
10914
+ warnings:
10915
+ ${lint.warnings.join("\n ")}` : ""}`
10916
+ );
10917
+ return EXIT_OK;
10918
+ }
10490
10919
  async function cmdValidate(argv) {
10491
10920
  const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
10492
10921
  const target = positionals[0];
10493
10922
  if (!target) throw new UsageError("vos validate <actions.json|take>");
10494
10923
  const r = createReporter(flags.json === true);
10495
- const kitTarget = target.endsWith("kit.json") ? target : existsSync14(join17(target, "kit.json")) && !isTakeDir(target) ? join17(target, "kit.json") : null;
10924
+ const kitTarget = target.endsWith("kit.json") ? target : existsSync15(join18(target, "kit.json")) && !isTakeDir(target) ? join18(target, "kit.json") : null;
10496
10925
  if (kitTarget) {
10497
10926
  const picture = flags.picture === true;
10498
10927
  const verdict = await validateKit(kitTarget, { picture });
@@ -10524,9 +10953,9 @@ async function cmdValidate(argv) {
10524
10953
  r.done({ valid: true, target }, `${target}: valid actions file`);
10525
10954
  return EXIT_OK;
10526
10955
  }
10527
- if (!isTakeDir(target) && existsSync14(join17(target, "config.json"))) {
10956
+ if (!isTakeDir(target) && existsSync15(join18(target, "config.json"))) {
10528
10957
  const parsed = JSON.parse(
10529
- await readFile14(join17(target, "config.json"), "utf8")
10958
+ await readFile15(join18(target, "config.json"), "utf8")
10530
10959
  );
10531
10960
  const pre = preflightConfig(parsed);
10532
10961
  const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
@@ -10567,7 +10996,7 @@ async function cmdValidate(argv) {
10567
10996
  const take = await loadTake(target);
10568
10997
  const problems = [];
10569
10998
  const warnings = [];
10570
- if (!existsSync14(take.paths.recording))
10999
+ if (!existsSync15(take.paths.recording))
10571
11000
  problems.push("missing recording.webm (re-run record)");
10572
11001
  if (!take.doc) problems.push("missing doc.json (run plan)");
10573
11002
  if (take.doc) {
@@ -10579,6 +11008,36 @@ async function cmdValidate(argv) {
10579
11008
  );
10580
11009
  problems.push(...probe.problems);
10581
11010
  warnings.push(...probe.warnings);
11011
+ if (flags.picture === true && (take.doc.overlays ?? []).some((o) => o.kind === "html")) {
11012
+ const doc = take.doc;
11013
+ const times = [
11014
+ ...new Set(
11015
+ (doc.overlays ?? []).filter((o) => o.kind === "html").map((o) => +(o.start + Math.min(0.35, o.duration / 2)).toFixed(3))
11016
+ )
11017
+ ];
11018
+ const browser = await launchBrowser();
11019
+ try {
11020
+ const frames = await footageFrames(
11021
+ browser,
11022
+ target,
11023
+ doc,
11024
+ times,
11025
+ framesTake
11026
+ );
11027
+ const findings = await groundFindings(
11028
+ doc,
11029
+ async (t) => frames.get(+t.toFixed(3)) ?? null
11030
+ );
11031
+ for (const f of findings) {
11032
+ if (f.level === "problem") problems.push(`doc.json: ${f.message}`);
11033
+ else if (f.level === "warning")
11034
+ warnings.push(`doc.json: ${f.message}`);
11035
+ else r.event({ event: "ground", ...f });
11036
+ }
11037
+ } finally {
11038
+ await browser.close();
11039
+ }
11040
+ }
10582
11041
  }
10583
11042
  if (problems.length) {
10584
11043
  r.done(
@@ -10695,6 +11154,8 @@ async function run(argv) {
10695
11154
  return await cmdOpen(rest);
10696
11155
  case "validate":
10697
11156
  return await cmdValidate(rest);
11157
+ case "callout":
11158
+ return await cmdCallout(rest);
10698
11159
  case "judge":
10699
11160
  return await cmdJudge(rest);
10700
11161
  case "fetch":
@@ -10778,4 +11239,4 @@ export {
10778
11239
  convertAgentBrowser,
10779
11240
  run
10780
11241
  };
10781
- //# sourceMappingURL=chunk-QJORXTJM.js.map
11242
+ //# sourceMappingURL=chunk-43FCXWNS.js.map