@vosjs/cli 0.32.0 → 0.33.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,
@@ -214,6 +214,7 @@ import {
214
214
  findStep,
215
215
  outputEnd,
216
216
  pinBox,
217
+ pinCandidates,
217
218
  pinRectOnScreen,
218
219
  pinReferent,
219
220
  ratedSegments,
@@ -1611,6 +1612,17 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1611
1612
  const tf = isObj(o.transform) ? o.transform : null;
1612
1613
  if (!tf || !isNum(tf.x) || !isNum(tf.y)) return;
1613
1614
  const pinned = pins.get(String(o.id));
1615
+ if (!pinned && o.pin === void 0 && (o.kind === "html" || o.kind === "image" || o.kind === "video")) {
1616
+ const cands = pinCandidates(docIn, {
1617
+ start: o.start,
1618
+ duration: o.duration
1619
+ });
1620
+ if (cands.length) {
1621
+ warnings.push(
1622
+ `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)}" }`
1623
+ );
1624
+ }
1625
+ }
1614
1626
  if (pinned) {
1615
1627
  if (pinned.clamped > 4) {
1616
1628
  warnings.push(
@@ -5294,10 +5306,208 @@ function winRate(verdicts) {
5294
5306
  return { wins, ties, judged, rate: judged ? (wins + ties / 2) / judged : null };
5295
5307
  }
5296
5308
 
5309
+ // src/plugin/callout.ts
5310
+ import { existsSync as existsSync9 } from "fs";
5311
+ import { readFile as readFile9 } from "fs/promises";
5312
+ import { join as join10 } from "path";
5313
+ import { lerpArray, sample } from "@vosjs/timeline";
5314
+ import {
5315
+ CALLOUT_DEFAULT_BODY_PX,
5316
+ CALLOUT_SHAPES,
5317
+ calloutClip,
5318
+ calloutGroundOf,
5319
+ cameraModel as cameraModel2,
5320
+ deltaE,
5321
+ docCardLayout as docCardLayout2,
5322
+ docZoomTrack as docZoomTrack2,
5323
+ hexToRgb,
5324
+ parseCssColour,
5325
+ pinBox as pinBox2,
5326
+ ratedSegments as ratedSegments5,
5327
+ resolvePins as resolvePins2,
5328
+ spanOutputExtent as spanOutputExtent5
5329
+ } from "@vosjs/studio-core";
5330
+ var CALLOUT_DEFAULT_SECONDS = 3;
5331
+ var CALLOUT_LEAD_SECONDS = 0.2;
5332
+ var CALLOUT_DELTA_E_PROBLEM = 8;
5333
+ var CALLOUT_DELTA_E_WARNING = 16;
5334
+ function registerFrom(roles, flags) {
5335
+ const ground = flags.ground ?? roles?.bgA;
5336
+ const accent = flags.accent ?? roles?.accent;
5337
+ if (!ground || !accent) return null;
5338
+ if (!hexToRgb(ground) || !hexToRgb(accent)) return null;
5339
+ const face = flags.face ?? roles?.fontBody;
5340
+ return {
5341
+ ground,
5342
+ accent,
5343
+ ...face && !/^ui-sans-serif$|^system-ui$/.test(face) ? { face } : {},
5344
+ ...flags.mono ? { mono: flags.mono } : {},
5345
+ body: flags.body ?? CALLOUT_DEFAULT_BODY_PX
5346
+ };
5347
+ }
5348
+ function appScaleAt(doc, t) {
5349
+ const layout = docCardLayout2(doc);
5350
+ const meta = doc.source.meta;
5351
+ const base = layout.dw / Math.max(1, meta.width);
5352
+ const track = docZoomTrack2(doc);
5353
+ if (!track || !track.keyframes.length) return base;
5354
+ const z = sample(track, t, lerpArray);
5355
+ return base * Math.max(1, z[0]);
5356
+ }
5357
+ function windowForStep(doc, step, seconds = CALLOUT_DEFAULT_SECONDS) {
5358
+ const steps = doc.source.meta.steps ?? [];
5359
+ const s = steps.find((x) => x.id !== void 0 && x.id === step) ?? steps.find((x) => String(x.step) === String(step));
5360
+ if (!s || s.skipped) return null;
5361
+ const rated = ratedSegments5(doc);
5362
+ const ext = spanOutputExtent5(rated, s.tStart, s.tEnd);
5363
+ if (!ext) return null;
5364
+ const at2 = s.do === "click" || s.do === "drag" ? ext.start : ext.end;
5365
+ const start = +(at2 + CALLOUT_LEAD_SECONDS).toFixed(3);
5366
+ let end = start + seconds;
5367
+ for (const n of steps) {
5368
+ if (n.tStart <= s.tStart) continue;
5369
+ if (n.do !== "scroll" && !n.navigated) continue;
5370
+ const ne = spanOutputExtent5(rated, n.tStart, n.tEnd);
5371
+ if (ne && ne.start > start && ne.start < end) end = ne.start - 0.05;
5372
+ }
5373
+ return { start, duration: +Math.max(0.5, end - start).toFixed(3) };
5374
+ }
5375
+ function composeCallout(doc, register, ask) {
5376
+ let window2 = null;
5377
+ if (ask.step !== void 0) {
5378
+ window2 = windowForStep(doc, ask.step, ask.seconds);
5379
+ if (!window2)
5380
+ throw new Error(
5381
+ `step "${String(ask.step)}" is not in the take's step timeline, was skipped, or falls outside the cut`
5382
+ );
5383
+ } else if (ask.at !== void 0) {
5384
+ window2 = {
5385
+ start: ask.at,
5386
+ duration: ask.seconds ?? CALLOUT_DEFAULT_SECONDS
5387
+ };
5388
+ } else {
5389
+ throw new Error(
5390
+ "name the step the callout is about (--step <id>), or --at <output seconds>"
5391
+ );
5392
+ }
5393
+ const pin = ask.step !== void 0 ? { step: ask.step } : ask.press !== void 0 ? { press: ask.press } : void 0;
5394
+ if (pin) {
5395
+ if (ask.side && ask.side !== "auto") pin.side = ask.side;
5396
+ if (ask.mark && ask.mark !== "none") pin.mark = ask.mark;
5397
+ if (ask.leader) pin.leader = true;
5398
+ if (ask.color) pin.color = ask.color;
5399
+ }
5400
+ const taken = new Set((doc.overlays ?? []).map((o) => o.id));
5401
+ let id = ask.id ?? `${ask.shape}-${ask.step ?? Math.round(window2.start * 10)}`;
5402
+ let n = 2;
5403
+ while (taken.has(id) && !ask.id)
5404
+ id = `${ask.shape}-${ask.step ?? Math.round(window2.start * 10)}-${n++}`;
5405
+ const clip = calloutClip(ask.shape, register, ask.words, {
5406
+ id,
5407
+ start: window2.start,
5408
+ duration: window2.duration,
5409
+ scale: appScaleAt(doc, window2.start + 0.35),
5410
+ pin,
5411
+ // The fallback place, in the margin: the lower third.
5412
+ at: { x: 0.5, y: 0.82 }
5413
+ });
5414
+ return { clip, window: window2 };
5415
+ }
5416
+ function isCalloutShape(s) {
5417
+ return CALLOUT_SHAPES.includes(s);
5418
+ }
5419
+ async function groundFindings(doc, frameAt) {
5420
+ const out = [];
5421
+ const overlays = doc.overlays ?? [];
5422
+ const layout = docCardLayout2(doc);
5423
+ const camera = cameraModel2(doc.frame);
5424
+ const track = docZoomTrack2(doc);
5425
+ const pins = resolvePins2(doc, layout, camera, track);
5426
+ for (let i = 0; i < overlays.length; i++) {
5427
+ const o = overlays[i];
5428
+ if (o.kind !== "html") continue;
5429
+ const card = calloutGroundOf(o.css ?? "");
5430
+ if (!card) {
5431
+ out.push({
5432
+ index: i,
5433
+ id: o.id,
5434
+ level: "note",
5435
+ deltaE: null,
5436
+ card: null,
5437
+ footage: null,
5438
+ message: `overlays[${i}] declares no readable background on its root rule \u2014 the ground check cannot read it`
5439
+ });
5440
+ continue;
5441
+ }
5442
+ const t = o.start + Math.min(0.35, o.duration / 2);
5443
+ const img = await frameAt(t);
5444
+ if (!img) {
5445
+ out.push({
5446
+ index: i,
5447
+ id: o.id,
5448
+ level: "note",
5449
+ deltaE: null,
5450
+ card,
5451
+ footage: null,
5452
+ message: `overlays[${i}]: the frame at ${t.toFixed(2)}s could not be read`
5453
+ });
5454
+ continue;
5455
+ }
5456
+ const box = pinBox2(o, layout);
5457
+ const pinned = pins.get(o.id);
5458
+ let cx = o.transform.x * layout.W;
5459
+ let cy = o.transform.y * layout.H;
5460
+ if (pinned) {
5461
+ const v = sample(pinned.track, t - o.start, lerpArray);
5462
+ cx = v[0] * layout.W;
5463
+ cy = v[1] * layout.H;
5464
+ }
5465
+ const sx = img.w / layout.W;
5466
+ const sy = img.h / layout.H;
5467
+ const rect = {
5468
+ x: (cx - box.w / 2) * sx,
5469
+ y: (cy - box.h / 2) * sy,
5470
+ w: box.w * sx,
5471
+ h: box.h * sy
5472
+ };
5473
+ const footage = medianColour(img, rect);
5474
+ const cardRgb = parseCssColour(card);
5475
+ if (!cardRgb) continue;
5476
+ const d = deltaE(cardRgb, footage);
5477
+ const fh = `#${footage.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
5478
+ const level = d < CALLOUT_DELTA_E_PROBLEM ? "problem" : d < CALLOUT_DELTA_E_WARNING ? "warning" : "note";
5479
+ out.push({
5480
+ index: i,
5481
+ id: o.id,
5482
+ level,
5483
+ deltaE: +d.toFixed(1),
5484
+ card,
5485
+ footage: fh,
5486
+ 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`
5487
+ });
5488
+ }
5489
+ return out;
5490
+ }
5491
+ async function footageFrames(browser, dir, doc, times, framesTake2) {
5492
+ const out = /* @__PURE__ */ new Map();
5493
+ if (!times.length) return out;
5494
+ const bare = { ...doc, overlays: [] };
5495
+ const outDir = join10(dir, ".ground-check");
5496
+ const res = await framesTake2(browser, dir, { times, outDir, doc: bare });
5497
+ for (const f of res.frames) {
5498
+ if (!existsSync9(f.file)) continue;
5499
+ out.set(
5500
+ +f.time.toFixed(3),
5501
+ decodePng(new Uint8Array(await readFile9(f.file)))
5502
+ );
5503
+ }
5504
+ return out;
5505
+ }
5506
+
5297
5507
  // src/plugin/kitPicture.ts
5298
5508
  import { execFile } from "child_process";
5299
5509
  import { promisify } from "util";
5300
- import { readFile as readFile9 } from "fs/promises";
5510
+ import { readFile as readFile10 } from "fs/promises";
5301
5511
  var execFileP = promisify(execFile);
5302
5512
  var SUBJECT_BAND = { min: 0.6, max: 0.92 };
5303
5513
  var BLANK_INK = 0.12;
@@ -5325,7 +5535,7 @@ function apcaContrast(text, bg) {
5325
5535
  return sapc > -0.1 ? 0 : -(sapc + 0.027) * 100;
5326
5536
  }
5327
5537
  var pct = (v) => `${Math.round(v * 100)}%`;
5328
- var hexToRgb = (h) => {
5538
+ var hexToRgb2 = (h) => {
5329
5539
  const m = /^#([0-9a-f]{6})$/i.exec(h.trim());
5330
5540
  if (!m) return null;
5331
5541
  const n = parseInt(m[1], 16);
@@ -5474,7 +5684,7 @@ function textFindings(a, img) {
5474
5684
  });
5475
5685
  }
5476
5686
  }
5477
- const rgb = t.color ? hexToRgb(t.color) : null;
5687
+ const rgb = t.color ? hexToRgb2(t.color) : null;
5478
5688
  if (rgb) {
5479
5689
  const ground = medianColour(img, box);
5480
5690
  const lc = apcaContrast(rgb, ground);
@@ -5573,7 +5783,7 @@ async function pictureChecks(assets) {
5573
5783
  let ffmpeg = null;
5574
5784
  for (const a of assets) {
5575
5785
  if (/\.png$/i.test(a.file)) {
5576
- const img = decodePng(new Uint8Array(await readFile9(a.file)));
5786
+ const img = decodePng(new Uint8Array(await readFile10(a.file)));
5577
5787
  if (!img) {
5578
5788
  findings.push({
5579
5789
  code: "unreadable",
@@ -5666,9 +5876,9 @@ function formatFinding(f) {
5666
5876
  }
5667
5877
 
5668
5878
  // 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";
5879
+ import { existsSync as existsSync10 } from "fs";
5880
+ import { readFile as readFile11, stat as stat2 } from "fs/promises";
5881
+ import { dirname as dirname2, isAbsolute as isAbsolute4, join as join11 } from "path";
5672
5882
  import { DESTINATIONS as DESTINATIONS3 } from "@vosjs/studio-core";
5673
5883
  var PNG_SIG2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
5674
5884
  function pngDimensions(bytes) {
@@ -5687,7 +5897,7 @@ async function probeVideo(path) {
5687
5897
  const MB = await import("mediabunny");
5688
5898
  const input = new MB.Input({
5689
5899
  formats: MB.ALL_FORMATS,
5690
- source: new MB.BufferSource(new Uint8Array(await readFile10(path)))
5900
+ source: new MB.BufferSource(new Uint8Array(await readFile11(path)))
5691
5901
  });
5692
5902
  try {
5693
5903
  const track = await input.getPrimaryVideoTrack();
@@ -5708,7 +5918,7 @@ async function validateKit(kitPath, opts = {}) {
5708
5918
  const measured = [];
5709
5919
  let kit;
5710
5920
  try {
5711
- kit = JSON.parse(await readFile10(kitPath, "utf8"));
5921
+ kit = JSON.parse(await readFile11(kitPath, "utf8"));
5712
5922
  } catch (e) {
5713
5923
  return {
5714
5924
  valid: false,
@@ -5727,8 +5937,8 @@ async function validateKit(kitPath, opts = {}) {
5727
5937
  }
5728
5938
  const base = dirname2(kitPath);
5729
5939
  const resolvePath = (p) => {
5730
- if (isAbsolute4(p) && existsSync9(p)) return p;
5731
- const beside = join10(base, p.split("/").pop() ?? p);
5940
+ if (isAbsolute4(p) && existsSync10(p)) return p;
5941
+ const beside = join11(base, p.split("/").pop() ?? p);
5732
5942
  return beside;
5733
5943
  };
5734
5944
  const perDestination = /* @__PURE__ */ new Map();
@@ -5754,7 +5964,7 @@ async function validateKit(kitPath, opts = {}) {
5754
5964
  let h = null;
5755
5965
  let seconds = null;
5756
5966
  if (/\.(png|jpe?g|webp)$/i.test(file)) {
5757
- const head = Buffer.from(await readFile10(file));
5967
+ const head = Buffer.from(await readFile11(file));
5758
5968
  const kind = sniffImage(head);
5759
5969
  if (file.toLowerCase().endsWith(".png") && kind !== "png")
5760
5970
  problems.push(
@@ -5864,7 +6074,7 @@ async function validateKit(kitPath, opts = {}) {
5864
6074
 
5865
6075
  // src/plugin/recorder.ts
5866
6076
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
5867
- import { join as join11 } from "path";
6077
+ import { join as join12 } from "path";
5868
6078
 
5869
6079
  // src/plugin/recordingCap.ts
5870
6080
  var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
@@ -5952,7 +6162,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
5952
6162
  cdp.on("Page.screencastFrame", (ev) => {
5953
6163
  const tsMs = ev.metadata.timestamp ? ev.metadata.timestamp * 1e3 : Date.now();
5954
6164
  const file = `frame-${String(frameIdx++).padStart(5, "0")}.jpg`;
5955
- writeFileSync2(join11(paths.framesDir, file), Buffer.from(ev.data, "base64"));
6165
+ writeFileSync2(join12(paths.framesDir, file), Buffer.from(ev.data, "base64"));
5956
6166
  frames.push({ file, tMs: Math.max(0, Math.round(tsMs - t0)) });
5957
6167
  cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
5958
6168
  });
@@ -6190,7 +6400,7 @@ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6190
6400
  await sleep(200);
6191
6401
  const pageTitle = await page.title().catch(() => "");
6192
6402
  await context.close();
6193
- const firstFrame = frames[0] ? jpegDims(readFileSync3(join11(paths.framesDir, frames[0].file))) : null;
6403
+ const firstFrame = frames[0] ? jpegDims(readFileSync3(join12(paths.framesDir, frames[0].file))) : null;
6194
6404
  const meta = {
6195
6405
  dpr: 1,
6196
6406
  zoom: 1,
@@ -6298,9 +6508,9 @@ async function encodeRecording(browser, takeDir, onProgress) {
6298
6508
  }
6299
6509
 
6300
6510
  // 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";
6511
+ import { existsSync as existsSync11 } from "fs";
6512
+ import { readFile as readFile12 } from "fs/promises";
6513
+ import { join as join13 } from "path";
6304
6514
  import {
6305
6515
  DEFAULT_FRAME_STYLE,
6306
6516
  STYLE_FIELDS,
@@ -6522,10 +6732,10 @@ function retimeCut(prev, newSteps, newDurationMs) {
6522
6732
  // src/plugin/plan.ts
6523
6733
  var overlaps = (a, b) => a.in < b.out && b.in < a.out;
6524
6734
  async function readDigestActivity(dir) {
6525
- const file = join12(dir, "digest", "digest.json");
6526
- if (!existsSync10(file)) return null;
6735
+ const file = join13(dir, "digest", "digest.json");
6736
+ if (!existsSync11(file)) return null;
6527
6737
  try {
6528
- const d = JSON.parse(await readFile11(file, "utf8"));
6738
+ const d = JSON.parse(await readFile12(file, "utf8"));
6529
6739
  return Array.isArray(d.activity) && d.activity.every((v) => typeof v === "number") ? d.activity : null;
6530
6740
  } catch {
6531
6741
  return null;
@@ -6735,17 +6945,17 @@ async function planTake(dir, opts = {}) {
6735
6945
 
6736
6946
  // src/plugin/sync.ts
6737
6947
  import { createHash } from "crypto";
6738
- import { existsSync as existsSync12 } from "fs";
6739
- import { readFile as readFile12 } from "fs/promises";
6948
+ import { existsSync as existsSync13 } from "fs";
6949
+ import { readFile as readFile13 } from "fs/promises";
6740
6950
  import { createInterface } from "readline/promises";
6741
- import { basename, join as join15 } from "path";
6951
+ import { basename, join as join16 } from "path";
6742
6952
  import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc3 } from "@vosjs/studio-core";
6743
6953
 
6744
6954
  // src/plugin/media.ts
6745
6955
  import { isKeyedOverlay } from "@vosjs/studio-core";
6746
- import { createWriteStream, existsSync as existsSync11 } from "fs";
6956
+ import { createWriteStream, existsSync as existsSync12 } from "fs";
6747
6957
  import { writeFile as writeFile9 } from "fs/promises";
6748
- import { join as join13 } from "path";
6958
+ import { join as join14 } from "path";
6749
6959
  import { Readable } from "stream";
6750
6960
  import { pipeline } from "stream/promises";
6751
6961
  var MIC_NAME = "mic.webm";
@@ -6869,8 +7079,8 @@ async function pullMedia(ctx, dir, doc, log) {
6869
7079
  const url = doc.source[key];
6870
7080
  const assetId = assetIdOf(url);
6871
7081
  if (!assetId) continue;
6872
- const target = join13(dir, file);
6873
- if (existsSync11(target)) {
7082
+ const target = join14(dir, file);
7083
+ if (existsSync12(target)) {
6874
7084
  result.kept.push(file);
6875
7085
  } else {
6876
7086
  const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
@@ -6895,8 +7105,8 @@ async function pullMedia(ctx, dir, doc, log) {
6895
7105
  for (const ref of docMediaRefs(doc, (k) => assetIdOf(k) !== null)) {
6896
7106
  const assetId = assetIdOf(ref.key);
6897
7107
  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"))
7108
+ const existing = existsSync12(join14(dir, "media")) ? (await import("fs/promises").then(
7109
+ (fs) => fs.readdir(join14(dir, "media"))
6900
7110
  )).find((f) => f.startsWith(`${assetId}.`)) : void 0;
6901
7111
  let file;
6902
7112
  if (existing) {
@@ -6913,9 +7123,9 @@ async function pullMedia(ctx, dir, doc, log) {
6913
7123
  }
6914
7124
  const ext = extensionFor(res.headers.get("content-type") ?? "");
6915
7125
  file = `media/${assetId}${ext}`;
6916
- const target = join13(dir, file);
7126
+ const target = join14(dir, file);
6917
7127
  await import("fs/promises").then(
6918
- (fs) => fs.mkdir(join13(dir, "media"), { recursive: true })
7128
+ (fs) => fs.mkdir(join14(dir, "media"), { recursive: true })
6919
7129
  );
6920
7130
  await pipeline(
6921
7131
  Readable.fromWeb(res.body),
@@ -6929,17 +7139,17 @@ async function pullMedia(ctx, dir, doc, log) {
6929
7139
  }
6930
7140
  ref.set(file);
6931
7141
  }
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));
7142
+ const metaPath = join14(dir, "meta.json");
7143
+ if (!existsSync12(metaPath)) await writeJson(metaPath, doc.source.meta, true);
7144
+ const cursorPath = join14(dir, "cursor.json");
7145
+ if (!existsSync12(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
7146
+ await writeFile9(join14(dir, "doc.json"), JSON.stringify(doc, null, 2));
6937
7147
  return result;
6938
7148
  }
6939
7149
 
6940
7150
  // src/plugin/folder.ts
6941
7151
  import { mkdir as mkdir7, writeFile as writeFile10 } from "fs/promises";
6942
- import { join as join14 } from "path";
7152
+ import { join as join15 } from "path";
6943
7153
  import { recipeHints } from "@vosjs/shared/frontmatter";
6944
7154
  import { migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
6945
7155
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["json", "help", "media"]);
@@ -7115,18 +7325,18 @@ async function cmdPull(argv) {
7115
7325
  throw new Error(apiError(`pull folder ${folder.slug}`, res));
7116
7326
  const payload = res.body;
7117
7327
  const out = strFlag(flags, "out") ?? folder.slug;
7118
- await mkdir7(join14(out, "recipes"), { recursive: true });
7328
+ await mkdir7(join15(out, "recipes"), { recursive: true });
7119
7329
  const lines = [];
7120
7330
  const recipes = [];
7121
7331
  for (const rec of payload.recipes) {
7122
- const path = join14(out, "recipes", safeName(rec.filename));
7332
+ const path = join15(out, "recipes", safeName(rec.filename));
7123
7333
  await writeFile10(path, rec.body ?? "");
7124
7334
  recipes.push({ path, line: recipeLine(rec.filename, rec.body ?? "") });
7125
7335
  }
7126
7336
  for (const rec of payload.inheritedRecipes) {
7127
7337
  const from = safeName(rec.folderSlug ?? "inherited");
7128
- await mkdir7(join14(out, "recipes", "_inherited", from), { recursive: true });
7129
- const path = join14(
7338
+ await mkdir7(join15(out, "recipes", "_inherited", from), { recursive: true });
7339
+ const path = join15(
7130
7340
  out,
7131
7341
  "recipes",
7132
7342
  "_inherited",
@@ -7141,7 +7351,7 @@ async function cmdPull(argv) {
7141
7351
  }
7142
7352
  const members = [];
7143
7353
  for (const v of payload.voses) {
7144
- const dir = join14(out, "members", safeName(v.slug || v.title || v.id));
7354
+ const dir = join15(out, "members", safeName(v.slug || v.title || v.id));
7145
7355
  await mkdir7(dir, { recursive: true });
7146
7356
  const cfg = await apiJson(origin, `/api/vos/${v.id}/config`, { key });
7147
7357
  if (cfg.status !== 200) {
@@ -7149,7 +7359,7 @@ async function cmdPull(argv) {
7149
7359
  continue;
7150
7360
  }
7151
7361
  await writeFile10(
7152
- join14(dir, "config.json"),
7362
+ join15(dir, "config.json"),
7153
7363
  JSON.stringify(cfg.body.config, null, 2)
7154
7364
  );
7155
7365
  let take = false;
@@ -7161,7 +7371,7 @@ async function cmdPull(argv) {
7161
7371
  );
7162
7372
  if (doc.status === 200) {
7163
7373
  await writeFile10(
7164
- join14(dir, "doc.json"),
7374
+ join15(dir, "doc.json"),
7165
7375
  JSON.stringify(doc.body, null, 2)
7166
7376
  );
7167
7377
  take = true;
@@ -7278,7 +7488,7 @@ async function pushTake(dir, flags, r) {
7278
7488
  throw new Error(`doc.json fails lint:
7279
7489
  ${lint.problems.join("\n ")}`);
7280
7490
  }
7281
- if (!existsSync12(take.paths.recording)) {
7491
+ if (!existsSync13(take.paths.recording)) {
7282
7492
  throw new Error("no recording.webm in this take");
7283
7493
  }
7284
7494
  const ctx = apiContext(flags);
@@ -7310,7 +7520,7 @@ async function pushTake(dir, flags, r) {
7310
7520
  throw new Error("push cancelled");
7311
7521
  }
7312
7522
  }
7313
- const bytes = await readFile12(take.paths.recording);
7523
+ const bytes = await readFile13(take.paths.recording);
7314
7524
  const hash = createHash("sha256").update(bytes).digest("hex");
7315
7525
  r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
7316
7526
  const upload = await api(ctx, "/assets/recording", {
@@ -7349,12 +7559,12 @@ async function pushTake(dir, flags, r) {
7349
7559
  delete docForPush.source.micKey;
7350
7560
  }
7351
7561
  for (const ref of docMediaRefs(docForPush)) {
7352
- const file = join15(dir, takeRelativeFile(ref.key));
7353
- if (!existsSync12(file)) {
7562
+ const file = join16(dir, takeRelativeFile(ref.key));
7563
+ if (!existsSync13(file)) {
7354
7564
  r.log(` ${ref.where}: ${ref.key} is not in the take \u2014 left as is`);
7355
7565
  continue;
7356
7566
  }
7357
- const media = await readFile12(file);
7567
+ const media = await readFile13(file);
7358
7568
  const mediaHash = createHash("sha256").update(media).digest("hex");
7359
7569
  const type = mediaContentType(file);
7360
7570
  const put = await api(ctx, "/assets/recording", {
@@ -7490,9 +7700,9 @@ async function pullTake(dir, flags, r) {
7490
7700
  };
7491
7701
  }
7492
7702
  let media2;
7493
- if (flags.media && existsSync12(join15(dir, "doc.json"))) {
7703
+ if (flags.media && existsSync13(join16(dir, "doc.json"))) {
7494
7704
  const local = JSON.parse(
7495
- await readFile12(join15(dir, "doc.json"), "utf8")
7705
+ await readFile13(join16(dir, "doc.json"), "utf8")
7496
7706
  );
7497
7707
  media2 = await pullMedia(ctx, dir, local, r.log);
7498
7708
  }
@@ -7552,10 +7762,10 @@ async function pullTake(dir, flags, r) {
7552
7762
  }
7553
7763
  const hostedDoc = migrateHostedDoc3(docRes.json);
7554
7764
  const doc = hostedDoc;
7555
- if (existsSync12(join15(dir, RECORDING_NAME))) {
7765
+ if (existsSync13(join16(dir, RECORDING_NAME))) {
7556
7766
  doc.source.videoKey = RECORDING_NAME;
7557
7767
  }
7558
- await writeJson(join15(dir, "doc.json"), doc, true);
7768
+ await writeJson(join16(dir, "doc.json"), doc, true);
7559
7769
  const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
7560
7770
  const versions = await api(ctx, `/vos/${vosId}/versions`);
7561
7771
  const headRow = (versions.json.versions ?? []).find((v) => v.id === head);
@@ -7574,10 +7784,10 @@ async function pullTake(dir, flags, r) {
7574
7784
  }
7575
7785
 
7576
7786
  // 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";
7787
+ import { existsSync as existsSync14 } from "fs";
7788
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile11 } from "fs/promises";
7579
7789
  import { createInterface as createInterface2 } from "readline/promises";
7580
- import { basename as basename2, dirname as dirname3, join as join16 } from "path";
7790
+ import { basename as basename2, dirname as dirname3, join as join17 } from "path";
7581
7791
  import {
7582
7792
  CURRENT_CONFIG_VERSION,
7583
7793
  migrateConfig,
@@ -7747,8 +7957,8 @@ function preflightConfig(parsed) {
7747
7957
  }
7748
7958
  function resolveConfigPath(target) {
7749
7959
  if (target.endsWith(".json")) return target;
7750
- const inDir = join16(target, "config.json");
7751
- if (existsSync13(inDir)) return inDir;
7960
+ const inDir = join17(target, "config.json");
7961
+ if (existsSync14(inDir)) return inDir;
7752
7962
  throw new UsageError(
7753
7963
  `${target} has no config.json (and is not a take \u2014 no doc.json)`
7754
7964
  );
@@ -7776,7 +7986,7 @@ async function cmdFetch(argv) {
7776
7986
  const out = strFlag(flags, "out") ?? slug;
7777
7987
  await mkdir8(out, { recursive: true });
7778
7988
  await writeFile11(
7779
- join16(out, "config.json"),
7989
+ join17(out, "config.json"),
7780
7990
  JSON.stringify(cfg.body.config, null, 2)
7781
7991
  );
7782
7992
  const title = typeof vosMeta.title === "string" ? vosMeta.title : "";
@@ -7801,12 +8011,12 @@ async function cmdFetch(argv) {
7801
8011
  const hosted = migrateHostedDoc4(doc.body);
7802
8012
  const { config: own } = await writeProgramDoc(out, hosted);
7803
8013
  if (own) {
7804
- await writeFile11(join16(out, "config.json"), JSON.stringify(own, null, 2));
8014
+ await writeFile11(join17(out, "config.json"), JSON.stringify(own, null, 2));
7805
8015
  }
7806
8016
  } else if (doc.status === 200) {
7807
8017
  take = true;
7808
8018
  const hosted = migrateHostedDoc4(doc.body);
7809
- await writeFile11(join16(out, "doc.json"), JSON.stringify(hosted, null, 2));
8019
+ await writeFile11(join17(out, "doc.json"), JSON.stringify(hosted, null, 2));
7810
8020
  if (flags.media === true) {
7811
8021
  const media = await pullMedia({ origin, key }, out, hosted, r.log);
7812
8022
  mediaLine = media.downloaded.length ? ` + ${media.downloaded.map((m) => m.file).join(", ")}` : "";
@@ -7919,7 +8129,7 @@ async function cmdPushProgram(argv) {
7919
8129
  api: strFlag(flags, "api")
7920
8130
  });
7921
8131
  const dir = dirname3(source);
7922
- const parsed = JSON.parse(await readFile13(source, "utf8"));
8132
+ const parsed = JSON.parse(await readFile14(source, "utf8"));
7923
8133
  const pre = preflightConfig(parsed);
7924
8134
  if (!pre.ok || !pre.config) {
7925
8135
  for (const issue of pre.issues) r.log(`error ${issue}`);
@@ -8160,10 +8370,10 @@ async function cmdPullProgram(argv) {
8160
8370
  const cfg = await apiJson(origin, `/api/vos/${vosId}/config`, { key });
8161
8371
  if (cfg.status !== 200)
8162
8372
  throw new Error(apiError(`fetch head config for ${vosId}`, cfg));
8163
- const configPath = join16(dir, "config.json");
8373
+ const configPath = join17(dir, "config.json");
8164
8374
  let backedUp = false;
8165
- if (existsSync13(configPath)) {
8166
- await writeFile11(join16(dir, "config.backup.json"), await readFile13(configPath));
8375
+ if (existsSync14(configPath)) {
8376
+ await writeFile11(join17(dir, "config.backup.json"), await readFile14(configPath));
8167
8377
  backedUp = true;
8168
8378
  }
8169
8379
  let headConfig = cfg.body.config;
@@ -8196,7 +8406,7 @@ async function cmdPullProgram(argv) {
8196
8406
  protected: protectedIds,
8197
8407
  changes,
8198
8408
  out: configPath,
8199
- backup: backedUp ? join16(dir, "config.backup.json") : null
8409
+ backup: backedUp ? join17(dir, "config.backup.json") : null
8200
8410
  },
8201
8411
  `Pulled ${changes.length} version${changes.length === 1 ? "" : "s"} \u2192 ${configPath}` + (backedUp ? ` (previous copy: config.backup.json)` : "") + `
8202
8412
  Re-apply your edit on the new head, then: vos push ${configPath} --vos ${vosId}`
@@ -8258,9 +8468,9 @@ function storedProgramConfig(config, programDoc) {
8258
8468
  return lowerProgramDoc(doc).config;
8259
8469
  }
8260
8470
  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"));
8471
+ const docPath = join17(dir, "doc.json");
8472
+ if (!existsSync14(docPath)) return null;
8473
+ const parsed = JSON.parse(await readFile14(docPath, "utf8"));
8264
8474
  if (typeof parsed !== "object" || parsed === null || "source" in parsed)
8265
8475
  return null;
8266
8476
  const raw = parsed;
@@ -8271,7 +8481,7 @@ async function writeProgramDoc(dir, hosted) {
8271
8481
  const program = hosted.program && typeof hosted.program === "object" ? hosted.program : {};
8272
8482
  const { config, ...rest } = program;
8273
8483
  const onDisk = { ...hosted, program: rest };
8274
- await writeFile11(join16(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
8484
+ await writeFile11(join17(dir, "doc.json"), JSON.stringify(onDisk, null, 2));
8275
8485
  return {
8276
8486
  config: config && typeof config === "object" ? config : null
8277
8487
  };
@@ -9327,8 +9537,8 @@ async function cmdActions(argv) {
9327
9537
  if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
9328
9538
  viewport = { width: Number(m[1]), height: Number(m[2]) };
9329
9539
  }
9330
- const { readFile: readFile15 } = await import("fs/promises");
9331
- const text = await readFile15(resolve8(input), "utf8");
9540
+ const { readFile: readFile16 } = await import("fs/promises");
9541
+ const text = await readFile16(resolve8(input), "utf8");
9332
9542
  const { records, problems } = parseAgentBrowserLog(text);
9333
9543
  for (const p of problems) r.log(` ${p}`);
9334
9544
  const result = convertAgentBrowser(records, {
@@ -9382,7 +9592,8 @@ var BOOLEAN_FLAGS5 = /* @__PURE__ */ new Set([
9382
9592
  "yes",
9383
9593
  "composed",
9384
9594
  "check",
9385
- "motion"
9595
+ "motion",
9596
+ "leader"
9386
9597
  ]);
9387
9598
  var MULTI_FLAGS2 = /* @__PURE__ */ new Set(["set", "override"]);
9388
9599
  var HELP = `vos \u2014 record a browser flow, plan effects, render a product video; sync with vos.so
@@ -9397,6 +9608,7 @@ Take pipeline
9397
9608
  vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
9398
9609
  vos brand <url> [--out BRAND.md] [--json]
9399
9610
  vos open <take> [--studio <url>] [--print]
9611
+ 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
9612
  vos validate <actions.json|take|kit.json> [--picture] [--json]
9401
9613
  vos judge <kit.json> --against <MANIFEST.json> [--out dir] [--json]
9402
9614
  vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
@@ -9630,7 +9842,7 @@ lint-gated, so a bad override fails like a bad doc.json):
9630
9842
  first ready loop (the house backdrop the studio opens on), or a flat ground offline
9631
9843
  `;
9632
9844
  async function loadActions(file) {
9633
- const raw = JSON.parse(await readFile14(file, "utf8"));
9845
+ const raw = JSON.parse(await readFile15(file, "utf8"));
9634
9846
  const errors = validateActions(raw);
9635
9847
  if (errors.length)
9636
9848
  throw new UsageError(`invalid actions file:
@@ -9689,7 +9901,7 @@ async function cmdRecord(argv) {
9689
9901
  const outDir = resolve9(strFlag(flags, "out") ?? "take");
9690
9902
  const backdrop = await takeBackdrop(flags, r);
9691
9903
  const maxDurationSeconds = await maxDuration(flags, r);
9692
- if (existsSync14(join17(outDir, "meta.json"))) {
9904
+ if (existsSync15(join18(outDir, "meta.json"))) {
9693
9905
  const { prevDoc, kept } = await prepareReRecord(outDir);
9694
9906
  r.log(
9695
9907
  `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" : "")
@@ -9736,7 +9948,7 @@ async function cmdRecord(argv) {
9736
9948
  },
9737
9949
  strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}` : `Take ready: ${outDir}
9738
9950
  ${(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}`
9951
+ Next: edit ${join18(outDir, "doc.json")} (optional), then: vos render ${outDir}`
9740
9952
  );
9741
9953
  return strictFail ? EXIT_USAGE : EXIT_OK;
9742
9954
  } finally {
@@ -9765,14 +9977,14 @@ async function cmdCreate2(argv) {
9765
9977
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
9766
9978
  throw new UsageError("--format must be webm or mp4");
9767
9979
  const format = fmtRaw;
9768
- const out = positionals[0] ?? join17(outDir, `out.${format}`);
9980
+ const out = positionals[0] ?? join18(outDir, `out.${format}`);
9769
9981
  const parallel = numFlag(flags, "parallel", 1);
9770
9982
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
9771
9983
  throw new UsageError("--parallel expects an integer between 1 and 16");
9772
9984
  }
9773
9985
  const backdrop = await takeBackdrop(flags, r);
9774
9986
  const maxDurationSeconds = await maxDuration(flags, r);
9775
- if (existsSync14(join17(outDir, "meta.json"))) {
9987
+ if (existsSync15(join18(outDir, "meta.json"))) {
9776
9988
  const { prevDoc, kept } = await prepareReRecord(outDir);
9777
9989
  r.log(
9778
9990
  `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 +10075,24 @@ async function cmdPlan(argv) {
9863
10075
  );
9864
10076
  const r = createReporter(flags.json === true);
9865
10077
  if (flags.fresh === true) {
9866
- await rm4(join17(dir, "doc.json"), { force: true });
10078
+ await rm4(join18(dir, "doc.json"), { force: true });
9867
10079
  r.log("note: --fresh discarded the existing doc.json");
9868
10080
  }
9869
10081
  let reuse;
9870
10082
  if (flags.reuse === true) {
9871
- const from = resolve9(strFlag(flags, "from") ?? join17(dir, PREV_DOC_NAME));
9872
- if (!existsSync14(from)) {
10083
+ const from = resolve9(strFlag(flags, "from") ?? join18(dir, PREV_DOC_NAME));
10084
+ if (!existsSync15(from)) {
9873
10085
  throw new UsageError(
9874
10086
  `--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
9875
10087
  );
9876
10088
  }
9877
10089
  reuse = {
9878
10090
  from,
9879
- doc: JSON.parse(await readFile14(from, "utf8"))
10091
+ doc: JSON.parse(await readFile15(from, "utf8"))
9880
10092
  };
9881
10093
  }
9882
10094
  const style = await resolveStyleRef(flags);
9883
- const hasDoc = existsSync14(join17(dir, "doc.json"));
10095
+ const hasDoc = existsSync15(join18(dir, "doc.json"));
9884
10096
  const backdrop = hasDoc ? null : await takeBackdrop(flags, r);
9885
10097
  const release = await releaseInputs(dir, flags, r, multi.with ?? []);
9886
10098
  const motionWanted = !hasDoc || flags.motion === true || flags.fresh === true;
@@ -9938,7 +10150,7 @@ async function cmdPlan(argv) {
9938
10150
  ...s.motion ? { motion: s.motion } : {},
9939
10151
  ...s.reuse ? { reuse: s.reuse } : {}
9940
10152
  },
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}`
10153
+ `${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
10154
  );
9943
10155
  return EXIT_OK;
9944
10156
  }
@@ -10064,7 +10276,7 @@ async function cmdRender(argv) {
10064
10276
  if (fmtRaw !== "webm" && fmtRaw !== "mp4")
10065
10277
  throw new UsageError("--format must be webm or mp4");
10066
10278
  const format = fmtRaw;
10067
- const out = positionals[1] ?? join17(dir, `out.${format}`);
10279
+ const out = positionals[1] ?? join18(dir, `out.${format}`);
10068
10280
  const parallel = numFlag(flags, "parallel", 1);
10069
10281
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
10070
10282
  throw new UsageError("--parallel expects an integer between 1 and 16");
@@ -10311,15 +10523,15 @@ async function resolveStyleRef(flags) {
10311
10523
  }
10312
10524
  async function resolveDocRef(ref, flags, what) {
10313
10525
  const styleRef = ref;
10314
- const file = existsSync14(styleRef) ? resolve9(
10526
+ const file = existsSync15(styleRef) ? resolve9(
10315
10527
  styleRef,
10316
- existsSync14(join17(styleRef, "doc.json")) ? "doc.json" : ""
10528
+ existsSync15(join18(styleRef, "doc.json")) ? "doc.json" : ""
10317
10529
  ) : null;
10318
- if (file && existsSync14(file)) {
10530
+ if (file && existsSync15(file)) {
10319
10531
  return {
10320
10532
  from: file,
10321
10533
  doc: migrateHostedDoc5(
10322
- JSON.parse(await readFile14(file, "utf8"))
10534
+ JSON.parse(await readFile15(file, "utf8"))
10323
10535
  )
10324
10536
  };
10325
10537
  }
@@ -10371,7 +10583,7 @@ async function cmdDigest(argv) {
10371
10583
  const take = await loadTake(dir);
10372
10584
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
10373
10585
  const transcriptPath = strFlag(flags, "transcript");
10374
- const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile14(transcriptPath, "utf8"))) : null;
10586
+ const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile15(transcriptPath, "utf8"))) : null;
10375
10587
  const style = await resolveStyleRef(flags);
10376
10588
  const noFrames = flags["no-frames"] === true;
10377
10589
  let browser = null;
@@ -10402,7 +10614,7 @@ async function cmdDigest(argv) {
10402
10614
  kinds,
10403
10615
  frames: d.images.full,
10404
10616
  crops: d.images.crop,
10405
- sheet: d.images.sheet ? join17(result.outDir, d.images.sheet) : null,
10617
+ sheet: d.images.sheet ? join18(result.outDir, d.images.sheet) : null,
10406
10618
  scenes: kinds.scene ?? 0,
10407
10619
  sourceDuration: d.take.sourceDuration,
10408
10620
  outputDuration: d.take.outputDuration,
@@ -10426,7 +10638,7 @@ async function cmdOpen(argv) {
10426
10638
  if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
10427
10639
  const r = createReporter(flags.json === true);
10428
10640
  const take = await loadTake(dir);
10429
- if (!existsSync14(take.paths.recording)) {
10641
+ if (!existsSync15(take.paths.recording)) {
10430
10642
  throw new UsageError(`${dir} has no recording.webm \u2014 re-run record`);
10431
10643
  }
10432
10644
  const studio = (strFlag(flags, "studio") ?? "http://localhost:6060").replace(
@@ -10467,12 +10679,12 @@ async function cmdJudge(argv) {
10467
10679
  "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
10680
  );
10469
10681
  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))
10682
+ const kitFile = target.endsWith("kit.json") ? target : join18(target, "kit.json");
10683
+ if (!existsSync15(kitFile)) throw new UsageError(`${kitFile}: no kit manifest`);
10684
+ if (!existsSync15(against))
10473
10685
  throw new UsageError(`${against}: no reference manifest`);
10474
10686
  const result = await judgeKit(kitFile, against, strFlag(flags, "out"));
10475
- const verdicts = JSON.parse(await readFile14(result.verdictFile, "utf8")).verdicts;
10687
+ const verdicts = JSON.parse(await readFile15(result.verdictFile, "utf8")).verdicts;
10476
10688
  const rate = winRate(verdicts);
10477
10689
  const lines = result.sheets.map(
10478
10690
  (s) => `${s.asset} vs ${s.reference}: ${s.sheetA}, ${s.sheetB}, ${s.rubric}`
@@ -10487,12 +10699,109 @@ Win rate so far: ${rate.wins} win(s), ${rate.ties} tie(s) of ${rate.judged} judg
10487
10699
  );
10488
10700
  return EXIT_OK;
10489
10701
  }
10702
+ async function cmdCallout(argv) {
10703
+ const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
10704
+ const dir = positionals[0];
10705
+ const shape = positionals[1];
10706
+ if (!dir || !shape || !isCalloutShape(shape))
10707
+ throw new UsageError(
10708
+ '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]'
10709
+ );
10710
+ const r = createReporter(flags.json === true);
10711
+ const take = await loadTake(dir);
10712
+ if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
10713
+ const brand = await readBrandBesideTake(dir, strFlag(flags, "brand"));
10714
+ const bodyPx = strFlag(flags, "body-px");
10715
+ const register = registerFrom(brand?.roles ?? null, {
10716
+ ground: strFlag(flags, "ground"),
10717
+ accent: strFlag(flags, "accent"),
10718
+ face: strFlag(flags, "font"),
10719
+ mono: strFlag(flags, "mono"),
10720
+ body: bodyPx !== void 0 ? Number(bodyPx) : void 0
10721
+ });
10722
+ if (!register)
10723
+ throw new UsageError(
10724
+ `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`
10725
+ );
10726
+ const codeFile = strFlag(flags, "code-file");
10727
+ const code = codeFile ? await readFile15(codeFile, "utf8") : strFlag(flags, "code");
10728
+ const words = {
10729
+ kicker: strFlag(flags, "kicker"),
10730
+ title: strFlag(flags, "title"),
10731
+ body: strFlag(flags, "body"),
10732
+ code: code?.replace(/\\n/g, "\n")
10733
+ };
10734
+ if (shape === "code" && !words.code)
10735
+ throw new UsageError(
10736
+ 'a code callout needs --code "\u2026" or --code-file <file>'
10737
+ );
10738
+ if (shape === "note" && !words.title)
10739
+ throw new UsageError('a note needs --title "\u2026"');
10740
+ if (shape === "tag" && !words.kicker && !words.title)
10741
+ throw new UsageError('a tag needs --kicker "\u2026"');
10742
+ const stepRaw = strFlag(flags, "step");
10743
+ const atRaw = strFlag(flags, "at");
10744
+ const secondsRaw = strFlag(flags, "seconds");
10745
+ const side = strFlag(flags, "side");
10746
+ const mark = strFlag(flags, "mark");
10747
+ let composed;
10748
+ try {
10749
+ composed = composeCallout(take.doc, register, {
10750
+ shape,
10751
+ words,
10752
+ id: strFlag(flags, "id"),
10753
+ step: stepRaw !== void 0 ? /^\d+$/.test(stepRaw) ? Number(stepRaw) : stepRaw : void 0,
10754
+ at: atRaw !== void 0 ? Number(atRaw) : void 0,
10755
+ seconds: secondsRaw !== void 0 ? Number(secondsRaw) : void 0,
10756
+ side,
10757
+ mark,
10758
+ leader: flags.leader === true,
10759
+ color: strFlag(flags, "color")
10760
+ });
10761
+ } catch (e) {
10762
+ throw new UsageError(e instanceof Error ? e.message : String(e));
10763
+ }
10764
+ const { clip, window: window2 } = composed;
10765
+ if (flags.print === true) {
10766
+ r.done(
10767
+ { clip, window: window2, register, brand: brand?.file ?? null },
10768
+ JSON.stringify(clip, null, 2)
10769
+ );
10770
+ return EXIT_OK;
10771
+ }
10772
+ const doc = take.doc;
10773
+ const overlays = (doc.overlays ?? []).filter((o) => o.id !== clip.id);
10774
+ const next = { ...doc, overlays: [...overlays, clip] };
10775
+ const lint = lintDoc(next);
10776
+ if (lint.problems.length) {
10777
+ r.done(
10778
+ { valid: false, problems: lint.problems, warnings: lint.warnings, clip },
10779
+ `the callout would not validate:
10780
+ ${lint.problems.join("\n ")}`
10781
+ );
10782
+ return EXIT_ERROR;
10783
+ }
10784
+ await writeJson(take.paths.doc, next, true);
10785
+ r.done(
10786
+ {
10787
+ clip,
10788
+ window: window2,
10789
+ register,
10790
+ brand: brand?.file ?? null,
10791
+ warnings: lint.warnings
10792
+ },
10793
+ `${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 ? `
10794
+ warnings:
10795
+ ${lint.warnings.join("\n ")}` : ""}`
10796
+ );
10797
+ return EXIT_OK;
10798
+ }
10490
10799
  async function cmdValidate(argv) {
10491
10800
  const { positionals, flags } = parseArgs(argv, BOOLEAN_FLAGS5);
10492
10801
  const target = positionals[0];
10493
10802
  if (!target) throw new UsageError("vos validate <actions.json|take>");
10494
10803
  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;
10804
+ const kitTarget = target.endsWith("kit.json") ? target : existsSync15(join18(target, "kit.json")) && !isTakeDir(target) ? join18(target, "kit.json") : null;
10496
10805
  if (kitTarget) {
10497
10806
  const picture = flags.picture === true;
10498
10807
  const verdict = await validateKit(kitTarget, { picture });
@@ -10524,9 +10833,9 @@ async function cmdValidate(argv) {
10524
10833
  r.done({ valid: true, target }, `${target}: valid actions file`);
10525
10834
  return EXIT_OK;
10526
10835
  }
10527
- if (!isTakeDir(target) && existsSync14(join17(target, "config.json"))) {
10836
+ if (!isTakeDir(target) && existsSync15(join18(target, "config.json"))) {
10528
10837
  const parsed = JSON.parse(
10529
- await readFile14(join17(target, "config.json"), "utf8")
10838
+ await readFile15(join18(target, "config.json"), "utf8")
10530
10839
  );
10531
10840
  const pre = preflightConfig(parsed);
10532
10841
  const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
@@ -10567,7 +10876,7 @@ async function cmdValidate(argv) {
10567
10876
  const take = await loadTake(target);
10568
10877
  const problems = [];
10569
10878
  const warnings = [];
10570
- if (!existsSync14(take.paths.recording))
10879
+ if (!existsSync15(take.paths.recording))
10571
10880
  problems.push("missing recording.webm (re-run record)");
10572
10881
  if (!take.doc) problems.push("missing doc.json (run plan)");
10573
10882
  if (take.doc) {
@@ -10579,6 +10888,36 @@ async function cmdValidate(argv) {
10579
10888
  );
10580
10889
  problems.push(...probe.problems);
10581
10890
  warnings.push(...probe.warnings);
10891
+ if (flags.picture === true && (take.doc.overlays ?? []).some((o) => o.kind === "html")) {
10892
+ const doc = take.doc;
10893
+ const times = [
10894
+ ...new Set(
10895
+ (doc.overlays ?? []).filter((o) => o.kind === "html").map((o) => +(o.start + Math.min(0.35, o.duration / 2)).toFixed(3))
10896
+ )
10897
+ ];
10898
+ const browser = await launchBrowser();
10899
+ try {
10900
+ const frames = await footageFrames(
10901
+ browser,
10902
+ target,
10903
+ doc,
10904
+ times,
10905
+ framesTake
10906
+ );
10907
+ const findings = await groundFindings(
10908
+ doc,
10909
+ async (t) => frames.get(+t.toFixed(3)) ?? null
10910
+ );
10911
+ for (const f of findings) {
10912
+ if (f.level === "problem") problems.push(`doc.json: ${f.message}`);
10913
+ else if (f.level === "warning")
10914
+ warnings.push(`doc.json: ${f.message}`);
10915
+ else r.event({ event: "ground", ...f });
10916
+ }
10917
+ } finally {
10918
+ await browser.close();
10919
+ }
10920
+ }
10582
10921
  }
10583
10922
  if (problems.length) {
10584
10923
  r.done(
@@ -10695,6 +11034,8 @@ async function run(argv) {
10695
11034
  return await cmdOpen(rest);
10696
11035
  case "validate":
10697
11036
  return await cmdValidate(rest);
11037
+ case "callout":
11038
+ return await cmdCallout(rest);
10698
11039
  case "judge":
10699
11040
  return await cmdJudge(rest);
10700
11041
  case "fetch":
@@ -10778,4 +11119,4 @@ export {
10778
11119
  convertAgentBrowser,
10779
11120
  run
10780
11121
  };
10781
- //# sourceMappingURL=chunk-QJORXTJM.js.map
11122
+ //# sourceMappingURL=chunk-6O55OD6D.js.map