@vosjs/cli 0.79.0 → 0.80.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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ BROWSER_SWITCHES,
3
4
  BrowserUnavailableError,
4
5
  configDuration,
5
6
  directoryKind,
@@ -9,10 +10,12 @@ import {
9
10
  reencodeStill,
10
11
  renderStill,
11
12
  videoFormat
12
- } from "./chunk-Z6TLHRFQ.js";
13
+ } from "./chunk-B373NVJE.js";
13
14
  import {
14
15
  PREV_DOC_NAME,
16
+ PasteFileRefusal,
15
17
  RECORDING_NAME,
18
+ claimablePasteFiles,
16
19
  docAudioRefs,
17
20
  docLayerRefs,
18
21
  docMediaRefs,
@@ -30,8 +33,10 @@ import {
30
33
  pullMedia,
31
34
  readJson,
32
35
  readMediaHead,
36
+ readPasteFiles,
33
37
  recordHostedMedia,
34
38
  safePathName,
39
+ sendPasteFiles,
35
40
  sourceSidecarRefs,
36
41
  takeMediaFile,
37
42
  takePaths,
@@ -40,7 +45,7 @@ import {
40
45
  uploadDocRefs,
41
46
  uploadManifest,
42
47
  writeJson
43
- } from "./chunk-4ZUHEXUX.js";
48
+ } from "./chunk-PJ6MEJZV.js";
44
49
  import {
45
50
  CREDENTIALS_PATH,
46
51
  MEDIA_HEAD_BYTES,
@@ -87,7 +92,7 @@ import {
87
92
  var absolute = (origin, url) => typeof url === "string" && url ? /^https?:/.test(url) ? url : `${origin}${url.startsWith("/") ? "" : "/"}${url}` : null;
88
93
  async function waitForLanded(opts) {
89
94
  const origin = opts.origin.replace(/\/+$/, "");
90
- const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
95
+ const sleep = opts.sleep ?? ((ms2) => new Promise((r) => setTimeout(r, ms2)));
91
96
  const now = opts.now ?? (() => Date.now());
92
97
  const timeoutMs = opts.timeoutMs ?? 3e5;
93
98
  const intervalMs = opts.intervalMs ?? 4e3;
@@ -139,12 +144,12 @@ function landedLines(l) {
139
144
  }
140
145
 
141
146
  // src/plugin/run.ts
142
- import { mkdir as mkdir13, readFile as readFile16, rm as rm6 } from "fs/promises";
143
- import { existsSync as existsSync28 } from "fs";
144
- import { dirname as dirname11, join as join31, resolve as resolve19 } from "path";
147
+ import { mkdir as mkdir13, readFile as readFile18, rm as rm6 } from "fs/promises";
148
+ import { existsSync as existsSync29 } from "fs";
149
+ import { dirname as dirname11, join as join33, resolve as resolve20 } from "path";
145
150
  import {
146
151
  END_CARD_FROM as END_CARD_FROM2,
147
- docOutputDuration as docOutputDuration4,
152
+ docOutputDuration as docOutputDuration5,
148
153
  migrateHostedDoc as migrateHostedDoc4
149
154
  } from "@vosjs/studio-core";
150
155
 
@@ -186,6 +191,24 @@ function createReporter(json) {
186
191
 
187
192
  // src/plugin/actions.ts
188
193
  import { validateActions } from "@vosjs/render-core/record";
194
+ function jsSyntaxProblems(actions) {
195
+ const out = [];
196
+ const AsyncFunction = Object.getPrototypeOf(async function() {
197
+ }).constructor;
198
+ actions.steps.forEach((s, i) => {
199
+ const js = s.do === "expect" ? s.js : s.do === "await" ? s.until.js : void 0;
200
+ if (typeof js !== "string") return;
201
+ try {
202
+ new AsyncFunction(`return await (${js}
203
+ )`);
204
+ } catch (e) {
205
+ out.push(
206
+ `steps[${i}]: ${s.do === "await" ? "await until.js" : "expect js"} does not parse as an expression (${e instanceof Error ? e.message : String(e)})`
207
+ );
208
+ }
209
+ });
210
+ return out;
211
+ }
189
212
 
190
213
  // src/plugin/validateDoc.ts
191
214
  import {
@@ -226,7 +249,9 @@ import {
226
249
  formatElapsed,
227
250
  hasElapsed,
228
251
  outputEnd,
252
+ layerStart,
229
253
  resolveAnchor,
254
+ resolveOverlayAnchors,
230
255
  pinBox,
231
256
  pinCandidates,
232
257
  pinRectOnScreen,
@@ -238,6 +263,9 @@ import {
238
263
  snapRunWeight,
239
264
  RUN_STYLE_KEYS,
240
265
  ratedSegments,
266
+ replayedSeconds,
267
+ cameraSpanExtents,
268
+ spanOutputExtents,
241
269
  clipGain,
242
270
  recommendedExportResolution,
243
271
  resolvePins,
@@ -456,7 +484,7 @@ function lintLoudness(a, name, problems, warnings) {
456
484
  `${name}: normalize ${a.normalize} asks for more than +24 dB, the most a target lifts; it plays under its target`
457
485
  );
458
486
  }
459
- function checkSpanList(list, label, duration, problems) {
487
+ function checkSpanList(list, label, duration, problems, opts = {}) {
460
488
  const sorted = [...list].sort(
461
489
  (a, b) => (isNum(a.in) ? a.in : 0) - (isNum(b.in) ? b.in : 0)
462
490
  );
@@ -480,6 +508,7 @@ function checkSpanList(list, label, duration, problems) {
480
508
  );
481
509
  }
482
510
  }
511
+ if (opts.overlap) return;
483
512
  for (let i = 1; i < sorted.length; i++) {
484
513
  const prev = sorted[i - 1];
485
514
  const curr = sorted[i];
@@ -530,11 +559,11 @@ function lintDoc(docIn) {
530
559
  `media[${i}]${id ? ` (${id})` : ""}: videoKey must be a string`
531
560
  );
532
561
  const mm = isObj(m.meta) ? m.meta : {};
533
- const ms = isNum(mm.durationMs) ? mm.durationMs : 0;
534
- if (id) mediaLen.set(id, ms / 1e3);
562
+ const ms2 = isNum(mm.durationMs) ? mm.durationMs : 0;
563
+ if (id) mediaLen.set(id, ms2 / 1e3);
535
564
  }
536
565
  }
537
- const checkByMedia = (list, label) => {
566
+ const checkByMedia = (list, label, opts = {}) => {
538
567
  const groups = /* @__PURE__ */ new Map();
539
568
  for (const s of list) {
540
569
  const m = typeof s.media === "string" ? s.media : "";
@@ -551,7 +580,8 @@ function lintDoc(docIn) {
551
580
  group,
552
581
  label,
553
582
  m ? mediaLen.get(m) ?? 0 : duration,
554
- problems
583
+ problems,
584
+ opts
555
585
  );
556
586
  };
557
587
  if (!recording) {
@@ -623,7 +653,7 @@ function lintDoc(docIn) {
623
653
  } else {
624
654
  const rated = ratedSegments(doc);
625
655
  const end = outputEnd(
626
- doc,
656
+ resolveOverlayAnchors(doc, rated),
627
657
  rated.reduce((a, s) => a + (s.out - s.in) / (s.rate ?? 1), 0)
628
658
  );
629
659
  if (st > end + 1e-6)
@@ -705,7 +735,63 @@ function lintDoc(docIn) {
705
735
  if (doc.segments !== void 0 && !Array.isArray(doc.segments)) {
706
736
  problems.push("segments must be an array of {in, out} spans");
707
737
  }
708
- checkByMedia(entries(doc.segments), "segment");
738
+ checkByMedia(entries(doc.segments), "segment", { overlap: true });
739
+ {
740
+ const index = [];
741
+ const pieces = [];
742
+ for (const [i, seg] of entries(doc.segments).entries()) {
743
+ if (!isNum(seg.in) || !isNum(seg.out)) continue;
744
+ index.push(i);
745
+ pieces.push({
746
+ in: seg.in,
747
+ out: seg.out,
748
+ ...typeof seg.media === "string" ? { media: seg.media } : {}
749
+ });
750
+ }
751
+ const bySegment = /* @__PURE__ */ new Map();
752
+ for (const r of replayedSeconds(pieces)) {
753
+ const list = bySegment.get(r.piece) ?? [];
754
+ list.push(r);
755
+ bySegment.set(r.piece, list);
756
+ }
757
+ for (const [p, spans] of bySegment) {
758
+ const m = spans[0].media;
759
+ const which = spans.map((r) => `${r.in.toFixed(2)}\u2013${r.out.toFixed(2)}s`).join(", ");
760
+ const segs2 = entries(doc.segments);
761
+ const rests = segs2[index[p]]?.camera === "rest";
762
+ const restedBefore = pieces.map((q, k) => ({ q, k })).filter(
763
+ ({ q, k }) => k < p && (q.media ?? "") === m && segs2[index[k]]?.camera === "rest" && spans.some((r) => Math.min(q.out, r.out) > Math.max(q.in, r.in))
764
+ ).map(({ k }) => `segments[${index[k]}]`);
765
+ const what = `segments[${index[p]}] replays ${which} of ${m ? `media "${m}"` : "the recording"}`;
766
+ notes.push(
767
+ rests ? `${what} with its camera at rest: clicks, freezes and speed spans over those seconds play again, zooms, tilts and cam moves do not` : restedBefore.length ? `${what}, which ${restedBefore.join(" and ")} played with the camera at rest: clicks, freezes and speed spans over those seconds play in every occurrence, zooms, tilts and cam moves only in segments[${index[p]}]` : `${what}: zooms, tilts, clicks, freezes and speed spans over those seconds play in every occurrence`
768
+ );
769
+ }
770
+ }
771
+ const segList = entries(doc.segments);
772
+ segList.forEach((seg, i) => {
773
+ if (seg.camera !== void 0 && seg.camera !== "rest")
774
+ problems.push(
775
+ `segments[${i}].camera must be "rest" (the camera rests through this clip's pass); absent = the camera plays here`
776
+ );
777
+ });
778
+ if (segList.some((s) => s.camera === "rest")) {
779
+ const rated = ratedSegments(doc);
780
+ for (const [label, list] of [
781
+ ["zoom", zoom],
782
+ ["tilt", entries(doc.tilt)],
783
+ ["camMotion", entries(doc.camMotion)]
784
+ ]) {
785
+ for (const s of list) {
786
+ if (!isNum(s.in) || !isNum(s.out)) continue;
787
+ const media = typeof s.media === "string" ? s.media : void 0;
788
+ if (spanOutputExtents(rated, s.in, s.out, media).length && !cameraSpanExtents(rated, s.in, s.out, media).length)
789
+ warnings.push(
790
+ `${spanName(label, s)}: it plays only in segments whose camera rests (segments[].camera: "rest"), so it never moves the camera`
791
+ );
792
+ }
793
+ }
794
+ }
709
795
  const segs = Array.isArray(doc.segments) ? doc.segments.filter(isObj) : [];
710
796
  segs.forEach((seg, i) => {
711
797
  lintAnim(
@@ -1133,8 +1219,10 @@ function lintDoc(docIn) {
1133
1219
  `${name}.kind must be "text" | "image" | "video" | "html" (got ${String(o.kind)})`
1134
1220
  );
1135
1221
  }
1136
- if (!isNum(o.start) || o.start < 0)
1137
- problems.push(`${name}.start must be \u2265 0 (OUTPUT seconds)`);
1222
+ if (o.anchor === void 0 || o.start !== void 0) {
1223
+ if (!isNum(o.start) || o.start < 0)
1224
+ problems.push(`${name}.start must be \u2265 0 (OUTPUT seconds)`);
1225
+ }
1138
1226
  if (!isNum(o.duration) || o.duration <= 0) {
1139
1227
  problems.push(`${name}.duration must be > 0 (seconds)`);
1140
1228
  }
@@ -1730,6 +1818,24 @@ function lintDoc(docIn) {
1730
1818
  framingWarnings(docIn, doc, zoom, overlays ?? [], warnings);
1731
1819
  return { problems, warnings, ...notes.length ? { notes } : {} };
1732
1820
  }
1821
+ function otherSceneAt(doc, start, anchorMedia) {
1822
+ const segs = Array.isArray(doc.segments) ? doc.segments : [];
1823
+ if (!segs.length) return null;
1824
+ const own = typeof anchorMedia === "string" ? anchorMedia : "";
1825
+ const mediaOf = (i) => segs[i]?.media === void 0 ? "" : String(segs[i].media);
1826
+ const ext2 = segmentOutputExtents(doc);
1827
+ const EPS2 = 1e-3;
1828
+ const at2 = (i) => start >= ext2[i].start - EPS2 && start < ext2[i].end - EPS2;
1829
+ if (ext2.some((_, i) => mediaOf(i) === own && at2(i))) return null;
1830
+ const index = ext2.findIndex((_, i) => mediaOf(i) !== own && at2(i));
1831
+ if (index < 0) return null;
1832
+ const before = ext2.filter((e, i) => mediaOf(i) === own && e.end <= start + EPS2).map((e) => e.end);
1833
+ return {
1834
+ index,
1835
+ media: mediaOf(index),
1836
+ ownEnd: before.length ? Math.max(...before) : null
1837
+ };
1838
+ }
1733
1839
  function checkOverlayAnchor(o, name, docIn, recording, pieces, problems, warnings, notes) {
1734
1840
  const fallback = isNum(o.start) ? o.start : null;
1735
1841
  const words2 = o.kind === "text" ? o.text : void 0;
@@ -1765,7 +1871,14 @@ function checkOverlayAnchor(o, name, docIn, recording, pieces, problems, warning
1765
1871
  problems.push(`${name}.anchor.offset must be a number (OUTPUT seconds)`);
1766
1872
  shaped = false;
1767
1873
  }
1768
- if (!shaped || !recording || !pieces) return fallback;
1874
+ if (!shaped) return fallback;
1875
+ if (!recording || !pieces) {
1876
+ if (fallback === null)
1877
+ problems.push(
1878
+ recording ? `${name}.anchor: the cut could not be read to place step ${JSON.stringify(a.step)}, and the layer has no start to fall back to` : `${name}.anchor: names step ${JSON.stringify(a.step)}, but a program document has no recorder steps, and the layer has no start to fall back to; give it a start`
1879
+ );
1880
+ return fallback;
1881
+ }
1769
1882
  const r = resolveAnchor(
1770
1883
  docIn,
1771
1884
  a,
@@ -1775,9 +1888,21 @@ function checkOverlayAnchor(o, name, docIn, recording, pieces, problems, warning
1775
1888
  const at2 = fallback === null ? "nowhere" : `${fallback.toFixed(2)}s`;
1776
1889
  if (r.start === null) {
1777
1890
  const why = r.miss === "media" ? `media "${String(a.media)}" is not one of media[].id` : r.miss === "step" ? `${step} is not in its meta.steps` : r.miss === "skipped" ? `${step} was skipped by the recorder` : `the cut leaves ${step} out`;
1778
- warnings.push(
1779
- `${name}.anchor: ${why}, so it plays at its fallback start (${at2}); re-anchor it, or keep the footage that step plays in`
1780
- );
1891
+ if (fallback === null)
1892
+ problems.push(
1893
+ `${name}.anchor: ${why}, and the layer has no start to fall back to, so it would play nowhere; re-anchor it, keep the footage that step plays in, or give it a start`
1894
+ );
1895
+ else
1896
+ warnings.push(
1897
+ `${name}.anchor: ${why}, so it plays at its fallback start (${at2}); re-anchor it, or keep the footage that step plays in`
1898
+ );
1899
+ }
1900
+ if (r.start !== null) {
1901
+ const over = otherSceneAt(docIn, r.start, a.media);
1902
+ if (over)
1903
+ warnings.push(
1904
+ `${name}${typeof o.id === "string" ? ` (${o.id})` : ""}: anchored to ${step}, it starts at ${r.start.toFixed(2)}s, ${over.ownEnd === null ? "where" : `after that media's footage there ends (${over.ownEnd.toFixed(2)}s), where`} segments[${over.index}] plays ${over.media ? `media "${over.media}"` : "the recording"}: it speaks over another scene. Anchor it to the step's start (anchor.at: "start"), lower anchor.offset, or keep more of the footage it is about`
1905
+ );
1781
1906
  }
1782
1907
  if (elapsed && r.elapsedFrom && r.elapsedMs !== void 0)
1783
1908
  notes.push(
@@ -1976,7 +2101,9 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1976
2101
  const zoomTrack = docZoomTrack(docIn);
1977
2102
  const pins = resolvePins(docIn, layout, camera, zoomTrack);
1978
2103
  overlays.forEach((o, i) => {
1979
- if (!isObj(o) || !isNum(o.start) || !isNum(o.duration)) return;
2104
+ if (!isObj(o) || !isNum(o.duration)) return;
2105
+ const start = o.anchor ? layerStart(o, docIn, rated) : isNum(o.start) ? o.start : null;
2106
+ if (start === null) return;
1980
2107
  const tf = isObj(o.transform) ? o.transform : null;
1981
2108
  if (!tf || !isNum(tf.x) || !isNum(tf.y)) return;
1982
2109
  const pinned = pins.get(String(o.id));
@@ -1996,7 +2123,7 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
1996
2123
  }
1997
2124
  const about = [];
1998
2125
  for (const c of pinCandidates(docIn, {
1999
- start: o.start,
2126
+ start,
2000
2127
  duration: o.duration
2001
2128
  })) {
2002
2129
  const named = c.selector ? selectorLabelWords(c.selector).filter((x) => words2.has(x)) : [];
@@ -2064,7 +2191,7 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
2064
2191
  );
2065
2192
  for (const { c, key, ev } of presses) {
2066
2193
  const out = spanOutputExtent(rated, c.t, c.t + 1e-3, key || void 0);
2067
- if (!out || out.start < o.start || out.start > o.start + o.duration)
2194
+ if (!out || out.start < start || out.start > start + o.duration)
2068
2195
  continue;
2069
2196
  const target = pinRectOnScreen(
2070
2197
  {
@@ -2084,8 +2211,8 @@ function framingWarnings(docIn, doc, zoom, overlays, warnings) {
2084
2211
  w: box.w,
2085
2212
  h: box.h
2086
2213
  };
2087
- const overlaps2 = L.x < target.x + target.w && L.x + L.w > target.x && L.y < target.y + target.h && L.y + L.h > target.y;
2088
- if (overlaps2) {
2214
+ const overlaps3 = L.x < target.x + target.w && L.x + L.w > target.x && L.y < target.y + target.h && L.y + L.h > target.y;
2215
+ if (overlaps3) {
2089
2216
  warnings.push(
2090
2217
  `overlays[${i}] sits over the thing being clicked at ${c.t.toFixed(1)}s (output ${out.start.toFixed(1)}s) \u2014 pin it to that step (pin: { step }) so it sits beside the target and follows it, or move it off`
2091
2218
  );
@@ -2193,7 +2320,8 @@ import {
2193
2320
  planForDigest as planForDigest2,
2194
2321
  ratedSegments as ratedSegments2,
2195
2322
  resolveExportSize,
2196
- spanOutputExtent as spanOutputExtent2
2323
+ cameraSpanExtents as cameraSpanExtents2,
2324
+ spanOutputExtents as spanOutputExtents2
2197
2325
  } from "@vosjs/studio-core";
2198
2326
 
2199
2327
  // src/plugin/server.ts
@@ -2300,8 +2428,8 @@ function startTakeServer(rootDir, pages) {
2300
2428
  const size = statSync(file).size;
2301
2429
  const buf = readFileSync(file);
2302
2430
  const type = sniffMediaType(buf.subarray(0, MEDIA_HEAD_BYTES)) ?? MIME[extname(file)] ?? "application/octet-stream";
2303
- const range = req.headers.range;
2304
- const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
2431
+ const range2 = req.headers.range;
2432
+ const m = range2 && /^bytes=(\d*)-(\d*)$/.exec(range2);
2305
2433
  if (m) {
2306
2434
  const start = m[1] ? parseInt(m[1], 10) : 0;
2307
2435
  const end = m[2] ? Math.min(parseInt(m[2], 10), size - 1) : size - 1;
@@ -3234,7 +3362,7 @@ async function digestTake(browser, dir, opts = {}) {
3234
3362
  });
3235
3363
  await page.addInitScript(() => {
3236
3364
  ;
3237
- globalThis.__name = (f2) => f2;
3365
+ globalThis.__name = (f3) => f3;
3238
3366
  });
3239
3367
  await page.goto(`${server.base}/digest.html`);
3240
3368
  await page.waitForFunction("window.__pageReady__ === true");
@@ -3461,24 +3589,25 @@ function zoomApexes(doc) {
3461
3589
  });
3462
3590
  continue;
3463
3591
  }
3464
- const ext = spanOutputExtent2(rated, z.in, z.out, z.media);
3465
- if (!ext) {
3592
+ const exts = cameraSpanExtents2(rated, z.in, z.out, z.media);
3593
+ if (!exts.length) {
3466
3594
  skipped.push({
3467
3595
  id: z.id,
3468
3596
  ...of,
3469
3597
  in: z.in,
3470
3598
  out: z.out,
3471
- reason: "the cut keeps none of it, so it never plays"
3599
+ reason: spanOutputExtents2(rated, z.in, z.out, z.media).length ? "every pass of it plays in a segment whose camera rests" : "the cut keeps none of it, so it never plays"
3472
3600
  });
3473
3601
  continue;
3474
3602
  }
3475
- apexes.push({
3476
- id: z.id,
3477
- ...of,
3478
- time: (ext.start + ext.end) / 2,
3479
- start: ext.start,
3480
- end: ext.end
3481
- });
3603
+ for (const ext2 of exts)
3604
+ apexes.push({
3605
+ id: z.id,
3606
+ ...of,
3607
+ time: (ext2.start + ext2.end) / 2,
3608
+ start: ext2.start,
3609
+ end: ext2.end
3610
+ });
3482
3611
  }
3483
3612
  return { apexes, skipped };
3484
3613
  }
@@ -3966,20 +4095,30 @@ import {
3966
4095
  docOutputDuration,
3967
4096
  dropTemplate,
3968
4097
  endCardClips,
4098
+ hasReplay as hasReplay2,
3969
4099
  migrateMotion,
4100
+ momentOutputs as momentOutputs2,
3970
4101
  ratedSegments as ratedSegments4,
3971
4102
  sameMedia,
3972
- spanOutputExtent as spanOutputExtent4
4103
+ spanOutputExtent as spanOutputExtent3
3973
4104
  } from "@vosjs/studio-core";
3974
4105
 
3975
4106
  // src/plugin/moments.ts
3976
- import { spanOutputExtent as spanOutputExtent3 } from "@vosjs/studio-core";
4107
+ import {
4108
+ hasReplay,
4109
+ longestOutputExtent,
4110
+ momentOutputs,
4111
+ spanOutputExtent as spanOutputExtent2
4112
+ } from "@vosjs/studio-core";
3977
4113
  var STEP_SETTLE_SECONDS = 0.4;
3978
4114
  function stepOutputTime(rated, step, settle2) {
3979
4115
  const src = step.tEnd + settle2;
3980
- const ext = spanOutputExtent3(rated, src, src + 1e-3);
3981
- if (!ext) return null;
3982
- return ext.start;
4116
+ const ext2 = spanOutputExtent2(rated, src, src + 1e-3);
4117
+ if (!ext2) return null;
4118
+ if (!hasReplay(rated)) return ext2.start;
4119
+ const pass = longestOutputExtent(rated, step.tStart, step.tEnd);
4120
+ const after = pass ? momentOutputs(rated, src).find((o) => o >= pass.start - 1e-6) : void 0;
4121
+ return after ?? ext2.start;
3983
4122
  }
3984
4123
 
3985
4124
  // src/plugin/motionPlan.ts
@@ -4049,18 +4188,26 @@ function pickTrack(catalog, ask) {
4049
4188
  const want = ask.trim().toLowerCase();
4050
4189
  return catalog.tracks.find((t) => t.slug.toLowerCase() === want) ?? catalog.tracks.find((t) => (t.mood ?? "").toLowerCase() === want) ?? null;
4051
4190
  }
4052
- function clickTimes(doc, range) {
4191
+ function clickTimes(doc, range2) {
4053
4192
  const rated = ratedSegments4(doc);
4054
- const out = [];
4193
+ const replayed = hasReplay2(rated);
4194
+ const at2 = [];
4055
4195
  for (const e of doc.source.cursor) {
4056
4196
  if (e.type !== "down") continue;
4057
4197
  const src = e.t / 1e3;
4058
- const ext = spanOutputExtent4(rated, src, src + 1e-3);
4059
- if (!ext) continue;
4060
- const t = ext.start;
4061
- if (t < range[0] || t > range[1]) continue;
4198
+ if (replayed) {
4199
+ at2.push(...momentOutputs2(rated, src));
4200
+ continue;
4201
+ }
4202
+ const ext2 = spanOutputExtent3(rated, src, src + 1e-3);
4203
+ if (ext2) at2.push(ext2.start);
4204
+ }
4205
+ if (replayed) at2.sort((a, b) => a - b);
4206
+ const out = [];
4207
+ for (const t of at2) {
4208
+ if (t < range2[0] || t > range2[1]) continue;
4062
4209
  if (out.length && t - out[out.length - 1] < 0.12) continue;
4063
- out.push(+(t - range[0]).toFixed(3));
4210
+ out.push(+(t - range2[0]).toFixed(3));
4064
4211
  }
4065
4212
  return out;
4066
4213
  }
@@ -4102,7 +4249,7 @@ function proposeMotion(input, opts) {
4102
4249
  const notes = [];
4103
4250
  const skipped = [];
4104
4251
  const footage = outputLength(doc);
4105
- const range = [0, footage];
4252
+ const range2 = [0, footage];
4106
4253
  const entrance = launch.entrance;
4107
4254
  delete doc.frame.entrance;
4108
4255
  if (!off(entrance)) {
@@ -4257,7 +4404,7 @@ function proposeMotion(input, opts) {
4257
4404
  }
4258
4405
  const click = catalog?.sfx.find((s) => s.slug === "sfx-click");
4259
4406
  if (click && !doc.source.micKey && !off(launch.clicks)) {
4260
- const times = clickTimes(doc, range);
4407
+ const times = clickTimes(doc, range2);
4261
4408
  for (const [i, t] of times.entries()) {
4262
4409
  clips.push({
4263
4410
  id: `${CLICK_ID_PREFIX}${i}`,
@@ -4460,11 +4607,11 @@ async function deliverTake(browser, dir, opts) {
4460
4607
  );
4461
4608
  continue;
4462
4609
  }
4463
- let range = opts.range;
4610
+ let range2 = opts.range;
4464
4611
  let seconds = videoSeconds;
4465
4612
  if (d.maxSeconds !== void 0 && videoSeconds > d.maxSeconds) {
4466
4613
  if (LOOP_DESTINATIONS.has(d.id) && !opts.range) {
4467
- range = [0, d.maxSeconds];
4614
+ range2 = [0, d.maxSeconds];
4468
4615
  seconds = d.maxSeconds;
4469
4616
  opts.onPhase?.(
4470
4617
  `note: ${label} takes the first ${d.maxSeconds}s of the ${videoSeconds.toFixed(0)}s take (a loop's cap)`
@@ -4484,7 +4631,7 @@ async function deliverTake(browser, dir, opts) {
4484
4631
  height: d.px.h,
4485
4632
  format: "mp4",
4486
4633
  parallel: opts.parallel,
4487
- range,
4634
+ range: range2,
4488
4635
  bitrate,
4489
4636
  overrides: videoOverrides(d),
4490
4637
  onProgress: opts.onProgress
@@ -4571,7 +4718,7 @@ async function browserLogin(origin, r, opts) {
4571
4718
  if (opts.openBrowser && verifyUrlOk(verifyUrl, origin))
4572
4719
  void launchDefaultBrowser(verifyUrl);
4573
4720
  r.log("Waiting for approval\u2026 (Ctrl-C to cancel)");
4574
- const sleep = opts.sleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
4721
+ const sleep = opts.sleep ?? ((ms2) => new Promise((res) => setTimeout(res, ms2)));
4575
4722
  let intervalMs = baseIntervalMs;
4576
4723
  let failures = 0;
4577
4724
  let capNoted = false;
@@ -4673,8 +4820,8 @@ function markExtension(url) {
4673
4820
  const e = m[1].toLowerCase();
4674
4821
  return e === "jpeg" ? "jpg" : e;
4675
4822
  }
4676
- function imageAspect(bytes, ext) {
4677
- if (ext === "svg") {
4823
+ function imageAspect(bytes, ext2) {
4824
+ if (ext2 === "svg") {
4678
4825
  const head = new TextDecoder().decode(bytes.subarray(0, 4096));
4679
4826
  const vb = /viewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(
4680
4827
  head
@@ -4686,11 +4833,11 @@ function imageAspect(bytes, ext) {
4686
4833
  return null;
4687
4834
  }
4688
4835
  const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
4689
- if (ext === "png") {
4836
+ if (ext2 === "png") {
4690
4837
  if (bytes.length < 24 || bytes[0] !== 137 || bytes[1] !== 80) return null;
4691
4838
  return ratio(dv.getUint32(16), dv.getUint32(20));
4692
4839
  }
4693
- if (ext === "webp") {
4840
+ if (ext2 === "webp") {
4694
4841
  if (bytes.length < 30) return null;
4695
4842
  const tag = String.fromCharCode(...bytes.subarray(12, 16));
4696
4843
  if (tag === "VP8X")
@@ -4709,7 +4856,7 @@ function imageAspect(bytes, ext) {
4709
4856
  );
4710
4857
  return null;
4711
4858
  }
4712
- if (ext === "jpg") {
4859
+ if (ext2 === "jpg") {
4713
4860
  let i = 2;
4714
4861
  while (i + 9 < bytes.length) {
4715
4862
  if (bytes[i] !== 255) return null;
@@ -4733,8 +4880,8 @@ async function fetchBrandMarks(takeDir, roles) {
4733
4880
  const one = async (role, name) => {
4734
4881
  const src = (roles[role] ?? "").trim();
4735
4882
  if (!src) return null;
4736
- const ext = markExtension(src);
4737
- if (!ext) {
4883
+ const ext2 = markExtension(src);
4884
+ if (!ext2) {
4738
4885
  out.notes.push(
4739
4886
  `${role}: ${src} is not an svg, png, webp or jpg; the wordmark stands in`
4740
4887
  );
@@ -4757,7 +4904,7 @@ async function fetchBrandMarks(takeDir, roles) {
4757
4904
  );
4758
4905
  return null;
4759
4906
  }
4760
- const aspect = imageAspect(bytes, ext);
4907
+ const aspect = imageAspect(bytes, ext2);
4761
4908
  if (!aspect) {
4762
4909
  out.notes.push(
4763
4910
  `${role}: ${src} has no readable size; the wordmark stands in`
@@ -4765,8 +4912,8 @@ async function fetchBrandMarks(takeDir, roles) {
4765
4912
  return null;
4766
4913
  }
4767
4914
  await mkdir4(join5(takeDir, "brand"), { recursive: true });
4768
- await writeFile6(join5(takeDir, "brand", `${name}.${ext}`), bytes);
4769
- return { key: `/brand/${name}.${ext}`, aspect };
4915
+ await writeFile6(join5(takeDir, "brand", `${name}.${ext2}`), bytes);
4916
+ return { key: `/brand/${name}.${ext2}`, aspect };
4770
4917
  };
4771
4918
  out.light = await one("logoUrl", "mark");
4772
4919
  out.dark = await one("logoOnDarkUrl", "mark-on-dark");
@@ -4829,11 +4976,12 @@ import {
4829
4976
  docCardLayout as docCardLayout2,
4830
4977
  docZoomTrack as docZoomTrack2,
4831
4978
  hexToRgb,
4979
+ layerStart as layerStart2,
4832
4980
  parseCssColour,
4833
4981
  pinBox as pinBox2,
4834
4982
  ratedSegments as ratedSegments5,
4835
4983
  resolvePins as resolvePins2,
4836
- spanOutputExtent as spanOutputExtent5
4984
+ longestOutputExtent as longestOutputExtent2
4837
4985
  } from "@vosjs/studio-core";
4838
4986
 
4839
4987
  // src/plugin/picture.ts
@@ -5238,15 +5386,15 @@ function windowForStep(doc, step, seconds = CALLOUT_DEFAULT_SECONDS) {
5238
5386
  const s = steps.find((x) => x.id !== void 0 && x.id === step) ?? steps.find((x) => String(x.step) === String(step));
5239
5387
  if (!s || s.skipped) return null;
5240
5388
  const rated = ratedSegments5(doc);
5241
- const ext = spanOutputExtent5(rated, s.tStart, s.tEnd);
5242
- if (!ext) return null;
5243
- const at2 = s.do === "click" || s.do === "drag" ? ext.start : ext.end;
5389
+ const ext2 = longestOutputExtent2(rated, s.tStart, s.tEnd);
5390
+ if (!ext2) return null;
5391
+ const at2 = s.do === "click" || s.do === "drag" ? ext2.start : ext2.end;
5244
5392
  const start = +(at2 + CALLOUT_LEAD_SECONDS).toFixed(3);
5245
5393
  let end = start + seconds;
5246
5394
  for (const n of steps) {
5247
5395
  if (n.tStart <= s.tStart) continue;
5248
5396
  if (n.do !== "scroll" && !n.navigated) continue;
5249
- const ne = spanOutputExtent5(rated, n.tStart, n.tEnd);
5397
+ const ne = longestOutputExtent2(rated, n.tStart, n.tEnd);
5250
5398
  if (ne && ne.start > start && ne.start < end) end = ne.start - 0.05;
5251
5399
  }
5252
5400
  return { start, duration: +Math.max(0.5, end - start).toFixed(3) };
@@ -5307,6 +5455,19 @@ function composeCallout(doc, register, ask) {
5307
5455
  function isCalloutShape(s) {
5308
5456
  return CALLOUT_SHAPES.includes(s);
5309
5457
  }
5458
+ function groundTime(start, duration) {
5459
+ return +(start + Math.min(0.35, duration / 2)).toFixed(3);
5460
+ }
5461
+ function groundCheckTimes(doc) {
5462
+ const rated = ratedSegments5(doc);
5463
+ const times = /* @__PURE__ */ new Set();
5464
+ for (const o of doc.overlays ?? []) {
5465
+ if (o.kind !== "html") continue;
5466
+ const start = layerStart2(o, doc, rated);
5467
+ if (start !== null) times.add(groundTime(start, o.duration));
5468
+ }
5469
+ return [...times];
5470
+ }
5310
5471
  async function groundFindings(doc, frameAt) {
5311
5472
  const out = [];
5312
5473
  const overlays = doc.overlays ?? [];
@@ -5314,9 +5475,12 @@ async function groundFindings(doc, frameAt) {
5314
5475
  const camera = cameraModel2(doc.frame);
5315
5476
  const track = docZoomTrack2(doc);
5316
5477
  const pins = resolvePins2(doc, layout, camera, track);
5478
+ const rated = ratedSegments5(doc);
5317
5479
  for (let i = 0; i < overlays.length; i++) {
5318
5480
  const o = overlays[i];
5319
5481
  if (o.kind !== "html") continue;
5482
+ const start = layerStart2(o, doc, rated);
5483
+ if (start === null) continue;
5320
5484
  const card = calloutGroundOf(o.css ?? "");
5321
5485
  if (!card) {
5322
5486
  out.push({
@@ -5330,7 +5494,7 @@ async function groundFindings(doc, frameAt) {
5330
5494
  });
5331
5495
  continue;
5332
5496
  }
5333
- const t = o.start + Math.min(0.35, o.duration / 2);
5497
+ const t = groundTime(start, o.duration);
5334
5498
  const img = await frameAt(t);
5335
5499
  if (!img) {
5336
5500
  out.push({
@@ -5349,7 +5513,7 @@ async function groundFindings(doc, frameAt) {
5349
5513
  let cx = o.transform.x * layout.W;
5350
5514
  let cy = o.transform.y * layout.H;
5351
5515
  if (pinned) {
5352
- const v = sample(pinned.track, t - o.start, lerpArray);
5516
+ const v = sample(pinned.track, t - start, lerpArray);
5353
5517
  cx = v[0] * layout.W;
5354
5518
  cy = v[1] * layout.H;
5355
5519
  }
@@ -5654,161 +5818,13 @@ async function validateKit(kitPath, opts = {}) {
5654
5818
  // src/plugin/recorder.ts
5655
5819
  import { writeFileSync } from "fs";
5656
5820
  import { lstat, mkdir as mkdir5, readFile as readFile8, realpath } from "fs/promises";
5657
- import { join as join8, resolve as resolve6, sep as sep2 } from "path";
5821
+ import { join as join10, resolve as resolve6, sep as sep2 } from "path";
5658
5822
  import {
5659
5823
  fileNameProblem,
5660
5824
  inlineScript,
5661
5825
  loadScriptFiles,
5662
5826
  recordTake as recordTakeCore
5663
5827
  } from "@vosjs/render-core/record";
5664
- function outputsSink(dir) {
5665
- const outDir = join8(dir, "outputs");
5666
- return {
5667
- save: async (name, bytes) => {
5668
- const problem = fileNameProblem(name);
5669
- if (problem) throw new Error(`outputs/${name}: ${problem}`);
5670
- await refuseSymlink(outDir);
5671
- await mkdir5(outDir, { recursive: true });
5672
- await writeFileSafe(join8(outDir, name), bytes);
5673
- }
5674
- };
5675
- }
5676
- var PAGE_SOUND_DIR = "page-sound";
5677
- function pageSoundSink(dir) {
5678
- const soundDir = join8(dir, PAGE_SOUND_DIR);
5679
- return {
5680
- save: async (name, bytes) => {
5681
- const problem = fileNameProblem(name);
5682
- if (problem) throw new Error(`${PAGE_SOUND_DIR}/${name}: ${problem}`);
5683
- await refuseSymlink(soundDir);
5684
- await mkdir5(soundDir, { recursive: true });
5685
- await writeFileSafe(join8(soundDir, name), bytes);
5686
- return `${PAGE_SOUND_DIR}/${name}`;
5687
- }
5688
- };
5689
- }
5690
- function actionsDirFiles(dir) {
5691
- return {
5692
- read: async (name) => {
5693
- const problem = fileNameProblem(name);
5694
- if (problem) throw new Error(problem);
5695
- const file = join8(dir, name);
5696
- if ((await lstat(file)).isSymbolicLink())
5697
- throw new Error(
5698
- `${name} is a symbolic link, which a script never follows`
5699
- );
5700
- const [real, root] = await Promise.all([realpath(file), realpath(dir)]);
5701
- if (!real.startsWith(root + sep2))
5702
- throw new Error(`${name} resolves outside the actions file's directory`);
5703
- return new Uint8Array(await readFile8(real));
5704
- }
5705
- };
5706
- }
5707
- var pasteCopyName = (name) => name.startsWith("paste-") ? name : `paste-${name}`;
5708
- async function keepPasteFiles(actions, actionsDir, takeDir) {
5709
- if (!actions.steps.some((s) => s.do === "paste" && s.file)) return actions;
5710
- const files = actionsDirFiles(actionsDir);
5711
- const steps = await Promise.all(
5712
- actions.steps.map(async (s) => {
5713
- if (s.do !== "paste" || !s.file) return s;
5714
- const name = pasteCopyName(s.file);
5715
- const same = resolve6(actionsDir) === resolve6(takeDir) && name === s.file;
5716
- if (!same)
5717
- await writeFileSafe(join8(takeDir, name), await files.read(s.file));
5718
- return { ...s, file: name };
5719
- })
5720
- );
5721
- return { ...actions, steps };
5722
- }
5723
- async function scriptForHost(actions, actionsDir) {
5724
- const pathsOnly = {
5725
- steps: actions.steps.map(
5726
- (s) => s.do === "paste" ? { do: "wait", ms: 0 } : s
5727
- )
5728
- };
5729
- const files = await loadScriptFiles(pathsOnly, actionsDirFiles(actionsDir));
5730
- if ("problem" in files) throw new Error(files.problem);
5731
- return inlineScript(actions, files);
5732
- }
5733
- async function recordTake(browser, url, actions, paths, log, opts = {}) {
5734
- const dry = opts.dryRun === true;
5735
- const result = await recordTakeCore(
5736
- browser,
5737
- url,
5738
- actions,
5739
- {
5740
- frame: (file, bytes) => {
5741
- writeFileSync(join8(paths.framesDir, file), bytes);
5742
- }
5743
- },
5744
- log,
5745
- {
5746
- ...opts,
5747
- outputs: outputsSink(paths.dir),
5748
- sounds: pageSoundSink(paths.dir),
5749
- ...opts.actionsDir ? { files: actionsDirFiles(opts.actionsDir) } : {},
5750
- env: process.env,
5751
- platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux"
5752
- }
5753
- );
5754
- if (!dry) {
5755
- if (opts.actionsDir)
5756
- result.actions = await keepPasteFiles(
5757
- result.actions,
5758
- opts.actionsDir,
5759
- paths.dir
5760
- );
5761
- await writeJson(paths.cursor, result.events);
5762
- await writeJson(paths.meta, result.meta, true);
5763
- await writeJson(paths.framesIndex, result.frames);
5764
- await writeJson(paths.actions, result.actions, true);
5765
- }
5766
- return result;
5767
- }
5768
-
5769
- // src/plugin/run.ts
5770
- import {
5771
- RecordRefusedError,
5772
- guardVerdict,
5773
- guardedSteps,
5774
- hostedRefusals,
5775
- readyProblem,
5776
- overrunLine
5777
- } from "@vosjs/render-core/record";
5778
-
5779
- // src/plugin/wall.ts
5780
- import {
5781
- WALL_PROBE,
5782
- WallError,
5783
- isIdpHost,
5784
- isSignInPath,
5785
- redirectedAway,
5786
- wallVerdict
5787
- } from "@vosjs/render-core/record";
5788
-
5789
- // src/plugin/exposure.ts
5790
- import {
5791
- EXPOSURE_ADVICE,
5792
- EXPOSURE_MATCHERS_SRC,
5793
- EXPOSURE_PROBE,
5794
- ExposureLog,
5795
- NoticeLog,
5796
- exposureLine,
5797
- maskInitScript,
5798
- noticeAdvice,
5799
- noticeLine
5800
- } from "@vosjs/render-core/record";
5801
-
5802
- // src/plugin/setup.ts
5803
- import {
5804
- SetupEnvError,
5805
- SetupError,
5806
- parseHeaders,
5807
- resolveSetupText,
5808
- runSetup,
5809
- setupEnvNames,
5810
- validateSetup
5811
- } from "@vosjs/render-core/record";
5812
5828
 
5813
5829
  // src/plugin/session.ts
5814
5830
  import { spawn } from "child_process";
@@ -5821,13 +5837,13 @@ import {
5821
5837
  statSync as statSync2
5822
5838
  } from "fs";
5823
5839
  import { homedir, tmpdir } from "os";
5824
- import { join as join10 } from "path";
5840
+ import { join as join9 } from "path";
5825
5841
  import { chromium } from "playwright";
5826
5842
 
5827
5843
  // src/plugin/sessionWindow.ts
5828
5844
  import { execFile as execFile2 } from "child_process";
5829
5845
  import { existsSync as existsSync8 } from "fs";
5830
- import { join as join9 } from "path";
5846
+ import { join as join8 } from "path";
5831
5847
  import { createInterface } from "readline";
5832
5848
  function parseProcessTable(out) {
5833
5849
  const rows = [];
@@ -5863,19 +5879,19 @@ function chromeTree(rows, pid) {
5863
5879
  }
5864
5880
  function readChromeTree(pid) {
5865
5881
  if (process.platform === "win32") return Promise.resolve(null);
5866
- return new Promise((resolve20) => {
5882
+ return new Promise((resolve21) => {
5867
5883
  execFile2(
5868
5884
  "ps",
5869
5885
  ["-A", "-o", "pid=", "-o", "ppid=", "-o", "command="],
5870
5886
  { maxBuffer: 16 * 1024 * 1024, timeout: 5e3 },
5871
5887
  (err, stdout) => {
5872
- if (err) resolve20(null);
5873
- else resolve20(chromeTree(parseProcessTable(stdout), pid));
5888
+ if (err) resolve21(null);
5889
+ else resolve21(chromeTree(parseProcessTable(stdout), pid));
5874
5890
  }
5875
5891
  );
5876
5892
  });
5877
5893
  }
5878
- async function helpersGone(pids, ms = 1e4, sleep = realSleep) {
5894
+ async function helpersGone(pids, ms2 = 1e4, sleep = realSleep) {
5879
5895
  const alive = (p) => {
5880
5896
  try {
5881
5897
  process.kill(p, 0);
@@ -5884,10 +5900,10 @@ async function helpersGone(pids, ms = 1e4, sleep = realSleep) {
5884
5900
  return e.code === "EPERM";
5885
5901
  }
5886
5902
  };
5887
- const until = Date.now() + ms;
5903
+ const until = Date.now() + ms2;
5888
5904
  while (pids.some(alive) && Date.now() < until) await sleep(100);
5889
5905
  }
5890
- var realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
5906
+ var realSleep = (ms2) => new Promise((r) => setTimeout(r, ms2));
5891
5907
  async function waitForFinish(w) {
5892
5908
  const sleep = w.sleep ?? realSleep;
5893
5909
  const pollMs = w.pollMs ?? 1e3;
@@ -5972,7 +5988,7 @@ function enterPressed() {
5972
5988
  } };
5973
5989
  const rl = createInterface({ input: process.stdin });
5974
5990
  const pressed = new Promise(
5975
- (resolve20) => rl.once("line", () => resolve20())
5991
+ (resolve21) => rl.once("line", () => resolve21())
5976
5992
  );
5977
5993
  return { pressed, close: () => rl.close() };
5978
5994
  }
@@ -6004,8 +6020,8 @@ function cookieReport(state, rows, host) {
6004
6020
  }
6005
6021
  function cookieStore(profileDir) {
6006
6022
  for (const p of [
6007
- join9(profileDir, "Default", "Network", "Cookies"),
6008
- join9(profileDir, "Default", "Cookies")
6023
+ join8(profileDir, "Default", "Network", "Cookies"),
6024
+ join8(profileDir, "Default", "Cookies")
6009
6025
  ])
6010
6026
  if (existsSync8(p)) return p;
6011
6027
  return null;
@@ -6061,8 +6077,18 @@ function cookieWatch(profileDir, host, log) {
6061
6077
  };
6062
6078
  }
6063
6079
 
6080
+ // src/plugin/wall.ts
6081
+ import {
6082
+ WALL_PROBE,
6083
+ WallError,
6084
+ isIdpHost,
6085
+ isSignInPath,
6086
+ redirectedAway,
6087
+ wallVerdict
6088
+ } from "@vosjs/render-core/record";
6089
+
6064
6090
  // src/plugin/session.ts
6065
- var SESSIONS_DIR = join10(homedir(), ".config", "vos", "sessions");
6091
+ var SESSIONS_DIR = join9(homedir(), ".config", "vos", "sessions");
6066
6092
  var NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
6067
6093
  function sessionNameVerdict(name) {
6068
6094
  if (!name) return "a session needs a name: --name <app>";
@@ -6071,20 +6097,20 @@ function sessionNameVerdict(name) {
6071
6097
  return null;
6072
6098
  }
6073
6099
  function sessionDir(name) {
6074
- return join10(SESSIONS_DIR, name);
6100
+ return join9(SESSIONS_DIR, name);
6075
6101
  }
6076
6102
  function systemChromePath() {
6077
6103
  const explicit = process.env.VOS_BROWSER_PATH;
6078
6104
  if (explicit) return explicit;
6079
6105
  const candidates = process.platform === "darwin" ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] : process.platform === "win32" ? [
6080
- join10(
6106
+ join9(
6081
6107
  process.env["PROGRAMFILES"] ?? "C:\\Program Files",
6082
6108
  "Google",
6083
6109
  "Chrome",
6084
6110
  "Application",
6085
6111
  "chrome.exe"
6086
6112
  ),
6087
- join10(
6113
+ join9(
6088
6114
  process.env["LOCALAPPDATA"] ?? "",
6089
6115
  "Google",
6090
6116
  "Chrome",
@@ -6135,14 +6161,14 @@ async function openSessionWindow(opts) {
6135
6161
  const child = spawn(exe, args, { stdio: "ignore" });
6136
6162
  let failed = null;
6137
6163
  let code = null;
6138
- const exited = new Promise((resolve20) => {
6164
+ const exited = new Promise((resolve21) => {
6139
6165
  child.once("exit", (c) => {
6140
6166
  code = c;
6141
- resolve20();
6167
+ resolve21();
6142
6168
  });
6143
6169
  child.once("error", (e) => {
6144
6170
  failed = e;
6145
- resolve20();
6171
+ resolve21();
6146
6172
  });
6147
6173
  });
6148
6174
  try {
@@ -6189,6 +6215,7 @@ async function withProfile(dir, fn, opts = {}) {
6189
6215
  ...explicit ? { executablePath: explicit } : { channel: "chrome" },
6190
6216
  // The real keychain: the whole reason a profile Chrome wrote can be read.
6191
6217
  ignoreDefaultArgs: ["--use-mock-keychain"],
6218
+ args: [...BROWSER_SWITCHES],
6192
6219
  ...opts.viewport ? { viewport: opts.viewport, deviceScaleFactor: 1 } : {}
6193
6220
  });
6194
6221
  try {
@@ -6235,7 +6262,7 @@ async function profileVerdict(dir, url) {
6235
6262
  return sessionVerdictOf(null, stranger);
6236
6263
  }
6237
6264
  async function strangerWall(url) {
6238
- const dir = mkdtempSync(join10(tmpdir(), "vos-stranger-"));
6265
+ const dir = mkdtempSync(join9(tmpdir(), "vos-stranger-"));
6239
6266
  try {
6240
6267
  return await withProfile(dir, (ctx) => arrivalWall(ctx, url));
6241
6268
  } finally {
@@ -6298,7 +6325,7 @@ function summaryLine(s) {
6298
6325
  function listSessions() {
6299
6326
  if (!existsSync9(SESSIONS_DIR)) return [];
6300
6327
  return readdirSync(SESSIONS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && NAME.test(d.name)).map((d) => {
6301
- const st = statSync2(join10(SESSIONS_DIR, d.name));
6328
+ const st = statSync2(join9(SESSIONS_DIR, d.name));
6302
6329
  return {
6303
6330
  name: d.name,
6304
6331
  ageDays: Math.floor(
@@ -6319,33 +6346,201 @@ function looksLikeStorageState(value) {
6319
6346
  return Array.isArray(v.cookies) && Array.isArray(v.origins);
6320
6347
  }
6321
6348
 
6322
- // src/plugin/recordingCap.ts
6323
- import {
6324
- capReached,
6325
- cappedLine,
6326
- clampWait,
6327
- formatDurationCap
6328
- } from "@vosjs/render-core/record";
6329
- var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
6330
- function parseHostedCap(body) {
6331
- const limits = body && typeof body === "object" ? body.limits : void 0;
6332
- const cap = limits?.recordingMaxSeconds;
6333
- return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : null;
6349
+ // src/plugin/recorder.ts
6350
+ function outputsSink(dir) {
6351
+ const outDir = join10(dir, "outputs");
6352
+ return {
6353
+ save: async (name, bytes) => {
6354
+ const problem = fileNameProblem(name);
6355
+ if (problem) throw new Error(`outputs/${name}: ${problem}`);
6356
+ await refuseSymlink(outDir);
6357
+ await mkdir5(outDir, { recursive: true });
6358
+ await writeFileSafe(join10(outDir, name), bytes);
6359
+ }
6360
+ };
6334
6361
  }
6335
- async function hostedRecordingCap(origin, key, fetchImpl = fetch) {
6336
- const ctl = new AbortController();
6337
- const timer = setTimeout(() => ctl.abort(), 2e3);
6338
- try {
6339
- const res = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/limits`, {
6340
- signal: ctl.signal,
6341
- headers: bearerFor(key, origin)
6342
- });
6343
- if (!res.ok) return null;
6344
- return parseHostedCap(await res.json());
6345
- } catch {
6346
- return null;
6347
- } finally {
6348
- clearTimeout(timer);
6362
+ var PAGE_SOUND_DIR = "page-sound";
6363
+ function pageSoundSink(dir) {
6364
+ const soundDir = join10(dir, PAGE_SOUND_DIR);
6365
+ return {
6366
+ save: async (name, bytes) => {
6367
+ const problem = fileNameProblem(name);
6368
+ if (problem) throw new Error(`${PAGE_SOUND_DIR}/${name}: ${problem}`);
6369
+ await refuseSymlink(soundDir);
6370
+ await mkdir5(soundDir, { recursive: true });
6371
+ await writeFileSafe(join10(soundDir, name), bytes);
6372
+ return `${PAGE_SOUND_DIR}/${name}`;
6373
+ }
6374
+ };
6375
+ }
6376
+ function actionsDirFiles(dir) {
6377
+ return {
6378
+ read: async (name) => {
6379
+ const problem = fileNameProblem(name);
6380
+ if (problem) throw new Error(problem);
6381
+ const file = join10(dir, name);
6382
+ if ((await lstat(file)).isSymbolicLink())
6383
+ throw new Error(
6384
+ `${name} is a symbolic link, which a script never follows`
6385
+ );
6386
+ const [real, root] = await Promise.all([realpath(file), realpath(dir)]);
6387
+ if (!real.startsWith(root + sep2))
6388
+ throw new Error(`${name} resolves outside the actions file's directory`);
6389
+ return new Uint8Array(await readFile8(real));
6390
+ }
6391
+ };
6392
+ }
6393
+ function peerStateFrom(dir) {
6394
+ return async (name) => {
6395
+ const file = resolve6(dir, name);
6396
+ let raw;
6397
+ try {
6398
+ raw = await readFile8(file, "utf8");
6399
+ } catch {
6400
+ throw new Error(
6401
+ `there is no file at ${file}; a storage state is never copied into a take, so name it by a path from the actions file (or an absolute one, such as one saved under ~/.config/vos)`
6402
+ );
6403
+ }
6404
+ let parsed;
6405
+ try {
6406
+ parsed = JSON.parse(raw);
6407
+ } catch {
6408
+ parsed = null;
6409
+ }
6410
+ if (!looksLikeStorageState(parsed))
6411
+ throw new Error(
6412
+ `${file} is not a Playwright storage state ({ cookies, origins }); save one with context.storageState({ path })`
6413
+ );
6414
+ return file;
6415
+ };
6416
+ }
6417
+ var pasteCopyName = (name) => name.startsWith("paste-") ? name : `paste-${name}`;
6418
+ async function keepPasteFiles(actions, actionsDir, takeDir) {
6419
+ if (!actions.steps.some((s) => s.do === "paste" && s.file)) return actions;
6420
+ const files = actionsDirFiles(actionsDir);
6421
+ const steps = await Promise.all(
6422
+ actions.steps.map(async (s) => {
6423
+ if (s.do !== "paste" || !s.file) return s;
6424
+ const name = pasteCopyName(s.file);
6425
+ const same = resolve6(actionsDir) === resolve6(takeDir) && name === s.file;
6426
+ if (!same)
6427
+ await writeFileSafe(join10(takeDir, name), await files.read(s.file));
6428
+ return { ...s, file: name };
6429
+ })
6430
+ );
6431
+ return { ...actions, steps };
6432
+ }
6433
+ async function scriptForHost(actions, actionsDir) {
6434
+ const pathsOnly = {
6435
+ steps: actions.steps.map(
6436
+ (s) => s.do === "paste" ? { do: "wait", ms: 0 } : s
6437
+ )
6438
+ };
6439
+ const files = await loadScriptFiles(pathsOnly, actionsDirFiles(actionsDir));
6440
+ if ("problem" in files) throw new Error(files.problem);
6441
+ return inlineScript(actions, files);
6442
+ }
6443
+ async function recordTake(browser, url, actions, paths, log, opts = {}) {
6444
+ const dry = opts.dryRun === true;
6445
+ const result = await recordTakeCore(
6446
+ browser,
6447
+ url,
6448
+ actions,
6449
+ {
6450
+ frame: (file, bytes) => {
6451
+ writeFileSync(join10(paths.framesDir, file), bytes);
6452
+ }
6453
+ },
6454
+ log,
6455
+ {
6456
+ ...opts,
6457
+ outputs: outputsSink(paths.dir),
6458
+ sounds: pageSoundSink(paths.dir),
6459
+ ...opts.actionsDir ? {
6460
+ files: actionsDirFiles(opts.actionsDir),
6461
+ peerStorageState: peerStateFrom(opts.actionsDir)
6462
+ } : {},
6463
+ env: process.env,
6464
+ platform: process.platform === "darwin" ? "mac" : process.platform === "win32" ? "windows" : "linux"
6465
+ }
6466
+ );
6467
+ if (!dry) {
6468
+ if (opts.actionsDir)
6469
+ result.actions = await keepPasteFiles(
6470
+ result.actions,
6471
+ opts.actionsDir,
6472
+ paths.dir
6473
+ );
6474
+ await writeJson(paths.cursor, result.events);
6475
+ await writeJson(paths.meta, result.meta, true);
6476
+ await writeJson(paths.framesIndex, result.frames);
6477
+ await writeJson(paths.actions, result.actions, true);
6478
+ }
6479
+ return result;
6480
+ }
6481
+
6482
+ // src/plugin/run.ts
6483
+ import {
6484
+ RecordRefusedError,
6485
+ guardVerdict,
6486
+ guardedSteps,
6487
+ hostedRefusals,
6488
+ readyProblem,
6489
+ overrunLine,
6490
+ sayValue
6491
+ } from "@vosjs/render-core/record";
6492
+
6493
+ // src/plugin/exposure.ts
6494
+ import {
6495
+ EXPOSURE_ADVICE,
6496
+ EXPOSURE_MATCHERS_SRC,
6497
+ EXPOSURE_PROBE,
6498
+ ExposureLog,
6499
+ NoticeLog,
6500
+ exposureLine,
6501
+ maskInitScript,
6502
+ noticeAdvice,
6503
+ noticeLine
6504
+ } from "@vosjs/render-core/record";
6505
+
6506
+ // src/plugin/setup.ts
6507
+ import {
6508
+ SetupEnvError,
6509
+ SetupError,
6510
+ parseHeaders,
6511
+ resolveSetupText,
6512
+ runSetup,
6513
+ setupEnvNames,
6514
+ validateSetup
6515
+ } from "@vosjs/render-core/record";
6516
+
6517
+ // src/plugin/recordingCap.ts
6518
+ import {
6519
+ capReached,
6520
+ cappedLine,
6521
+ clampWait,
6522
+ formatDurationCap
6523
+ } from "@vosjs/render-core/record";
6524
+ var HOSTED_RECORDING_CAP_SECONDS = 30 * 60;
6525
+ function parseHostedCap(body) {
6526
+ const limits = body && typeof body === "object" ? body.limits : void 0;
6527
+ const cap = limits?.recordingMaxSeconds;
6528
+ return typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : null;
6529
+ }
6530
+ async function hostedRecordingCap(origin, key, fetchImpl = fetch) {
6531
+ const ctl = new AbortController();
6532
+ const timer = setTimeout(() => ctl.abort(), 2e3);
6533
+ try {
6534
+ const res = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/limits`, {
6535
+ signal: ctl.signal,
6536
+ headers: bearerFor(key, origin)
6537
+ });
6538
+ if (!res.ok) return null;
6539
+ return parseHostedCap(await res.json());
6540
+ } catch {
6541
+ return null;
6542
+ } finally {
6543
+ clearTimeout(timer);
6349
6544
  }
6350
6545
  }
6351
6546
  function defaultMaxDurationSeconds() {
@@ -6408,12 +6603,30 @@ var AFTER_STEP = 1;
6408
6603
  var LAYER_AFTER_STEP = 5;
6409
6604
  var WINDOW_SLACK = 0.05;
6410
6605
  var LAYER_DRIFT = 0.5;
6411
- function matchStep(old, newSteps) {
6606
+ function matchByNeighbour(old, newSteps, oldSteps) {
6607
+ const named = oldSteps.filter(
6608
+ (s) => s.id && newSteps.some((n) => n.id === s.id && !n.skipped)
6609
+ );
6610
+ const before = named.filter((s) => s.step < old.step).sort((a, b) => b.step - a.step)[0];
6611
+ const after = named.filter((s) => s.step > old.step).sort((a, b) => a.step - b.step)[0];
6612
+ const found = [before, after].flatMap((anchor) => {
6613
+ if (!anchor) return [];
6614
+ const now = newSteps.find((n) => n.id === anchor.id);
6615
+ const hit = newSteps.find(
6616
+ (n) => n.step === now.step + old.step - anchor.step
6617
+ );
6618
+ return hit && !hit.skipped && !hit.id && hit.do === old.do && hit.selector === old.selector ? [hit] : [];
6619
+ });
6620
+ return found.length && found.every((h) => h === found[0]) ? found[0] : null;
6621
+ }
6622
+ function matchStep(old, newSteps, oldSteps = []) {
6412
6623
  const usable = (s) => !!s && s.skipped !== true;
6413
6624
  if (old.id) {
6414
6625
  const byId = newSteps.find((s) => s.id === old.id);
6415
6626
  return usable(byId) ? byId : null;
6416
6627
  }
6628
+ const near2 = matchByNeighbour(old, newSteps, oldSteps);
6629
+ if (near2) return near2;
6417
6630
  const atIndex = newSteps.find((s) => s.step === old.step);
6418
6631
  if (usable(atIndex) && atIndex.do === old.do && atIndex.selector === old.selector) {
6419
6632
  return atIndex;
@@ -6445,7 +6658,7 @@ function buildStepMap(oldSteps, newSteps, oldDuration, newDuration) {
6445
6658
  let matched = 0;
6446
6659
  for (const old of oldSteps) {
6447
6660
  if (old.skipped) continue;
6448
- const hit = matchStep(old, newSteps);
6661
+ const hit = matchStep(old, newSteps, oldSteps);
6449
6662
  if (!hit) {
6450
6663
  unmatched.push(old);
6451
6664
  continue;
@@ -6505,7 +6718,7 @@ function placeMoment(t, anchor, ctx) {
6505
6718
  }
6506
6719
  const under = stepUnder(t, ctx.oldSteps);
6507
6720
  if (under) {
6508
- const hit = matchStep(under, ctx.newSteps);
6721
+ const hit = matchStep(under, ctx.newSteps, ctx.oldSteps);
6509
6722
  if (!hit) return { gone: under };
6510
6723
  ctx.report.anchored++;
6511
6724
  return { at: hit.tStart + (t - under.tStart), via: "step" };
@@ -6660,8 +6873,8 @@ function retimeOverlays(prev, next, report) {
6660
6873
  const newDuration = next.source.meta.durationMs / 1e3;
6661
6874
  const stepMap = buildStepMap(oldSteps, newSteps, oldDuration, newDuration);
6662
6875
  const ctx = { oldSteps, newSteps, stepMap, report };
6663
- const follow = (o, oldRef, newRef) => {
6664
- const offset = o.start - sourceToOutput(oldPieces, oldRef);
6876
+ const follow = (o, from, oldRef, newRef) => {
6877
+ const offset = from - sourceToOutput(oldPieces, oldRef);
6665
6878
  const start = Math.max(0, sourceToOutput(newPieces, newRef) + offset);
6666
6879
  return { ...o, start: +start.toFixed(3) };
6667
6880
  };
@@ -6670,54 +6883,56 @@ function retimeOverlays(prev, next, report) {
6670
6883
  const r = resolveLayerAnchor(next, o.anchor, newPieces);
6671
6884
  if (r.start === null) {
6672
6885
  report.flagged.push(
6673
- `overlay ${o.id}: anchored to step ${String(o.anchor.step)}, which is missing, skipped or cut in the new recording \u2014 it plays at its fallback ${o.start.toFixed(2)}s; re-anchor or re-place it`
6886
+ o.start === void 0 ? `overlay ${o.id}: anchored to step ${String(o.anchor.step)}, which is missing, skipped or cut in the new recording \u2014 it has no start to fall back to, so it plays nowhere; re-anchor it or give it a start` : `overlay ${o.id}: anchored to step ${String(o.anchor.step)}, which is missing, skipped or cut in the new recording \u2014 it plays at its fallback ${o.start.toFixed(2)}s; re-anchor or re-place it`
6674
6887
  );
6675
6888
  return o;
6676
6889
  }
6677
6890
  report.anchored++;
6678
- return { ...o, start: r.start };
6891
+ return o.start === void 0 ? o : { ...o, start: r.start };
6679
6892
  }
6893
+ const at2 = o.start;
6894
+ if (at2 === void 0) return o;
6680
6895
  const pin = o.pin;
6681
6896
  if (pin?.step !== void 0) {
6682
6897
  const was = stepByRef(oldSteps, pin.step);
6683
- const now2 = was ? matchStep(was, newSteps) : stepByRef(newSteps, pin.step);
6898
+ const now2 = was ? matchStep(was, newSteps, oldSteps) : stepByRef(newSteps, pin.step);
6684
6899
  if (!now2 || now2.skipped) {
6685
6900
  report.flagged.push(
6686
- `overlay ${o.id}: pinned to step ${String(pin.step)}, which is missing or skipped in the new recording \u2014 kept at ${o.start.toFixed(2)}s; re-pin or re-place it`
6901
+ `overlay ${o.id}: pinned to step ${String(pin.step)}, which is missing or skipped in the new recording \u2014 kept at ${at2.toFixed(2)}s; re-pin or re-place it`
6687
6902
  );
6688
6903
  return o;
6689
6904
  }
6690
6905
  report.anchored++;
6691
- return follow(o, was?.tStart ?? now2.tStart, now2.tStart);
6906
+ return follow(o, at2, was?.tStart ?? now2.tStart, now2.tStart);
6692
6907
  }
6693
6908
  if (typeof pin?.press === "number") {
6694
6909
  const placed = placeMoment(pin.press, void 0, ctx);
6695
6910
  if ("gone" in placed) {
6696
6911
  report.flagged.push(
6697
- `overlay ${o.id}: pinned to a press at ${pin.press.toFixed(2)}s, and ${goneWords(placed.gone)} \u2014 kept at ${o.start.toFixed(2)}s; re-pin or re-place it`
6912
+ `overlay ${o.id}: pinned to a press at ${pin.press.toFixed(2)}s, and ${goneWords(placed.gone)} \u2014 kept at ${at2.toFixed(2)}s; re-pin or re-place it`
6698
6913
  );
6699
6914
  return o;
6700
6915
  }
6701
- const moved = follow(o, pin.press, placed.at);
6916
+ const moved = follow(o, at2, pin.press, placed.at);
6702
6917
  return { ...moved, pin: { ...pin, press: +placed.at.toFixed(3) } };
6703
6918
  }
6704
6919
  const under = stepUnder(
6705
- outputToSource(oldPieces, o.start),
6920
+ outputToSource(oldPieces, at2),
6706
6921
  oldSteps,
6707
6922
  LAYER_AFTER_STEP
6708
6923
  );
6709
6924
  if (!under) return o;
6710
- const now = matchStep(under, newSteps);
6925
+ const now = matchStep(under, newSteps, oldSteps);
6711
6926
  if (!now) {
6712
6927
  report.flagged.push(
6713
- `overlay ${o.id} (${o.start.toFixed(2)}s): it sat over step ${stepName(under)}, which is missing or skipped in the new recording \u2014 kept at its output time; re-place it`
6928
+ `overlay ${o.id} (${at2.toFixed(2)}s): it sat over step ${stepName(under)}, which is missing or skipped in the new recording \u2014 kept at its output time; re-place it`
6714
6929
  );
6715
6930
  return o;
6716
6931
  }
6717
- const expected = follow(o, under.tStart, now.tStart).start;
6718
- if (Math.abs(expected - o.start) > LAYER_DRIFT) {
6932
+ const expected = follow(o, at2, under.tStart, now.tStart).start;
6933
+ if (Math.abs(expected - at2) > LAYER_DRIFT) {
6719
6934
  report.flagged.push(
6720
- `overlay ${o.id} (${o.start.toFixed(2)}s): it sat over step ${stepName(under)}, which now plays at ${expected.toFixed(2)}s in the cut \u2014 kept at ${o.start.toFixed(2)}s by the output-time contract; pin it to follow (pin: { step: ${JSON.stringify(under.id ?? under.step)} }) or move it`
6935
+ `overlay ${o.id} (${at2.toFixed(2)}s): it sat over step ${stepName(under)}, which now plays at ${expected.toFixed(2)}s in the cut \u2014 kept at ${at2.toFixed(2)}s by the output-time contract; pin it to follow (pin: { step: ${JSON.stringify(under.id ?? under.step)} }) or move it`
6721
6936
  );
6722
6937
  }
6723
6938
  return o;
@@ -6961,14 +7176,14 @@ import { createHash } from "crypto";
6961
7176
  import { existsSync as existsSync12, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
6962
7177
  import { readFile as readFile11 } from "fs/promises";
6963
7178
  import { createInterface as createInterface2 } from "readline/promises";
6964
- import { basename, join as join14 } from "path";
7179
+ import { basename as basename2, join as join14 } from "path";
6965
7180
  import { lowerToComposition as lowerToComposition3, migrateHostedDoc as migrateHostedDoc2 } from "@vosjs/studio-core";
6966
7181
 
6967
7182
  // src/plugin/takeScript.ts
6968
7183
  import { existsSync as existsSync11 } from "fs";
6969
7184
  import { readFile as readFile10, writeFile as writeFile7 } from "fs/promises";
6970
7185
  import { readFileSync as readFileSync2 } from "fs";
6971
- import { join as join12 } from "path";
7186
+ import { basename, join as join12 } from "path";
6972
7187
  function scriptFromText(text) {
6973
7188
  try {
6974
7189
  const v = JSON.parse(text);
@@ -6984,6 +7199,8 @@ function scriptToWrite(doc, hasLocal) {
6984
7199
  `;
6985
7200
  }
6986
7201
  function publicScript(script) {
7202
+ const peers = publicPeers(script.peers);
7203
+ if (peers) script = { ...script, peers };
6987
7204
  const setup = script.setup;
6988
7205
  if (!Array.isArray(setup)) return script;
6989
7206
  return {
@@ -7005,6 +7222,15 @@ function publicScript(script) {
7005
7222
  })
7006
7223
  };
7007
7224
  }
7225
+ function publicPeers(peers) {
7226
+ if (!Array.isArray(peers)) return null;
7227
+ return peers.map((p) => {
7228
+ if (!p || typeof p !== "object") return p;
7229
+ const peer = p;
7230
+ if (typeof peer.storageState !== "string") return peer;
7231
+ return { ...peer, storageState: basename(peer.storageState) };
7232
+ });
7233
+ }
7008
7234
  function embedScript(doc, dir) {
7009
7235
  const path = join12(dir, "actions.json");
7010
7236
  if (!existsSync11(path)) return;
@@ -7018,6 +7244,11 @@ async function writeScriptBeside(dir, doc) {
7018
7244
  await writeFile7(path, text, "utf8");
7019
7245
  return true;
7020
7246
  }
7247
+ async function readScript(dir) {
7248
+ const path = join12(dir, "actions.json");
7249
+ if (!existsSync11(path)) return null;
7250
+ return scriptFromText(await readFile10(path, "utf8"));
7251
+ }
7021
7252
 
7022
7253
  // src/plugin/folder.ts
7023
7254
  import { mkdir as mkdir6 } from "fs/promises";
@@ -7511,6 +7742,10 @@ async function pushTake(dir, flags, r) {
7511
7742
  "no recording in this take \u2014 record it, or `vos pull --media` to bring it home"
7512
7743
  );
7513
7744
  }
7745
+ const pasteFiles = await readPasteFiles(
7746
+ await readScript(dir) ?? take.doc.source.script,
7747
+ dir
7748
+ );
7514
7749
  const ctx = apiContext(flags);
7515
7750
  const tracked = readSyncState(dir);
7516
7751
  const firstPush = tracked === null;
@@ -7567,12 +7802,6 @@ async function pushTake(dir, flags, r) {
7567
7802
  filename: take.paths.recordingName
7568
7803
  });
7569
7804
  const filename = nameForType(take.paths.recordingName, contentType);
7570
- if (filename !== take.paths.recordingName) {
7571
- r.log(
7572
- ` ${take.paths.recordingName} holds ${contentType} \u2014 uploading it as ${filename}`
7573
- );
7574
- }
7575
- r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
7576
7805
  let upload;
7577
7806
  try {
7578
7807
  upload = await uploadAsset(ctx, new Uint8Array(bytes), {
@@ -7581,6 +7810,15 @@ async function pushTake(dir, flags, r) {
7581
7810
  contentHash: hash,
7582
7811
  // The take's length so the server holds it to the plan's cap.
7583
7812
  ...take.meta.durationMs > 0 ? { durationSeconds: take.meta.durationMs / 1e3 } : {},
7813
+ // Said once the platform asks for the bytes: a re-push of a hosted
7814
+ // take sends none, and says only that it reused them.
7815
+ onSending: () => {
7816
+ if (filename !== take.paths.recordingName)
7817
+ r.log(
7818
+ ` ${take.paths.recordingName} holds ${contentType} \u2014 uploading it as ${filename}`
7819
+ );
7820
+ r.log(`uploading recording (${Math.round(bytes.length / 1024)} kB)\u2026`);
7821
+ },
7584
7822
  onPart: (done, total) => r.log(` part ${done}/${total}`)
7585
7823
  });
7586
7824
  } catch (e) {
@@ -7596,6 +7834,7 @@ async function pushTake(dir, flags, r) {
7596
7834
  const docForPush = structuredClone(sendDoc);
7597
7835
  docForPush.source.videoKey = assetUrl;
7598
7836
  embedScript(docForPush, dir);
7837
+ await sendPasteFiles(docForPush, pasteFiles, ctx, (l) => r.log(l));
7599
7838
  await uploadDocRefs(sourceSidecarRefs(docForPush), dir, ctx, (l) => r.log(l));
7600
7839
  for (const field of ["micKey", "camKey"]) {
7601
7840
  const key = docForPush.source[field];
@@ -7610,7 +7849,7 @@ async function pushTake(dir, flags, r) {
7610
7849
  const lowered = lowerToComposition3(docForPush);
7611
7850
  const config = { ...lowered.config, data: lowered.data };
7612
7851
  if (!state) {
7613
- const title = flags.title ?? `Take ${basename(dir)} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
7852
+ const title = flags.title ?? `Take ${basename2(dir)} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
7614
7853
  const slug = `${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "take"}-${Date.now().toString(36)}`;
7615
7854
  const created = await api(ctx, "/vos", {
7616
7855
  method: "POST",
@@ -7797,7 +8036,7 @@ async function pullTake(dir, flags, r) {
7797
8036
  const doc = hostedDoc;
7798
8037
  const localRecording = takeMediaFile(dir, "recording", RECORDING_NAME);
7799
8038
  if (existsSync12(localRecording)) {
7800
- doc.source.videoKey = basename(localRecording);
8039
+ doc.source.videoKey = basename2(localRecording);
7801
8040
  }
7802
8041
  await writeJson(join14(dir, "doc.json"), doc, true);
7803
8042
  const media = flags.media ? await pullMedia(ctx, dir, doc, r.log) : void 0;
@@ -7840,6 +8079,10 @@ async function pushTakeClaimable(dir, flags, r) {
7840
8079
  ${lint.problems.join("\n ")}`
7841
8080
  );
7842
8081
  }
8082
+ const pasteFiles = await readPasteFiles(
8083
+ await readScript(dir) ?? take.doc.source.script,
8084
+ dir
8085
+ );
7843
8086
  const state = readSyncState(dir);
7844
8087
  const hosted = state?.hostedMedia ?? {};
7845
8088
  if (!Object.keys(hosted).length)
@@ -7882,6 +8125,7 @@ async function pushTakeClaimable(dir, flags, r) {
7882
8125
  for (const line of plays.lines) r.log(line);
7883
8126
  const docForPush = structuredClone(plays.doc);
7884
8127
  embedScript(docForPush, dir);
8128
+ await claimablePasteFiles(docForPush, pasteFiles, hosted, (l) => r.log(l));
7885
8129
  for (const ref of claimRefs(docForPush)) {
7886
8130
  const origin2 = hosted[takeRelativeFile(ref.key)];
7887
8131
  if (!origin2)
@@ -7892,7 +8136,7 @@ async function pushTakeClaimable(dir, flags, r) {
7892
8136
  }
7893
8137
  const lowered = lowerToComposition3(docForPush);
7894
8138
  const config = { ...lowered.config, data: lowered.data };
7895
- const title = (flags.title ?? (state?.title ? `${state.title} remix` : basename(dir))).slice(0, 200);
8139
+ const title = (flags.title ?? (state?.title ? `${state.title} remix` : basename2(dir))).slice(0, 200);
7896
8140
  const body = { title, config, doc: docForPush };
7897
8141
  if (state?.vosId) body.remixOfId = state.vosId;
7898
8142
  if (flags.prompt) body.prompt = flags.prompt;
@@ -7919,7 +8163,7 @@ async function pushTakeClaimable(dir, flags, r) {
7919
8163
  import { existsSync as existsSync14 } from "fs";
7920
8164
  import { mkdir as mkdir7, readFile as readFile12 } from "fs/promises";
7921
8165
  import { createInterface as createInterface3 } from "readline/promises";
7922
- import { basename as basename3, dirname as dirname2, join as join15 } from "path";
8166
+ import { basename as basename4, dirname as dirname2, join as join15 } from "path";
7923
8167
  import {
7924
8168
  CURRENT_CONFIG_VERSION,
7925
8169
  migrateConfig,
@@ -7928,7 +8172,7 @@ import {
7928
8172
  import { lowerProgramDoc, migrateHostedDoc as migrateHostedDoc3 } from "@vosjs/studio-core";
7929
8173
 
7930
8174
  // src/plugin/programFiles.ts
7931
- import { basename as basename2, extname as extname2, relative as relative2 } from "path";
8175
+ import { basename as basename3, extname as extname2, relative as relative2 } from "path";
7932
8176
  import { existsSync as existsSync13, realpathSync as realpathSync2 } from "fs";
7933
8177
  function isLocalName(value) {
7934
8178
  if (/^(https?:|data:|blob:|asset:|media:)/.test(value)) return false;
@@ -7939,7 +8183,7 @@ function isLocalName(value) {
7939
8183
  }
7940
8184
  var FILE_EXT = /\.(png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|mp3|m4a|ogg|wav|flac|aac|glb|gltf|woff2?|ttf|otf|hdr|exr)$/i;
7941
8185
  function manifestName(file, taken) {
7942
- const stem = basename2(file, extname2(file)).replace(/[^A-Za-z0-9_]/g, "_").replace(/^(?=[0-9])/, "_") || "file";
8186
+ const stem = basename3(file, extname2(file)).replace(/[^A-Za-z0-9_]/g, "_").replace(/^(?=[0-9])/, "_") || "file";
7943
8187
  let name = stem;
7944
8188
  for (let n = 2; taken.has(name); n++) name = `${stem}_${n}`;
7945
8189
  taken.add(name);
@@ -8358,7 +8602,7 @@ async function cmdPushProgram(argv) {
8358
8602
  "--claimable creates a NEW claimable vos and cannot iterate (--vos). After the human claims it, iterate with their key: vos push --vos <id>"
8359
8603
  );
8360
8604
  }
8361
- const fallback = state?.title ? `${state.title} remix` : basename3(source).replace(/\.json$/i, "") || "vos program";
8605
+ const fallback = state?.title ? `${state.title} remix` : basename4(source).replace(/\.json$/i, "") || "vos program";
8362
8606
  const title2 = (strFlag(flags, "title") ?? fallback).slice(0, 100);
8363
8607
  const files2 = await collectFiles(config, programDoc, dir, r);
8364
8608
  let uploadToken;
@@ -8525,7 +8769,7 @@ ${landedLines(landed2)}` : "")
8525
8769
  return EXIT_OK;
8526
8770
  }
8527
8771
  const remixOfId = pushTarget.kind === "create" ? pushTarget.remixOfId : void 0;
8528
- const fallbackTitle = state?.title ? `${state.title} remix` : basename3(source).replace(/\.json$/i, "") || "vos remix";
8772
+ const fallbackTitle = state?.title ? `${state.title} remix` : basename4(source).replace(/\.json$/i, "") || "vos remix";
8529
8773
  const title = (strFlag(flags, "title") ?? fallbackTitle).slice(0, 100);
8530
8774
  const pushFolder = folderRef ? await resolvePushFolder(origin, key, folderRef) : void 0;
8531
8775
  const folderId = pushFolder?.id;
@@ -8851,7 +9095,7 @@ async function writeProgramDoc(dir, hosted) {
8851
9095
  // src/plugin/asset.ts
8852
9096
  import { createHash as createHash2 } from "crypto";
8853
9097
  import { readFileSync as readFileSync4 } from "fs";
8854
- import { basename as basename4 } from "path";
9098
+ import { basename as basename5 } from "path";
8855
9099
 
8856
9100
  // src/plugin/assetWords.ts
8857
9101
  function hostedFile(raw) {
@@ -9116,7 +9360,7 @@ async function cmdPush(argv) {
9116
9360
  const folder = folderRef ? resolveFolder(await listFolders(origin, key), folderRef) : null;
9117
9361
  const uploaded = [];
9118
9362
  for (const file of positionals) {
9119
- const name = basename4(file);
9363
+ const name = basename5(file);
9120
9364
  const body = readFileSync4(file);
9121
9365
  const media = resolveMediaType({
9122
9366
  head: body.subarray(0, MEDIA_HEAD_BYTES),
@@ -9634,7 +9878,7 @@ function trackingCwd(vosId) {
9634
9878
 
9635
9879
  // src/plugin/buildProgram.ts
9636
9880
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
9637
- import { basename as basename5, dirname as dirname3, join as join17, resolve as resolve7 } from "path";
9881
+ import { basename as basename6, dirname as dirname3, join as join17, resolve as resolve7 } from "path";
9638
9882
  import { pathToFileURL } from "url";
9639
9883
  import { parse } from "acorn";
9640
9884
  import { fullAncestor } from "acorn-walk";
@@ -9842,7 +10086,7 @@ async function cmdBuild(argv) {
9842
10086
  throw new UsageError(
9843
10087
  `${source} exports no program: \`export default { version: 2, duration, camera, createContent, createTimeline, \u2026 }\``
9844
10088
  );
9845
- const built = buildProgram(program, basename5(file), text);
10089
+ const built = buildProgram(program, basename6(file), text);
9846
10090
  for (const p of built.problems) r.log(`error ${p.fn}: ${p.message}`);
9847
10091
  if (built.problems.length) {
9848
10092
  r.done(
@@ -10077,7 +10321,7 @@ import { findFontFamily as findFontFamily3 } from "@vosjs/shared";
10077
10321
  import { existsSync as existsSync17, readFileSync as readFileSync6, readdirSync as readdirSync3, statSync as statSync5 } from "fs";
10078
10322
  import { createServer as createServer2 } from "http";
10079
10323
  import { createRequire } from "module";
10080
- import { basename as basename6, extname as extname3, join as join19 } from "path";
10324
+ import { basename as basename7, extname as extname3, join as join19 } from "path";
10081
10325
  function themeColors(sources) {
10082
10326
  const out = {};
10083
10327
  const re = /\bconst\s+([A-Za-z_$][\w$]*)\s*(?::\s*string\s*)?=\s*['"](#[0-9a-fA-F]{3,8})['"]/g;
@@ -10091,10 +10335,10 @@ function themeColors(sources) {
10091
10335
  function remotionScenes(seqs, fps) {
10092
10336
  const seen = /* @__PURE__ */ new Map();
10093
10337
  const live = seqs.filter((s) => s.durationInFrames > 0);
10094
- const overlaps2 = (a, b) => a.from < b.from + b.durationInFrames && b.from < a.from + a.durationInFrames;
10338
+ const overlaps3 = (a, b) => a.from < b.from + b.durationInFrames && b.from < a.from + a.durationInFrames;
10095
10339
  return live.filter(
10096
10340
  (s) => !live.some(
10097
- (o) => o !== s && o.durationInFrames > s.durationInFrames && overlaps2(o, s)
10341
+ (o) => o !== s && o.durationInFrames > s.durationInFrames && overlaps3(o, s)
10098
10342
  )
10099
10343
  ).sort((a, b) => a.from - b.from).map((s, i) => {
10100
10344
  const base = s.name ?? `scene${i + 1}`;
@@ -10357,7 +10601,7 @@ async function readRemotion(dir, log) {
10357
10601
  const sources = sourceTexts(dir);
10358
10602
  const publicDir = join19(dir, "public");
10359
10603
  const media = tree.audio.map((a) => {
10360
- const name = basename6(a.src.split("?")[0]);
10604
+ const name = basename7(a.src.split("?")[0]);
10361
10605
  const local = existsSync17(join19(publicDir, name)) ? `public/${name}` : a.src;
10362
10606
  return {
10363
10607
  kind: "audio",
@@ -10860,7 +11104,7 @@ async function readInventory(source, opts) {
10860
11104
  // src/plugin/portScaffold.ts
10861
11105
  import { spawnSync as spawnSync3 } from "child_process";
10862
11106
  import { copyFileSync, existsSync as existsSync19, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
10863
- import { basename as basename7, dirname as dirname6, join as join21, resolve as resolve10 } from "path";
11107
+ import { basename as basename8, dirname as dirname6, join as join21, resolve as resolve10 } from "path";
10864
11108
  import { findFontFamily as findFontFamily4, fontFaceUrl, nearestFontWeight } from "@vosjs/shared";
10865
11109
 
10866
11110
  // src/plugin/painterLib.ts
@@ -11270,7 +11514,7 @@ function planScaffold(inv, opts) {
11270
11514
  const copies = [];
11271
11515
  inv.media.filter((m) => m.kind === "audio" && m.src).forEach((m, i) => {
11272
11516
  const from = resolve10(opts.sourceDir, m.src);
11273
- const name = basename7(from);
11517
+ const name = basename8(from);
11274
11518
  if (!existsSync19(from)) {
11275
11519
  report.push(
11276
11520
  `- the audio ${m.src} is not in the source folder; add it to doc.json by hand`
@@ -11423,7 +11667,7 @@ ${sceneTodos}${springHint}
11423
11667
  }
11424
11668
  function reportText(inv, r) {
11425
11669
  const lines = [
11426
- `# Port report: ${inv.source === "." && inv.root ? basename7(inv.root) : inv.source}`,
11670
+ `# Port report: ${inv.source === "." && inv.root ? basename8(inv.root) : inv.source}`,
11427
11671
  "",
11428
11672
  `${inv.engine} piece, ${inv.width}\xD7${inv.height}${inv.fps ? ` at ${inv.fps} fps` : ""}, ${inv.duration ?? "?"} s. A program has no size of its own: render it with \`--width ${inv.width} --height ${inv.height}\`.`,
11429
11673
  "",
@@ -11587,11 +11831,11 @@ function scaffoldSourceDir(inv) {
11587
11831
  // src/plugin/recipe.ts
11588
11832
  import { createHash as createHash3 } from "crypto";
11589
11833
  import { readFileSync as readFileSync9 } from "fs";
11590
- import { basename as basename8 } from "path";
11834
+ import { basename as basename9 } from "path";
11591
11835
  var BOOLEAN_FLAGS9 = /* @__PURE__ */ new Set(["json", "help"]);
11592
11836
  var USAGE = "vos recipe push <file.md> (--folder <folderId|slug> | --asset <assetId>)";
11593
11837
  function recipeFilename(file) {
11594
- const name = basename8(file);
11838
+ const name = basename9(file);
11595
11839
  if (!/\.md$/i.test(name)) {
11596
11840
  throw new UsageError(`a recipe is a markdown file \u2014 ${name} is not .md`);
11597
11841
  }
@@ -12077,7 +12321,7 @@ function isWallError(error) {
12077
12321
  return !!error && /^(\[\w+\] )?The page is behind a sign-in/.test(error);
12078
12322
  }
12079
12323
  async function recordHosted(input) {
12080
- const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12324
+ const sleep = input.sleep ?? ((ms2) => new Promise((r) => setTimeout(r, ms2)));
12081
12325
  const posted = await apiJson(input.origin, "/api/record/jobs", {
12082
12326
  method: "POST",
12083
12327
  key: input.key,
@@ -12640,8 +12884,8 @@ async function cmdActions(argv) {
12640
12884
  if (!m) throw new UsageError("--viewport expects WxH, e.g. 1280x720");
12641
12885
  viewport = { width: Number(m[1]), height: Number(m[2]) };
12642
12886
  }
12643
- const { readFile: readFile17 } = await import("fs/promises");
12644
- const text = await readFile17(resolve13(input), "utf8");
12887
+ const { readFile: readFile19 } = await import("fs/promises");
12888
+ const text = await readFile19(resolve13(input), "utf8");
12645
12889
  const { records, problems } = parseAgentBrowserLog(text);
12646
12890
  for (const p of problems) r.log(` ${p}`);
12647
12891
  const result = convertAgentBrowser(records, {
@@ -12678,9 +12922,9 @@ function summary2(result) {
12678
12922
  }
12679
12923
  async function cmdActionsScript(input, flags) {
12680
12924
  const r = createReporter(flags.json === true);
12681
- const { readFile: readFile17 } = await import("fs/promises");
12682
- const { shotList, formatShotList } = await import("./shotList-EE3S5LVF.js");
12683
- const raw = JSON.parse(await readFile17(resolve13(input), "utf8"));
12925
+ const { readFile: readFile19 } = await import("fs/promises");
12926
+ const { shotList, formatShotList } = await import("./shotList-MSNWLVDN.js");
12927
+ const raw = JSON.parse(await readFile19(resolve13(input), "utf8"));
12684
12928
  const errors = validateActions(raw);
12685
12929
  if (errors.length) throw new UsageError(`${input}:
12686
12930
  ${errors.join("\n ")}`);
@@ -12692,7 +12936,7 @@ async function cmdActionsScript(input, flags) {
12692
12936
  // src/plugin/ingest.ts
12693
12937
  import { existsSync as existsSync21 } from "fs";
12694
12938
  import { copyFile, mkdir as mkdir8, readFile as readFile13, stat as stat2 } from "fs/promises";
12695
- import { basename as basename9, join as join23, resolve as resolve14 } from "path";
12939
+ import { basename as basename10, join as join23, resolve as resolve14 } from "path";
12696
12940
 
12697
12941
  // src/plugin/traceInflate.ts
12698
12942
  import { inflateRawSync } from "zlib";
@@ -12765,7 +13009,7 @@ async function probeVideoFile(path) {
12765
13009
  input.getPrimaryAudioTrack()
12766
13010
  ]);
12767
13011
  const mimeType = format.mimeType;
12768
- const ext = mimeType.includes("mp4") ? "mp4" : mimeType.includes("quicktime") ? "mov" : "webm";
13012
+ const ext2 = mimeType.includes("mp4") ? "mp4" : mimeType.includes("quicktime") ? "mov" : "webm";
12769
13013
  return {
12770
13014
  width: video.displayWidth,
12771
13015
  height: video.displayHeight,
@@ -12773,14 +13017,14 @@ async function probeVideoFile(path) {
12773
13017
  fps: Math.round(stats.averagePacketRate * 100) / 100,
12774
13018
  hasAudio: !!audio,
12775
13019
  mimeType,
12776
- ext
13020
+ ext: ext2
12777
13021
  };
12778
13022
  } finally {
12779
13023
  await input.dispose?.();
12780
13024
  }
12781
13025
  }
12782
- async function copyRecording(src, dest, ext) {
12783
- if (ext === "mov") {
13026
+ async function copyRecording(src, dest, ext2) {
13027
+ if (ext2 === "mov") {
12784
13028
  await copyFile(src, dest);
12785
13029
  return false;
12786
13030
  }
@@ -12788,7 +13032,7 @@ async function copyRecording(src, dest, ext) {
12788
13032
  source: new FilePathSource(src),
12789
13033
  formats: ALL_FORMATS
12790
13034
  });
12791
- const format = () => ext === "mp4" ? new Mp4OutputFormat({ fastStart: "in-memory" }) : new WebMOutputFormat();
13035
+ const format = () => ext2 === "mp4" ? new Mp4OutputFormat({ fastStart: "in-memory" }) : new WebMOutputFormat();
12792
13036
  try {
12793
13037
  const probe3 = await Conversion.init({
12794
13038
  input,
@@ -12819,7 +13063,7 @@ async function copyRecording(src, dest, ext) {
12819
13063
  }
12820
13064
  }
12821
13065
  async function readTraceFile(path, offsetMs) {
12822
- const kind = traceKindOf(basename9(path));
13066
+ const kind = traceKindOf(basename10(path));
12823
13067
  if (!kind) {
12824
13068
  throw new UsageError(
12825
13069
  `${path}: not a trace the recorder reads (trace.zip, .trace, .jsonl, .json or .csv)`
@@ -12840,7 +13084,7 @@ async function readTraceFile(path, offsetMs) {
12840
13084
  return cursorFromPlaywrightTrace(text2, { offsetMs });
12841
13085
  }
12842
13086
  const text = await readFile13(path, "utf8");
12843
- return cursorFromTrace(basename9(path), text, { offsetMs });
13087
+ return cursorFromTrace(basename10(path), text, { offsetMs });
12844
13088
  }
12845
13089
  function parseViewport(v) {
12846
13090
  if (!v) return null;
@@ -12871,7 +13115,7 @@ async function cmdIngest(argv) {
12871
13115
  "note: finished footage opens bare, so --background is not used (pass --as take for the screen-recording card and its ground)"
12872
13116
  );
12873
13117
  const backdrop = footage ? null : await takeBackdrop(flags, r);
12874
- r.log(`probing ${basename9(videoPath)}\u2026`);
13118
+ r.log(`probing ${basename10(videoPath)}\u2026`);
12875
13119
  const probed = await probeVideoFile(videoPath);
12876
13120
  if (!(probed.durationMs > 0)) {
12877
13121
  throw new UsageError(
@@ -12920,13 +13164,13 @@ async function cmdIngest(argv) {
12920
13164
  const clicks = events.filter((e) => e.type === "down").length;
12921
13165
  const seconds = (probed.durationMs / 1e3).toFixed(1);
12922
13166
  const facts = `${seconds}s, ${probed.width}\xD7${probed.height} @ ${meta.fps} fps${probed.hasAudio ? ", audio" : ", no audio"}${remuxed ? "" : ", copied as is"}`;
12923
- const cursorLine = trace ? `${events.length} cursor events (${clicks} clicks) from ${basename9(cursorPath)}${trace.dropped ? `, ${trace.dropped} not placed` : ""}; ${plan.zoomAuto} zoom${plan.zoomAuto === 1 ? "" : "s"} planned` : "no cursor track, so nothing was planned";
13167
+ const cursorLine = trace ? `${events.length} cursor events (${clicks} clicks) from ${basename10(cursorPath)}${trace.dropped ? `, ${trace.dropped} not placed` : ""}; ${plan.zoomAuto} zoom${plan.zoomAuto === 1 ? "" : "s"} planned` : "no cursor track, so nothing was planned";
12924
13168
  const opened = footage ? `opened as finished footage: no card, no browser bar, no drawn cursor, ${meta.fps > 30 ? 60 : 30} fps export (--as take for a screen recording's card)` : `opened as a screen recording: the card, its ground and the drawn cursor (--as footage to open it bare)`;
12925
13169
  const next = footage && !trace ? `Next: vos push ${outDir} --wait \u2014 it lands as it is; trim or add to it in the studio` : trace ? `Next: vos digest ${outDir} \u2014 look before you cut; edit doc.json; vos push ${outDir}` : `Add --cursor <trace> recorded beside the video (a Playwright trace.zip, stamped JSON records, or a t,x,y,type CSV) to plan zooms from its clicks; else place them by eye: vos frames ${outDir} --times 0,25%,50%,75%,100%, then edit doc.json`;
12926
13170
  r.done(
12927
13171
  {
12928
13172
  out: outDir,
12929
- recording: basename9(recording),
13173
+ recording: basename10(recording),
12930
13174
  durationMs: probed.durationMs,
12931
13175
  width: probed.width,
12932
13176
  height: probed.height,
@@ -12935,7 +13179,7 @@ async function cmdIngest(argv) {
12935
13179
  hasAudio: probed.hasAudio,
12936
13180
  remuxed,
12937
13181
  cursor: trace ? {
12938
- file: basename9(cursorPath),
13182
+ file: basename10(cursorPath),
12939
13183
  kind: trace.kind,
12940
13184
  events: events.length,
12941
13185
  clicks,
@@ -12947,7 +13191,7 @@ async function cmdIngest(argv) {
12947
13191
  backdrop: plan.backdrop ?? null,
12948
13192
  as: footage ? "footage" : "take"
12949
13193
  },
12950
- `Ingested ${basename9(videoPath)} \u2192 ${outDir}/${basename9(recording)} (${facts})
13194
+ `Ingested ${basename10(videoPath)} \u2192 ${outDir}/${basename10(recording)} (${facts})
12951
13195
  ${opened}
12952
13196
  ${cursorLine}
12953
13197
  ${next}`
@@ -12959,12 +13203,13 @@ async function cmdIngest(argv) {
12959
13203
  import { createRequire as createRequire2 } from "module";
12960
13204
  import { existsSync as existsSync22 } from "fs";
12961
13205
  import { mkdir as mkdir9, readFile as readFile14, stat as stat3, writeFile as writeFile10 } from "fs/promises";
12962
- import { basename as basename10, dirname as dirname8, extname as extname4, join as join24, resolve as resolve15 } from "path";
13206
+ import { basename as basename11, dirname as dirname8, extname as extname4, join as join24, resolve as resolve15 } from "path";
12963
13207
  import { ALL_FORMATS as ALL_FORMATS2, FilePathSource as FilePathSource2, Input as Input2 } from "mediabunny";
12964
13208
 
12965
13209
  // src/plugin/videoChecks.ts
12966
13210
  var SCENE_CHANGE_AT = 0.12;
12967
13211
  var CELL_CHANGED_AT = 0.03;
13212
+ var CELL_STRONG_AT = 0.06;
12968
13213
  var SCENE_WINDOW = 5;
12969
13214
  var UNIFORM_BELOW = 2;
12970
13215
  var QUIET_BELOW_LUFS = -23;
@@ -12975,7 +13220,9 @@ var SCENE_SPIKE = 2.5;
12975
13220
  var ISOLATED_CHANGE_AT = 0.03;
12976
13221
  var ISOLATED_SPIKE = 8;
12977
13222
  var ISOLATED_COVERAGE = 0.25;
12978
- function sceneChanges2(times, changes, coverage) {
13223
+ var SCATTER_SHARE = 0.1;
13224
+ var SCATTER_BOX = 0.22;
13225
+ function sceneChanges2(times, changes, coverage, scatter) {
12979
13226
  const out = [];
12980
13227
  for (let i = 1; i < changes.length; i++) {
12981
13228
  const c = changes[i];
@@ -12987,7 +13234,9 @@ function sceneChanges2(times, changes, coverage) {
12987
13234
  const most = around.length ? around[around.length - 1] : 0;
12988
13235
  const share = coverage ? coverage[i] : 1;
12989
13236
  const standsOut = c >= SCENE_CHANGE_AT && c >= SCENE_SPIKE * mid;
12990
- const standsAlone = c >= ISOLATED_CHANGE_AT && share >= ISOLATED_COVERAGE && c >= ISOLATED_SPIKE * most;
13237
+ const spread = scatter?.[i];
13238
+ const scattered = !!spread && spread.share >= SCATTER_SHARE && spread.box >= SCATTER_BOX;
13239
+ const standsAlone = c >= ISOLATED_CHANGE_AT && (share >= ISOLATED_COVERAGE || scattered) && c >= ISOLATED_SPIKE * most;
12991
13240
  if (!standsOut && !standsAlone) continue;
12992
13241
  out.push({
12993
13242
  time: times[i],
@@ -13195,7 +13444,8 @@ function posterWarning(which, deviation) {
13195
13444
 
13196
13445
  // src/plugin/checkVideo.ts
13197
13446
  var BOOLEAN_FLAGS11 = /* @__PURE__ */ new Set(["json", "help", "strict"]);
13198
- var THUMB_LONG_SIDE = 64;
13447
+ var THUMB_LONG_SIDE = 160;
13448
+ var CELLS_LONG_SIDE = 16;
13199
13449
  async function probeVideoFacts(file) {
13200
13450
  const input = new Input2({
13201
13451
  source: new FilePathSource2(file),
@@ -13295,22 +13545,43 @@ async function decodeInPage(opts) {
13295
13545
  const coverageOf = (a, b) => {
13296
13546
  const W = opts.thumbW;
13297
13547
  const H = opts.thumbH;
13548
+ const C = opts.cellPx;
13298
13549
  let cells = 0;
13299
13550
  let changed = 0;
13300
- for (let cy = 0; cy < H; cy += 4)
13301
- for (let cx = 0; cx < W; cx += 4) {
13551
+ let strong = 0;
13552
+ let x0 = W;
13553
+ let x1 = 0;
13554
+ let y0 = H;
13555
+ let y1 = 0;
13556
+ for (let cy = 0; cy < H; cy += C)
13557
+ for (let cx = 0; cx < W; cx += C) {
13302
13558
  let s = 0;
13303
- let n = 0;
13304
- for (let y = cy; y < Math.min(H, cy + 4); y++)
13305
- for (let x = cx; x < Math.min(W, cx + 4); x++) {
13559
+ let n2 = 0;
13560
+ for (let y = cy; y < Math.min(H, cy + C); y++)
13561
+ for (let x = cx; x < Math.min(W, cx + C); x++) {
13306
13562
  const k = (y * W + x) * 3;
13307
13563
  s += Math.abs(a[k] - b[k]) + Math.abs(a[k + 1] - b[k + 1]) + Math.abs(a[k + 2] - b[k + 2]);
13308
- n += 3;
13564
+ n2 += 3;
13309
13565
  }
13310
13566
  cells++;
13311
- if (s / Math.max(1, n) / 255 >= opts.cellChanged) changed++;
13567
+ const m = s / Math.max(1, n2) / 255;
13568
+ if (m >= opts.cellChanged) changed++;
13569
+ if (m >= opts.cellStrong) {
13570
+ strong++;
13571
+ x0 = Math.min(x0, cx);
13572
+ x1 = Math.max(x1, Math.min(W, cx + C));
13573
+ y0 = Math.min(y0, cy);
13574
+ y1 = Math.max(y1, Math.min(H, cy + C));
13575
+ }
13576
+ }
13577
+ const n = Math.max(1, cells);
13578
+ return {
13579
+ share: changed / n,
13580
+ scatter: {
13581
+ share: strong / n,
13582
+ box: strong ? (x1 - x0) * (y1 - y0) / Math.max(1, W * H) : 0
13312
13583
  }
13313
- return changed / Math.max(1, cells);
13584
+ };
13314
13585
  };
13315
13586
  const small = document.createElement("canvas");
13316
13587
  small.width = opts.thumbW;
@@ -13323,6 +13594,8 @@ async function decodeInPage(opts) {
13323
13594
  const times = [];
13324
13595
  const changes = [];
13325
13596
  const coverage = [];
13597
+ const scatter = [];
13598
+ const none = { share: 0, scatter: { share: 0, box: 0 } };
13326
13599
  let next = -Infinity;
13327
13600
  let prev = null;
13328
13601
  let first = null;
@@ -13350,7 +13623,9 @@ async function decodeInPage(opts) {
13350
13623
  }
13351
13624
  times.push(t);
13352
13625
  changes.push(prev ? difference(prev, g) : 0);
13353
- coverage.push(prev ? coverageOf(prev, g) : 0);
13626
+ const cov = prev ? coverageOf(prev, g) : none;
13627
+ coverage.push(cov.share);
13628
+ scatter.push(cov.scatter);
13354
13629
  prev = g;
13355
13630
  lastKept = t;
13356
13631
  lastGray = g;
@@ -13362,7 +13637,9 @@ async function decodeInPage(opts) {
13362
13637
  if (tail && lastTime > lastKept) {
13363
13638
  times.push(lastTime);
13364
13639
  changes.push(prev ? difference(prev, tail) : 0);
13365
- coverage.push(prev ? coverageOf(prev, tail) : 0);
13640
+ const cov = prev ? coverageOf(prev, tail) : none;
13641
+ coverage.push(cov.share);
13642
+ scatter.push(cov.scatter);
13366
13643
  lastGray = tail;
13367
13644
  }
13368
13645
  const toBase64 = (bytes) => {
@@ -13412,6 +13689,7 @@ async function decodeInPage(opts) {
13412
13689
  times,
13413
13690
  changes,
13414
13691
  coverage,
13692
+ scatter,
13415
13693
  firstDeviation: deviation(first),
13416
13694
  lastDeviation: deviation(lastGray),
13417
13695
  firstTime,
@@ -13447,12 +13725,14 @@ async function checkVideo(browser, file, outDir) {
13447
13725
  void page.evaluate(decodeInPage, {
13448
13726
  mbCode: await mediabunnyBundle(),
13449
13727
  meterSrc: String(createLoudnessMeter),
13450
- videoUrl: `/${encodeURIComponent(basename10(file))}`,
13728
+ videoUrl: `/${encodeURIComponent(basename11(file))}`,
13451
13729
  sampleFps,
13452
13730
  thumbW: side(facts.width),
13453
13731
  thumbH: side(facts.height),
13732
+ cellPx: Math.max(1, Math.round(THUMB_LONG_SIDE / CELLS_LONG_SIDE)),
13454
13733
  subBlock: SUB_BLOCK_SECONDS,
13455
- cellChanged: CELL_CHANGED_AT
13734
+ cellChanged: CELL_CHANGED_AT,
13735
+ cellStrong: CELL_STRONG_AT
13456
13736
  }).catch(() => {
13457
13737
  });
13458
13738
  done = await waitForPageDone(
@@ -13471,7 +13751,12 @@ async function checkVideo(browser, file, outDir) {
13471
13751
  const lastFile = join24(outDir, "last.png");
13472
13752
  await writeFile10(firstFile, Buffer.from(done.firstPng, "base64"));
13473
13753
  await writeFile10(lastFile, Buffer.from(done.lastPng, "base64"));
13474
- const changes = sceneChanges2(done.times, done.changes, done.coverage);
13754
+ const changes = sceneChanges2(
13755
+ done.times,
13756
+ done.changes,
13757
+ done.coverage,
13758
+ done.scatter
13759
+ );
13475
13760
  const loudness = done.audio ? loudnessFromEnergies(done.audio.energies, done.audio.peak) : null;
13476
13761
  const round2 = (n) => Math.round(n * 100) / 100;
13477
13762
  const posters = {
@@ -13548,7 +13833,7 @@ async function cmdCheckVideo(argv) {
13548
13833
  const r = createReporter(flags.json === true);
13549
13834
  const strict = flags.strict === true;
13550
13835
  const outDir = resolve15(
13551
- strFlag(flags, "out") ?? join24(dirname8(file), `${basename10(file, extname4(file))}-check`)
13836
+ strFlag(flags, "out") ?? join24(dirname8(file), `${basename11(file, extname4(file))}-check`)
13552
13837
  );
13553
13838
  const browser = await launchBrowser();
13554
13839
  try {
@@ -13563,27 +13848,28 @@ async function cmdCheckVideo(argv) {
13563
13848
  }
13564
13849
 
13565
13850
  // src/plugin/join.ts
13566
- import { existsSync as existsSync24, readdirSync as readdirSync5, statSync as statSync7 } from "fs";
13567
- import { copyFile as copyFile3, mkdir as mkdir11, rm as rm4 } from "fs/promises";
13851
+ import { existsSync as existsSync25, readdirSync as readdirSync5, statSync as statSync7 } from "fs";
13852
+ import { copyFile as copyFile3, mkdir as mkdir11, readFile as readFile17, rm as rm4 } from "fs/promises";
13568
13853
  import {
13569
- basename as basename12,
13854
+ basename as basename13,
13570
13855
  dirname as dirname9,
13571
13856
  isAbsolute as isAbsolute4,
13572
- join as join26,
13857
+ join as join27,
13573
13858
  relative as relative5,
13574
13859
  resolve as resolve17,
13575
13860
  sep as sep4
13576
13861
  } from "path";
13577
13862
  import {
13578
- mediaKey as mediaKey2,
13579
- mediaRefId,
13863
+ mediaKey as mediaKey4,
13864
+ mediaRefId as mediaRefId2,
13580
13865
  migrateMotion as migrateMotion3,
13581
13866
  projectFromArtifact as projectFromArtifact2,
13582
- ratedSegments as ratedSegments9,
13583
- resolveAnchor as resolveAnchor6,
13584
- segmentOutputExtents as segmentOutputExtents3
13867
+ ratedSegments as ratedSegments10,
13868
+ layerStart as layerStart4,
13869
+ resolveAnchor as resolveAnchor7,
13870
+ segmentOutputExtents as segmentOutputExtents4
13585
13871
  } from "@vosjs/studio-core";
13586
- import { totalDuration as totalDuration2 } from "@vosjs/timeline";
13872
+ import { totalDuration as totalDuration3 } from "@vosjs/timeline";
13587
13873
 
13588
13874
  // src/plugin/score.ts
13589
13875
  import { existsSync as existsSync23 } from "fs";
@@ -13596,7 +13882,7 @@ import {
13596
13882
  writeFile as writeFile11
13597
13883
  } from "fs/promises";
13598
13884
  import { tmpdir as tmpdir2 } from "os";
13599
- import { basename as basename11, isAbsolute as isAbsolute3, join as join25, relative as relative4, resolve as resolve16, sep as sep3 } from "path";
13885
+ import { basename as basename12, isAbsolute as isAbsolute3, join as join25, relative as relative4, resolve as resolve16, sep as sep3 } from "path";
13600
13886
  import {
13601
13887
  NORMALIZE_MAX_LUFS as NORMALIZE_MAX_LUFS2,
13602
13888
  NORMALIZE_MIN_LUFS as NORMALIZE_MIN_LUFS2,
@@ -13911,7 +14197,7 @@ async function measureInBrowser(path) {
13911
14197
  const bytes = await readFile15(path);
13912
14198
  if (bytes.length > BROWSER_DECODE_MAX_BYTES)
13913
14199
  throw new Error(
13914
- `${basename11(path)} is ${Math.round(bytes.length / 1048576)} MB, past the ${BROWSER_DECODE_MAX_BYTES / 1048576} MB the browser decode takes whole`
14200
+ `${basename12(path)} is ${Math.round(bytes.length / 1048576)} MB, past the ${BROWSER_DECODE_MAX_BYTES / 1048576} MB the browser decode takes whole`
13915
14201
  );
13916
14202
  const browser = await launchBrowser();
13917
14203
  try {
@@ -14015,12 +14301,12 @@ async function resolveTrack(dir, ref, flags, dryRun) {
14015
14301
  if (existsSync23(local)) {
14016
14302
  const rel = relative4(resolve16(dir), local);
14017
14303
  const inside = !!rel && !rel.startsWith(`..${sep3}`) && rel !== ".." && !isAbsolute3(rel);
14018
- const key = inside ? rel.split(sep3).join("/") : `music/${basename11(local)}`;
14304
+ const key = inside ? rel.split(sep3).join("/") : `music/${basename12(local)}`;
14019
14305
  if (!inside && !dryRun) {
14020
14306
  await mkdir10(join25(dir, "music"), { recursive: true });
14021
14307
  await copyFile2(local, join25(dir, key));
14022
14308
  }
14023
- return { path: local, key, name: basename11(local) };
14309
+ return { path: local, key, name: basename12(local) };
14024
14310
  }
14025
14311
  const origin = platformOrigin({
14026
14312
  origin: strFlag(flags, "origin"),
@@ -14035,7 +14321,7 @@ async function resolveTrack(dir, ref, flags, dryRun) {
14035
14321
  const res = await fetch(track.url);
14036
14322
  if (!res.ok) throw new Error(`GET ${track.url} answered ${res.status}`);
14037
14323
  const tmp = await mkdtemp(join25(tmpdir2(), "vos-score-"));
14038
- const path = join25(tmp, basename11(new URL(track.url).pathname) || "track");
14324
+ const path = join25(tmp, basename12(new URL(track.url).pathname) || "track");
14039
14325
  await writeFile11(path, new Uint8Array(await res.arrayBuffer()));
14040
14326
  return {
14041
14327
  path,
@@ -14183,30 +14469,94 @@ import {
14183
14469
  segmentOutputExtents as segmentOutputExtents2
14184
14470
  } from "@vosjs/studio-core";
14185
14471
  import { totalDuration } from "@vosjs/timeline";
14472
+
14473
+ // src/plugin/scriptStep.ts
14474
+ var asRaw = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
14475
+ function scriptStepOf(step, list, recorded = []) {
14476
+ if (!Array.isArray(list)) return void 0;
14477
+ const steps = list.map(asRaw);
14478
+ const indexOfId = (id) => steps.findIndex((s) => s?.id === id);
14479
+ if (step.id !== void 0) {
14480
+ const at2 = indexOfId(String(step.id));
14481
+ if (at2 >= 0) return steps[at2];
14482
+ }
14483
+ const recordedIds = new Set(
14484
+ recorded.filter((r) => r !== step && r.id !== void 0).map((r) => String(r.id))
14485
+ );
14486
+ const fits = (s) => !!s && s.do === step.do && !(typeof s.id === "string" && recordedIds.has(s.id)) && !(typeof s.selector === "string" && typeof step.selector === "string" && s.selector !== step.selector);
14487
+ const named = recorded.filter(
14488
+ (r) => r !== step && r.id !== void 0 && indexOfId(String(r.id)) >= 0
14489
+ );
14490
+ const before = named.filter((r) => r.step < step.step).sort((a, b) => b.step - a.step)[0];
14491
+ const after = named.filter((r) => r.step > step.step).sort((a, b) => a.step - b.step)[0];
14492
+ const found = /* @__PURE__ */ new Set();
14493
+ for (const anchor of [before, after]) {
14494
+ if (!anchor) continue;
14495
+ const hit = steps[indexOfId(String(anchor.id)) + step.step - anchor.step];
14496
+ if (fits(hit)) found.add(hit);
14497
+ }
14498
+ if (found.size === 1) return [...found][0];
14499
+ if (found.size > 1) return void 0;
14500
+ const own = steps[step.step];
14501
+ return fits(own) ? own : void 0;
14502
+ }
14503
+ function scriptDrift(recorded, list) {
14504
+ if (!Array.isArray(list) || !recorded.length) return null;
14505
+ const changed = [];
14506
+ for (const step of recorded) {
14507
+ const raw = scriptStepOf(step, list, recorded);
14508
+ const id = step.id !== void 0 ? { id: String(step.id) } : {};
14509
+ if (!raw) {
14510
+ changed.push({ step: step.step, ...id, was: step.do, now: null });
14511
+ continue;
14512
+ }
14513
+ const selectorMoved = typeof raw.selector === "string" && typeof step.selector === "string" && raw.selector !== step.selector;
14514
+ if (raw.do !== step.do || selectorMoved)
14515
+ changed.push({
14516
+ step: step.step,
14517
+ ...id,
14518
+ was: step.selector ? `${step.do} ${step.selector}` : step.do,
14519
+ now: typeof raw.selector === "string" ? `${String(raw.do)} ${raw.selector}` : String(raw.do)
14520
+ });
14521
+ }
14522
+ if (list.length === recorded.length && !changed.length) return null;
14523
+ return { recorded: recorded.length, inScript: list.length, changed };
14524
+ }
14525
+ function scriptDriftLine(d, file = "actions.json") {
14526
+ const counts = d.inScript === d.recorded ? `${file} no longer matches what was recorded` : `${file} has ${d.inScript} step${d.inScript === 1 ? "" : "s"} and the take recorded ${d.recorded} (meta.steps)`;
14527
+ const shown = d.changed.slice(0, 3).map(
14528
+ (c) => `#${c.step}${c.id ? ` ${c.id}` : ""} was ${c.was}, ${c.now === null ? "not found in the file" : `now ${c.now}`}`
14529
+ );
14530
+ const changed = d.changed.length ? `; ${d.changed.length} recorded step${d.changed.length === 1 ? "" : "s"} changed (${shown.join("; ")}${d.changed.length > 3 ? "; \u2026" : ""})` : "";
14531
+ return `${counts}${changed}: captions and zoom marks are matched to the recorded steps by id, then by place beside a named step, then by index (vos info shows where each lands; record again to bring them back in step)`;
14532
+ }
14533
+
14534
+ // src/plugin/stepCaptions.ts
14186
14535
  var STEP_CAPTIONS_FROM = "steps";
14187
- var STEP_CAPTION_OFFSET = 0.2;
14536
+ var STEP_CAPTION_AT = "start";
14188
14537
  var CAPTION_MAX = 3;
14189
14538
  var CAPTION_MIN = 1.2;
14190
14539
  var CAPTION_GAP = 0.1;
14191
- function captionOf(step, actions, script) {
14540
+ function captionOf(step, actions, script, recorded = []) {
14192
14541
  const own = step.caption;
14193
14542
  if (typeof own === "string" && own.trim()) return own.trim();
14194
14543
  for (const list of [
14195
14544
  actions?.steps,
14196
14545
  Array.isArray(script?.steps) ? script.steps : void 0
14197
14546
  ]) {
14198
- if (!Array.isArray(list)) continue;
14199
- const byId = step.id !== void 0 ? list.find((s) => s?.id === step.id) : void 0;
14200
- const raw = byId ?? list[step.step];
14547
+ const raw = scriptStepOf(step, list, recorded);
14201
14548
  if (typeof raw?.caption === "string" && raw.caption.trim())
14202
14549
  return raw.caption.trim();
14203
14550
  }
14204
14551
  return null;
14205
14552
  }
14206
14553
  var safeId = (v) => String(v).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "step";
14207
- function landStepCaptions(doc, actions) {
14554
+ function landStepCaptions(doc, actions, opts = {}) {
14208
14555
  const report = { landed: [], skipped: [], uncaptioned: [] };
14209
- const kept = (doc.overlays ?? []).filter((o) => o.from !== STEP_CAPTIONS_FROM);
14556
+ const mine = (o) => !opts.only || opts.only.has(mediaKey(o.anchor?.media));
14557
+ const kept = (doc.overlays ?? []).filter(
14558
+ (o) => o.from !== STEP_CAPTIONS_FROM || !mine(o)
14559
+ );
14210
14560
  const rated = ratedSegments8(doc);
14211
14561
  const footageEnd = totalDuration(rated);
14212
14562
  const docSegments = doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }];
@@ -14229,10 +14579,11 @@ function landStepCaptions(doc, actions) {
14229
14579
  const fresh = [];
14230
14580
  const taken = new Set(kept.map((o) => o.id));
14231
14581
  for (const { id: media, source } of docMediaList2(doc)) {
14582
+ if (opts.only && !opts.only.has(media)) continue;
14232
14583
  const steps = source.meta.steps ?? [];
14233
14584
  let captioned = 0;
14234
14585
  for (const step of steps) {
14235
- const caption = captionOf(step, actions.get(media), source.script);
14586
+ const caption = captionOf(step, actions.get(media), source.script, steps);
14236
14587
  if (!caption) continue;
14237
14588
  captioned++;
14238
14589
  const ref = step.id ?? step.step;
@@ -14248,8 +14599,7 @@ function landStepCaptions(doc, actions) {
14248
14599
  const anchor = {
14249
14600
  step: ref,
14250
14601
  ...media ? { media } : {},
14251
- at: "end",
14252
- offset: STEP_CAPTION_OFFSET
14602
+ at: STEP_CAPTION_AT
14253
14603
  };
14254
14604
  const placed = resolveAnchor5(doc, anchor, rated);
14255
14605
  if (placed.start === null) {
@@ -14267,6 +14617,7 @@ function landStepCaptions(doc, actions) {
14267
14617
  taken.add(id);
14268
14618
  const clip = {
14269
14619
  ...houseCaption(id, caption, placed.start, CAPTION_MAX),
14620
+ start: placed.start,
14270
14621
  anchor,
14271
14622
  from: STEP_CAPTIONS_FROM
14272
14623
  };
@@ -14303,55 +14654,1072 @@ function bareCaptionsFlag(argv) {
14303
14654
  );
14304
14655
  }
14305
14656
 
14306
- // src/plugin/join.ts
14307
- var FULL_BLEED_FRAME = {
14308
- inset: { top: 0, right: 0, bottom: 0, left: 0 },
14309
- radius: 0,
14310
- shadow: 0,
14311
- shadowContact: 0,
14312
- border: 0,
14313
- fit: "cover"
14314
- };
14315
- var round32 = (v) => Math.round(v * 1e3) / 1e3;
14316
- var FILE_SOUND_FADE = 0.15;
14317
- var prefixed = (id, key) => `${id}/${takeRelativeFile(key)}`;
14318
- function fileDoc(key, probed, t0 = 0) {
14319
- const meta = {
14320
- dpr: 1,
14321
- zoom: 1,
14322
- t0,
14323
- durationMs: probed.durationMs,
14324
- width: probed.width,
14325
- height: probed.height,
14326
- captureWidth: probed.width,
14327
- captureHeight: probed.height,
14328
- fps: probed.fps > 0 ? probed.fps : 30,
14329
- hasAudio: probed.hasAudio,
14330
- producer: "ingest"
14657
+ // src/plugin/beats.ts
14658
+ import { docMediaList as docMediaList3, mediaKey as mediaKey2, rejectSpan } from "@vosjs/studio-core";
14659
+ var PER_BEAT_NOTE = "one zoom per beat";
14660
+ var ZOOM_FALSE_NOTE = "zoom: false in the script";
14661
+ var ZOOM_MODES = ["per-beat"];
14662
+ function zoomModeOf(raw) {
14663
+ if (raw === void 0) return null;
14664
+ if (ZOOM_MODES.includes(raw)) return raw;
14665
+ return {
14666
+ error: `--zooms expects ${ZOOM_MODES.join(" or ")}, got "${raw}"`
14331
14667
  };
14332
- const doc = projectFromArtifact2({ videoKey: key, cursor: [], meta }, key, {
14333
- footage: true
14334
- }).doc;
14335
- doc.segments = [{ in: 0, out: round32(probed.durationMs / 1e3) }];
14336
- return doc;
14337
14668
  }
14338
- function endingLayers(doc) {
14339
- const pieces = ratedSegments9(doc);
14340
- const end = totalDuration2(pieces);
14341
- if (!(end > 0)) return [];
14342
- return (doc.overlays ?? []).filter((o) => {
14343
- const start = o.anchor ? resolveAnchor6(doc, o.anchor, pieces).start ?? o.start : o.start;
14344
- return start >= end - 1e-3;
14669
+ var LAST_GESTURE_VERBS = [
14670
+ "click",
14671
+ "drag",
14672
+ "type",
14673
+ "paste"
14674
+ ];
14675
+ function zoomMarkOf(step, actions, script, recorded = []) {
14676
+ const inList = (list) => {
14677
+ const raw = scriptStepOf(step, list, recorded);
14678
+ return typeof raw?.zoom === "boolean" ? raw.zoom : void 0;
14679
+ };
14680
+ return inList(actions?.steps) ?? (typeof step.zoom === "boolean" ? step.zoom : void 0) ?? inList(script?.steps);
14681
+ }
14682
+ var beatStep = (s) => ({
14683
+ step: s.step,
14684
+ ...s.id ? { id: s.id } : {},
14685
+ do: s.do,
14686
+ in: s.tStart,
14687
+ out: Math.max(s.tStart, s.tEnd)
14688
+ });
14689
+ function markedSteps(source, actions) {
14690
+ return [...source.meta.steps ?? []].filter((s) => typeof s.tStart === "number" && !s.skipped).sort((a, b) => a.tStart - b.tStart || a.step - b.step).map((s) => ({
14691
+ s,
14692
+ mark: zoomMarkOf(s, actions, source.script, source.meta.steps ?? [])
14693
+ }));
14694
+ }
14695
+ function mediaBeats(media, source, actions) {
14696
+ const end = (source.meta.durationMs || 0) / 1e3;
14697
+ const starts = [];
14698
+ for (const step of source.meta.steps ?? []) {
14699
+ if (typeof step.tStart !== "number") continue;
14700
+ const caption = captionOf(
14701
+ step,
14702
+ actions,
14703
+ source.script,
14704
+ source.meta.steps ?? []
14705
+ );
14706
+ if (caption) starts.push({ step, caption });
14707
+ }
14708
+ if (!starts.length) return [];
14709
+ starts.sort((a, b) => a.step.tStart - b.step.tStart);
14710
+ const steps = markedSteps(source, actions);
14711
+ const beat = (step, caption, from, to, last) => {
14712
+ const mine = steps.filter(
14713
+ (x) => x.s.tStart >= from && (x.s.tStart < to || last)
14714
+ );
14715
+ const gesture = mine.filter((x) => LAST_GESTURE_VERBS.includes(x.s.do) && x.mark !== false).at(-1);
14716
+ return {
14717
+ media,
14718
+ step,
14719
+ caption,
14720
+ in: from,
14721
+ out: to,
14722
+ marked: mine.filter((x) => x.mark === true).map((x) => beatStep(x.s)),
14723
+ ...gesture ? { lastGesture: beatStep(gesture.s) } : {}
14724
+ };
14725
+ };
14726
+ const beats = [];
14727
+ if (starts[0].step.tStart > 0)
14728
+ beats.push(beat(null, null, 0, starts[0].step.tStart, false));
14729
+ starts.forEach(({ step, caption }, i) => {
14730
+ const next = starts[i + 1];
14731
+ beats.push(
14732
+ beat(
14733
+ step.id ?? step.step,
14734
+ caption,
14735
+ step.tStart,
14736
+ next ? next.step.tStart : Math.max(end, step.tStart),
14737
+ !next
14738
+ )
14739
+ );
14345
14740
  });
14741
+ return beats;
14742
+ }
14743
+ function playedSeconds(doc, z) {
14744
+ const key = mediaKey2(z.media);
14745
+ const segments = doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }];
14746
+ let played = 0;
14747
+ for (const s of segments) {
14748
+ if (mediaKey2(s.media) !== key) continue;
14749
+ played += Math.max(0, Math.min(s.out, z.out) - Math.max(s.in, z.in));
14750
+ }
14751
+ return played;
14752
+ }
14753
+ var ms = (v) => Math.round(v * 1e3);
14754
+ function stepOverlap(z, step) {
14755
+ if (!step) return 0;
14756
+ const shared = Math.min(z.out, step.out) - Math.max(z.in, step.in);
14757
+ if (shared > 0) return shared;
14758
+ return step.out - step.in < 1e-3 && z.in <= step.in && step.in <= z.out ? 1e-6 : 0;
14759
+ }
14760
+ function onStep(z, step) {
14761
+ const shorter = Math.min(z.out - z.in, step.out - step.in);
14762
+ return shorter > 0 ? stepOverlap(z, step) > shorter / 2 : stepOverlap(z, step) > 0;
14763
+ }
14764
+ function stronger(doc) {
14765
+ return (a, b) => ms(playedSeconds(doc, b)) - ms(playedSeconds(doc, a)) || ms(b.out - b.in) - ms(a.out - a.in) || b.level - a.level || a.in - b.in;
14766
+ }
14767
+ var overlaps2 = (a, b) => a.in < b.out && b.in < a.out;
14768
+ function takeMarksZoom(doc, actions) {
14769
+ return docMediaList3(doc).some(
14770
+ ({ id, source }) => markedSteps(source, actions.get(id)).some((x) => x.mark === true)
14771
+ );
14346
14772
  }
14347
- function dropEnding(d, label, notes) {
14348
- const ending = endingLayers(d);
14349
- const legacy = d.endCard !== void 0;
14350
- if (!ending.length && !legacy) return;
14351
- const gone = new Set(ending);
14352
- d.overlays = (d.overlays ?? []).filter((o) => !gone.has(o));
14353
- if (!d.overlays.length) delete d.overlays;
14354
- if (legacy) delete d.endCard;
14773
+ function rejectAll(doc, drop, note) {
14774
+ if (!drop.size) return 0;
14775
+ const rejected = [...doc.rejected ?? []];
14776
+ for (const z of doc.zoom) {
14777
+ if (!drop.has(z)) continue;
14778
+ const entry = rejectSpan(rejected, "zoom", z, note);
14779
+ if (z.media) entry.media = z.media;
14780
+ rejected.push(entry);
14781
+ }
14782
+ doc.rejected = rejected;
14783
+ doc.zoom = doc.zoom.filter((z) => !drop.has(z));
14784
+ return drop.size;
14785
+ }
14786
+ function oneZoomPerBeat(doc, actions, opts = {}) {
14787
+ const fallback = !takeMarksZoom(doc, actions);
14788
+ const report = {
14789
+ fallback,
14790
+ kept: [],
14791
+ none: [],
14792
+ manual: [],
14793
+ rejected: 0,
14794
+ uncaptioned: [],
14795
+ beats: 0
14796
+ };
14797
+ const drop = /* @__PURE__ */ new Set();
14798
+ const refused = /* @__PURE__ */ new Set();
14799
+ const order = stronger(doc);
14800
+ for (const { id: media, source } of docMediaList3(doc)) {
14801
+ if (opts.only && !opts.only.has(media)) continue;
14802
+ const beats = mediaBeats(media, source, actions.get(media));
14803
+ const spans = doc.zoom.filter((z) => mediaKey2(z.media) === media);
14804
+ if (!beats.length) {
14805
+ if (spans.length) report.uncaptioned.push(media);
14806
+ continue;
14807
+ }
14808
+ const never = markedSteps(source, actions.get(media)).filter((x) => x.mark === false).map((x) => beatStep(x.s));
14809
+ for (const z of spans) if (never.some((s) => onStep(z, s))) refused.add(z);
14810
+ for (const beat of beats) {
14811
+ const inBeat = spans.filter((z) => {
14812
+ const mid = (z.in + z.out) / 2;
14813
+ return mid >= beat.in && mid < beat.out;
14814
+ });
14815
+ if (inBeat.length) report.beats++;
14816
+ const manual = inBeat.filter((z) => z.source === "manual");
14817
+ const auto = inBeat.filter((z) => z.source !== "manual");
14818
+ if (manual.length) {
14819
+ for (const z of auto) drop.add(z);
14820
+ if (auto.length)
14821
+ report.manual.push({
14822
+ media,
14823
+ step: beat.step,
14824
+ ids: manual.map((z) => z.id)
14825
+ });
14826
+ continue;
14827
+ }
14828
+ const words2 = {
14829
+ media,
14830
+ step: beat.step,
14831
+ caption: beat.caption
14832
+ };
14833
+ if (!inBeat.length) {
14834
+ if (beat.step !== null)
14835
+ report.none.push({
14836
+ ...words2,
14837
+ why: "empty",
14838
+ ids: [],
14839
+ ...beat.marked.length ? {
14840
+ marked: beat.marked.map((m) => ({ step: m.step, do: m.do }))
14841
+ } : {}
14842
+ });
14843
+ continue;
14844
+ }
14845
+ const targets = fallback ? beat.step === null || !beat.lastGesture ? [] : [beat.lastGesture] : beat.marked;
14846
+ if (!targets.length) {
14847
+ for (const z of auto) drop.add(z);
14848
+ if (auto.length)
14849
+ report.none.push({
14850
+ ...words2,
14851
+ why: !fallback ? "unmarked" : beat.step === null ? "opening" : "no-gesture",
14852
+ ids: auto.map((z) => z.id)
14853
+ });
14854
+ continue;
14855
+ }
14856
+ const candidates = auto.filter((z) => !never.some((s) => onStep(z, s)));
14857
+ const chosen = [];
14858
+ for (const target of targets) {
14859
+ const action = { step: target.step, do: target.do };
14860
+ const [best] = candidates.filter(
14861
+ (z) => stepOverlap(z, target) > 0 && !chosen.some((c) => c === z || overlaps2(c, z))
14862
+ ).sort(
14863
+ (a, b) => ms(stepOverlap(b, target)) - ms(stepOverlap(a, target)) || order(a, b)
14864
+ );
14865
+ if (!best) {
14866
+ report.none.push({ ...words2, why: "no-proposal", action, ids: [] });
14867
+ continue;
14868
+ }
14869
+ chosen.push(best);
14870
+ report.kept.push({ ...words2, action, id: best.id });
14871
+ }
14872
+ for (const z of chosen) z.source = "manual";
14873
+ for (const z of auto) if (!chosen.includes(z)) drop.add(z);
14874
+ }
14875
+ }
14876
+ const onNever = new Set([...drop].filter((z) => refused.has(z)));
14877
+ for (const z of onNever) drop.delete(z);
14878
+ report.rejected = rejectAll(doc, onNever, ZOOM_FALSE_NOTE) + rejectAll(doc, drop, PER_BEAT_NOTE);
14879
+ return report;
14880
+ }
14881
+ function applyZoomMarks(doc, actions) {
14882
+ const report = { rejected: [], unproposed: [] };
14883
+ const drop = /* @__PURE__ */ new Set();
14884
+ for (const { id: media, source } of docMediaList3(doc)) {
14885
+ const spans = doc.zoom.filter((z) => mediaKey2(z.media) === media);
14886
+ for (const { s, mark } of markedSteps(source, actions.get(media))) {
14887
+ const step = beatStep(s);
14888
+ if (mark === false) {
14889
+ const on = spans.filter(
14890
+ (z) => z.source !== "manual" && !drop.has(z) && onStep(z, step)
14891
+ );
14892
+ for (const z of on) drop.add(z);
14893
+ if (on.length)
14894
+ report.rejected.push({
14895
+ media,
14896
+ step: s.step,
14897
+ ids: on.map((z) => z.id)
14898
+ });
14899
+ } else if (mark === true && !spans.some((z) => !drop.has(z) && stepOverlap(z, step) > 0)) {
14900
+ report.unproposed.push({
14901
+ media,
14902
+ step: s.step,
14903
+ ...s.id ? { id: s.id } : {},
14904
+ do: s.do
14905
+ });
14906
+ }
14907
+ }
14908
+ }
14909
+ rejectAll(doc, drop, ZOOM_FALSE_NOTE);
14910
+ return report;
14911
+ }
14912
+ function zoomMarkLine(r, name = (m) => m ? `media ${m}` : "the primary") {
14913
+ const at2 = (x) => `#${x.step}${x.media ? ` of ${name(x.media)}` : ""}`;
14914
+ const parts = [];
14915
+ if (r.rejected.length)
14916
+ parts.push(
14917
+ `zoom: false rejected ${r.rejected.map((x) => `${x.ids.join(", ")} on step ${at2(x)}`).join("; ")}`
14918
+ );
14919
+ if (r.unproposed.length)
14920
+ parts.push(
14921
+ `zoom: true on ${r.unproposed.map((x) => `step ${at2(x)} ${x.id ? `${x.id} ` : ""}${x.do}`).join(", ")}, but the planner proposed no zoom there (none is invented)`
14922
+ );
14923
+ return parts.length ? parts.join("; ") : null;
14924
+ }
14925
+ function perBeatLine(r, name = (m) => m ? `media ${m}` : "the primary") {
14926
+ const parts = [
14927
+ `one zoom per beat: ${r.beats} beat${r.beats === 1 ? "" : "s"} held zooms, ${r.kept.length} kept (now manual), ${r.rejected} rejected into doc.rejected (no re-plan brings them back)`
14928
+ ];
14929
+ if (r.fallback)
14930
+ parts.push(
14931
+ `no step is marked "zoom": falling back to each beat's last gesture`
14932
+ );
14933
+ const beatName = (b) => b.step === null ? `the opening${b.media ? ` of ${name(b.media)}` : ""}` : `beat ${b.caption ? JSON.stringify(b.caption) : String(b.step)}${b.media ? ` (${name(b.media)})` : ""}`;
14934
+ const target = (a) => `${r.fallback ? "last gesture" : "marked"} #${a.step} ${a.do}`;
14935
+ for (const b of r.kept)
14936
+ parts.push(`${beatName(b)}: ${target(b.action)}, kept ${b.id}`);
14937
+ for (const b of r.none)
14938
+ parts.push(
14939
+ `${beatName(b)}: ${b.why === "empty" ? `no proposal in this beat${b.marked?.length ? ` (marked ${b.marked.map((m) => `#${m.step} ${m.do}`).join(", ")})` : ""}` : b.why === "no-proposal" && b.action ? `${target(b.action)}, but no proposal on it` : b.why === "unmarked" ? 'no step marked "zoom", no zoom' : b.why === "no-gesture" ? "no gesture to stand in, no zoom" : "no zoom"}`
14940
+ );
14941
+ if (r.manual.length)
14942
+ parts.push(
14943
+ `${r.manual.length} beat${r.manual.length === 1 ? "" : "s"} kept ${r.manual.length === 1 ? "its" : "their"} manual zoom${r.manual.some((m) => m.ids.length > 1) ? "s" : ""}`
14944
+ );
14945
+ if (r.uncaptioned.length)
14946
+ parts.push(
14947
+ `no captioned step in ${r.uncaptioned.map(name).join(", ")}, so ${r.uncaptioned.length === 1 ? "its" : "their"} zooms stay as planned`
14948
+ );
14949
+ return parts.join("; ");
14950
+ }
14951
+
14952
+ // src/plugin/rejoin.ts
14953
+ import { createHash as createHash4 } from "crypto";
14954
+ import { createReadStream, existsSync as existsSync24 } from "fs";
14955
+ import { readFile as readFile16 } from "fs/promises";
14956
+ import { join as join26 } from "path";
14957
+ import {
14958
+ isRejected as isRejected2,
14959
+ layerStart as layerStart3,
14960
+ mediaKey as mediaKey3,
14961
+ mediaRefId,
14962
+ ratedSegments as ratedSegments9,
14963
+ resolveAnchor as resolveAnchor6,
14964
+ segmentOutputExtents as segmentOutputExtents3
14965
+ } from "@vosjs/studio-core";
14966
+ import { totalDuration as totalDuration2 } from "@vosjs/timeline";
14967
+ import { splitSegmentsAtSkips as splitSegmentsAtSkips2 } from "@vosjs/render-core/record";
14968
+ var JOIN_RECORD_NAME = "join.json";
14969
+ var round32 = (v) => Math.round(v * 1e3) / 1e3;
14970
+ function fileHash(file) {
14971
+ return new Promise((done, fail) => {
14972
+ const h = createHash4("sha256");
14973
+ createReadStream(file).on("data", (c) => h.update(c)).on("error", fail).on("end", () => done(h.digest("hex")));
14974
+ });
14975
+ }
14976
+ async function partFootage(part) {
14977
+ if (part.file) return fileHash(part.file.path);
14978
+ const key = part.doc.source.videoKey;
14979
+ if (!isTakeRelativeKey(key))
14980
+ return `key:${createHash4("sha256").update(key).digest("hex")}`;
14981
+ const file = join26(part.dir, takeRelativeFile(key));
14982
+ return existsSync24(file) ? fileHash(file) : `key:${createHash4("sha256").update(key).digest("hex")}`;
14983
+ }
14984
+ async function readJoinRecord(dir) {
14985
+ const file = join26(dir, JOIN_RECORD_NAME);
14986
+ if (!existsSync24(file)) return null;
14987
+ try {
14988
+ const v = JSON.parse(await readFile16(file, "utf8"));
14989
+ return v && v.version === 1 && Array.isArray(v.inputs) ? v : null;
14990
+ } catch {
14991
+ return null;
14992
+ }
14993
+ }
14994
+ async function recordFromFootage(dir, doc) {
14995
+ const hash = async (key) => {
14996
+ if (!isTakeRelativeKey(key))
14997
+ return `key:${createHash4("sha256").update(key).digest("hex")}`;
14998
+ const file = join26(dir, takeRelativeFile(key));
14999
+ return existsSync24(file) ? fileHash(file) : `missing:${key}`;
15000
+ };
15001
+ const inputs = [
15002
+ {
15003
+ path: "",
15004
+ kind: "take",
15005
+ footage: await hash(doc.source.videoKey),
15006
+ media: ""
15007
+ }
15008
+ ];
15009
+ for (const m of doc.media ?? [])
15010
+ inputs.push({
15011
+ path: "",
15012
+ kind: "take",
15013
+ footage: await hash(m.videoKey),
15014
+ media: m.id
15015
+ });
15016
+ return { version: 1, inputs };
15017
+ }
15018
+ function joinRecordOf(inputs, ids) {
15019
+ return {
15020
+ version: 1,
15021
+ inputs: inputs.map((x, i) => {
15022
+ const sub = ids[i]?.sub ?? {};
15023
+ return {
15024
+ path: x.path,
15025
+ kind: x.part.kind,
15026
+ footage: x.footage,
15027
+ media: ids[i]?.id ?? "",
15028
+ ...Object.keys(sub).length ? { sub } : {}
15029
+ };
15030
+ })
15031
+ };
15032
+ }
15033
+ function matchInputs(prev, next) {
15034
+ const match = next.map(() => null);
15035
+ const claimed = /* @__PURE__ */ new Set();
15036
+ const pass = (same) => {
15037
+ next.forEach((n, i) => {
15038
+ if (match[i] !== null) return;
15039
+ const j = prev.findIndex(
15040
+ (p, k) => !claimed.has(k) && p.footage === n.footage && same(p, n)
15041
+ );
15042
+ if (j >= 0) {
15043
+ match[i] = j;
15044
+ claimed.add(j);
15045
+ }
15046
+ });
15047
+ };
15048
+ pass((p, n) => p.path !== "" && p.path === n.path);
15049
+ pass(() => true);
15050
+ const changed = [];
15051
+ const removed = [];
15052
+ prev.forEach((p, j) => {
15053
+ if (claimed.has(j)) return;
15054
+ const back = p.path !== "" && next.some((n, i) => match[i] === null && n.path === p.path);
15055
+ (back ? changed : removed).push(j);
15056
+ });
15057
+ return { match, changed, removed };
15058
+ }
15059
+ var keysOf = (x) => [
15060
+ x.media,
15061
+ ...Object.values(x.sub ?? {})
15062
+ ];
15063
+ var scriptUrl = (m) => {
15064
+ const s = m?.script;
15065
+ return typeof s?.url === "string" ? s.url : void 0;
15066
+ };
15067
+ var SAME_SCRIPT = 0.5;
15068
+ function rerecordedInputs(prev, record, inputs, m) {
15069
+ const pairs = /* @__PURE__ */ new Map();
15070
+ const mediaOf = (k) => (prev.media ?? []).find((x) => x.id === k);
15071
+ const fit = (j) => {
15072
+ const x = record.inputs[j];
15073
+ if (x.kind !== "take" || x.media === "" || Object.keys(x.sub ?? {}).length)
15074
+ return null;
15075
+ const media = mediaOf(x.media);
15076
+ const steps = media?.meta.steps ?? [];
15077
+ return media && steps.length ? { media, steps } : null;
15078
+ };
15079
+ const open = (i) => i > 0 && m.match[i] === null && !pairs.has(i) && inputs[i].part.kind === "take" && !(inputs[i].part.doc.media ?? []).length && !!inputs[i].part.doc.source.meta.steps?.length;
15080
+ for (const j of m.changed) {
15081
+ if (!fit(j)) continue;
15082
+ const i = inputs.findIndex(
15083
+ (n, at2) => open(at2) && n.path === record.inputs[j].path
15084
+ );
15085
+ if (i >= 0) pairs.set(i, j);
15086
+ }
15087
+ for (const j of m.removed) {
15088
+ const old = fit(j);
15089
+ if (!old) continue;
15090
+ const ids = [
15091
+ ...new Set(old.steps.map((s) => s.id).filter((v) => !!v))
15092
+ ];
15093
+ if (!ids.length) continue;
15094
+ const url = scriptUrl(old.media);
15095
+ let best = -1;
15096
+ let bestScore = SAME_SCRIPT;
15097
+ let tie = false;
15098
+ inputs.forEach((n, i) => {
15099
+ if (!open(i)) return;
15100
+ const there = scriptUrl(n.part.doc.source) ?? n.part.actions?.url;
15101
+ if (url && there && url !== there) return;
15102
+ const now = new Set((n.part.doc.source.meta.steps ?? []).map((s) => s.id));
15103
+ const score = ids.filter((id) => now.has(id)).length / ids.length;
15104
+ if (score > bestScore) {
15105
+ best = i;
15106
+ bestScore = score;
15107
+ tie = false;
15108
+ } else if (score === bestScore && best >= 0) tie = true;
15109
+ });
15110
+ if (best >= 0 && !tie) pairs.set(best, j);
15111
+ }
15112
+ return pairs;
15113
+ }
15114
+ function mediaView(doc, media, segments) {
15115
+ const own = (xs) => (xs ?? []).filter((x) => mediaKey3(x.media) === media.id).map((x) => {
15116
+ const { media: _, ...rest } = x;
15117
+ return rest;
15118
+ });
15119
+ return {
15120
+ ...doc,
15121
+ source: {
15122
+ videoKey: media.videoKey,
15123
+ cursor: media.cursor,
15124
+ meta: media.meta
15125
+ },
15126
+ segments: own(segments),
15127
+ zoom: own(doc.zoom),
15128
+ speed: own(doc.speed),
15129
+ tilt: own(doc.tilt),
15130
+ freeze: own(doc.freeze),
15131
+ rejected: own(doc.rejected),
15132
+ media: void 0
15133
+ };
15134
+ }
15135
+ function rejoinDocs(prev, record, inputs, opts = {}) {
15136
+ const where = opts.where ?? ((p) => p);
15137
+ const { match, changed, removed } = matchInputs(record.inputs, inputs);
15138
+ const first = match[0] === null ? null : record.inputs[match[0]];
15139
+ if (!first || first.media !== "")
15140
+ throw new UsageError(
15141
+ `--keep-edits: ${inputs[0].part.label} is not the primary this take was joined from (its first input, footage unchanged). The primary carries the cut's frame and camera; keep it first, or join afresh with --force`
15142
+ );
15143
+ const redo = rerecordedInputs(prev, record, inputs, {
15144
+ match,
15145
+ changed,
15146
+ removed
15147
+ });
15148
+ const redoneFrom = new Set(redo.values());
15149
+ const isNew = (i) => match[i] === null && !redo.has(i);
15150
+ const parts = inputs.map((x, i) => {
15151
+ const j = match[i];
15152
+ if (i === 0 || j === null) return x.part;
15153
+ const was = record.inputs[j];
15154
+ return { ...x.part, id: was.media, ...was.sub ? { subIds: was.sub } : {} };
15155
+ });
15156
+ const reserved = /* @__PURE__ */ new Set([
15157
+ ...record.inputs.flatMap(keysOf),
15158
+ ...(prev.media ?? []).map((m) => m.id)
15159
+ ]);
15160
+ const fresh = joinDocs(parts, { reserved });
15161
+ const argOf = /* @__PURE__ */ new Map();
15162
+ const newIds = /* @__PURE__ */ new Set();
15163
+ const kept = /* @__PURE__ */ new Set();
15164
+ fresh.ids.forEach((ids, i) => {
15165
+ for (const k of [ids.id, ...Object.values(ids.sub)]) {
15166
+ argOf.set(k, i);
15167
+ if (isNew(i)) newIds.add(k);
15168
+ }
15169
+ });
15170
+ match.forEach((j, i) => {
15171
+ if (j === null) return;
15172
+ for (const k of keysOf(record.inputs[j])) kept.add(k);
15173
+ for (const k of [fresh.ids[i].id, ...Object.values(fresh.ids[i].sub)])
15174
+ kept.add(k);
15175
+ });
15176
+ kept.add("");
15177
+ const dropped = /* @__PURE__ */ new Set();
15178
+ for (const k of [
15179
+ ...record.inputs.flatMap(keysOf),
15180
+ ...(prev.media ?? []).map((m) => m.id)
15181
+ ])
15182
+ if (!kept.has(k)) dropped.add(k);
15183
+ const report = {
15184
+ kept: [],
15185
+ added: [],
15186
+ dropped: [],
15187
+ rerecorded: [],
15188
+ regrouped: false,
15189
+ shifted: [],
15190
+ notes: []
15191
+ };
15192
+ inputs.forEach((x, i) => {
15193
+ const j = match[i];
15194
+ if (j === null) {
15195
+ if (isNew(i))
15196
+ report.added.push({ label: x.part.label, id: fresh.ids[i].id });
15197
+ return;
15198
+ }
15199
+ const was = record.inputs[j];
15200
+ report.kept.push({
15201
+ label: x.part.label,
15202
+ id: was.media,
15203
+ ...was.path && was.path !== x.path ? { moved: where(was.path) } : {}
15204
+ });
15205
+ });
15206
+ const doc = structuredClone(prev);
15207
+ const keyOf = (s) => mediaKey3(s.media);
15208
+ const prevSegments = prev.segments.length ? prev.segments : [{ in: 0, out: round32(prev.source.meta.durationMs / 1e3) }];
15209
+ const prevExt = segmentOutputExtents3(prev);
15210
+ const prevFootage = totalDuration2(ratedSegments9(prev));
15211
+ const redoByOld = /* @__PURE__ */ new Map();
15212
+ for (const [i, j] of redo) {
15213
+ const from = record.inputs[j].media;
15214
+ const to = fresh.ids[i].id;
15215
+ const was = (prev.media ?? []).find((m) => m.id === from);
15216
+ const view = mediaView(prev, was, prevSegments);
15217
+ const meta = inputs[i].part.doc.source.meta;
15218
+ const rt = retimeCut(view, meta.steps ?? [], meta.durationMs);
15219
+ const cut = view.segments.length ? splitSegmentsAtSkips2(rt.segments, meta.skips) : [];
15220
+ const x = inputs[i];
15221
+ redoByOld.set(from, {
15222
+ i,
15223
+ from,
15224
+ to,
15225
+ rt,
15226
+ segments: cut.map((sg) => ({ ...sg, media: to })),
15227
+ placed: false,
15228
+ entry: {
15229
+ label: x.part.label,
15230
+ from,
15231
+ id: to,
15232
+ ...record.inputs[j].path && record.inputs[j].path !== x.path ? { was: where(record.inputs[j].path) } : {},
15233
+ segments: cut.length,
15234
+ spans: 0,
15235
+ layers: [],
15236
+ flagged: [...rt.report.flagged]
15237
+ }
15238
+ });
15239
+ }
15240
+ const redoTo = new Set([...redoByOld.values()].map((r) => r.to));
15241
+ let segments = prevSegments.flatMap((sg) => {
15242
+ const r = redoByOld.get(keyOf(sg));
15243
+ if (r) {
15244
+ if (r.placed) return [];
15245
+ r.placed = true;
15246
+ return r.segments;
15247
+ }
15248
+ return kept.has(keyOf(sg)) ? [sg] : [];
15249
+ });
15250
+ const keptOrder = match.filter((j) => j !== null);
15251
+ const inOrder = keptOrder.every((j, k) => k === 0 || keptOrder[k - 1] < j);
15252
+ if (!inOrder) {
15253
+ report.regrouped = true;
15254
+ segments = segments.map((s, at2) => ({ s, at: at2, arg: argOf.get(keyOf(s)) ?? 0 })).sort((a, b) => a.arg - b.arg || a.at - b.at).map((x) => x.s);
15255
+ }
15256
+ const freshSegs = fresh.doc.segments;
15257
+ const freshExt = segmentOutputExtents3(fresh.doc);
15258
+ const firstFresh = /* @__PURE__ */ new Map();
15259
+ inputs.forEach((_, i) => {
15260
+ if (!isNew(i)) return;
15261
+ const mine = freshSegs.map((s, at3) => ({ s, at: at3 })).filter((x) => argOf.get(keyOf(x.s)) === i);
15262
+ if (!mine.length) return;
15263
+ firstFresh.set(i, mine[0].at);
15264
+ let at2 = segments.length;
15265
+ for (let n = i + 1; n < inputs.length; n++) {
15266
+ if (isNew(n)) continue;
15267
+ const k = segments.findIndex((s) => argOf.get(keyOf(s)) === n);
15268
+ if (k >= 0) {
15269
+ at2 = k;
15270
+ break;
15271
+ }
15272
+ }
15273
+ segments.splice(at2, 0, ...mine.map((x) => x.s));
15274
+ });
15275
+ doc.segments = segments;
15276
+ const freshMedia = new Map((fresh.doc.media ?? []).map((m) => [m.id, m]));
15277
+ const media = [
15278
+ ...(prev.media ?? []).filter((m) => kept.has(m.id)).map((m) => {
15279
+ const f = freshMedia.get(m.id);
15280
+ return !m.script && f?.script ? { ...m, script: f.script } : m;
15281
+ }),
15282
+ ...(fresh.doc.media ?? []).filter(
15283
+ (m) => newIds.has(m.id) || redoTo.has(m.id)
15284
+ )
15285
+ ].map((m, at2) => ({ m, at: at2, arg: argOf.get(m.id) ?? 0 })).sort((a, b) => a.arg - b.arg || a.at - b.at).map((x) => x.m);
15286
+ if (media.length) doc.media = media;
15287
+ else delete doc.media;
15288
+ const spanLanes = [
15289
+ "zoom",
15290
+ "speed",
15291
+ "tilt",
15292
+ "camMotion",
15293
+ "freeze",
15294
+ "rejected"
15295
+ ];
15296
+ const dropCount = /* @__PURE__ */ new Map();
15297
+ const tally = (k, what) => {
15298
+ const c = dropCount.get(k) ?? { segments: 0, spans: 0 };
15299
+ c[what]++;
15300
+ dropCount.set(k, c);
15301
+ };
15302
+ for (const s of prevSegments)
15303
+ if (dropped.has(keyOf(s)) && !redoByOld.has(keyOf(s)))
15304
+ tally(keyOf(s), "segments");
15305
+ const redoSpans = (lane) => {
15306
+ const out = [];
15307
+ for (const r of redoByOld.values()) {
15308
+ const tag = (x) => ({ ...x, media: r.to });
15309
+ let mine;
15310
+ if (lane === "camMotion") {
15311
+ mine = (prev.camMotion ?? []).filter((x) => keyOf(x) === r.from).map(tag);
15312
+ } else {
15313
+ mine = r.rt[lane].map(tag);
15314
+ if (lane === "zoom" || lane === "speed" || lane === "tilt") {
15315
+ const rejected = r.rt.rejected;
15316
+ const proposed = (fresh.doc[lane] ?? []).filter(
15317
+ (x) => keyOf(x) === r.to && x.source === "auto" && !mine.some((m) => x.in < m.out && m.in < x.out) && !isRejected2(lane, x, rejected)
15318
+ );
15319
+ r.entry.spans += mine.length;
15320
+ mine = [...mine, ...proposed];
15321
+ } else if (lane === "rejected") {
15322
+ const fromTake = (fresh.doc.rejected ?? []).filter(
15323
+ (x) => keyOf(x) === r.to && !isRejected2(x.lane, x, r.rt.rejected)
15324
+ );
15325
+ r.entry.spans += mine.length;
15326
+ mine = [...mine, ...fromTake];
15327
+ } else r.entry.spans += mine.length;
15328
+ }
15329
+ if (lane === "camMotion") r.entry.spans += mine.length;
15330
+ out.push(...mine);
15331
+ }
15332
+ return out;
15333
+ };
15334
+ for (const lane of spanLanes) {
15335
+ const before = prev[lane] ?? [];
15336
+ for (const s of before)
15337
+ if (dropped.has(keyOf(s)) && !redoByOld.has(keyOf(s)))
15338
+ tally(keyOf(s), "spans");
15339
+ const next = [
15340
+ ...before.filter((s) => kept.has(keyOf(s))),
15341
+ ...redoSpans(lane),
15342
+ ...(fresh.doc[lane] ?? []).filter(
15343
+ (s) => newIds.has(keyOf(s))
15344
+ )
15345
+ ];
15346
+ if (next.length) doc[lane] = next;
15347
+ else delete doc[lane];
15348
+ }
15349
+ const newExt = segmentOutputExtents3(doc);
15350
+ const newFootage = totalDuration2(ratedSegments9(doc));
15351
+ const placeOf = /* @__PURE__ */ new Map();
15352
+ segments.forEach((s, i) => placeOf.set(s, i));
15353
+ const follow = (t) => {
15354
+ for (let i = 0; i < prevExt.length; i++) {
15355
+ const e = prevExt[i];
15356
+ if (t >= e.start - 1e-6 && t < e.end - 1e-6) {
15357
+ const at2 = placeOf.get(prevSegments[i]);
15358
+ if (at2 === void 0) return null;
15359
+ const moved = newExt[at2].start + (t - e.start);
15360
+ return Math.abs(moved - t) < 1e-9 ? t : round32(moved);
15361
+ }
15362
+ }
15363
+ if (t >= prevFootage - 1e-6) {
15364
+ const moved = newFootage + (t - prevFootage);
15365
+ return Math.abs(moved - t) < 1e-9 ? t : round32(moved);
15366
+ }
15367
+ return t;
15368
+ };
15369
+ const deltaOf = (k) => {
15370
+ const i = argOf.get(k);
15371
+ if (i === void 0) return 0;
15372
+ const at2 = firstFresh.get(i);
15373
+ if (at2 === void 0) return 0;
15374
+ const now = placeOf.get(freshSegs[at2]);
15375
+ return now === void 0 ? 0 : newExt[now].start - freshExt[at2].start;
15376
+ };
15377
+ const droppedItems = /* @__PURE__ */ new Map();
15378
+ const lose = (k, what, id) => {
15379
+ const c = droppedItems.get(k) ?? { layers: [], sound: [] };
15380
+ c[what].push(id);
15381
+ droppedItems.set(k, c);
15382
+ };
15383
+ const footageAt = (t) => {
15384
+ for (let i = 0; i < prevExt.length; i++) {
15385
+ const e = prevExt[i];
15386
+ if (t >= e.start - 1e-6 && t < e.end - 1e-6) return keyOf(prevSegments[i]);
15387
+ }
15388
+ return null;
15389
+ };
15390
+ const blockOf = (segs, ext2, k) => {
15391
+ const at2 = segs.flatMap((sg, i) => keyOf(sg) === k ? [ext2[i]] : []);
15392
+ return at2.length ? { start: at2[0].start, end: at2[at2.length - 1].end } : null;
15393
+ };
15394
+ const place2 = (t) => {
15395
+ const k = footageAt(t);
15396
+ const r = k === null ? void 0 : redoByOld.get(k);
15397
+ if (!r) return follow(t);
15398
+ const was = blockOf(prevSegments, prevExt, r.from);
15399
+ const now = blockOf(segments, newExt, r.to);
15400
+ if (!was || !now) return null;
15401
+ const moved = now.start + (t - was.start);
15402
+ if (moved >= now.end - 1e-6) return null;
15403
+ return Math.abs(moved - t) < 1e-9 ? t : round32(moved);
15404
+ };
15405
+ const loseOver = (t, what) => {
15406
+ const r = redoByOld.get(footageAt(t) ?? "\0");
15407
+ if (!r) return false;
15408
+ r.entry.flagged.push(
15409
+ `${what} (${t.toFixed(2)} s): it played over this input's footage past where its new recording ends; dropped`
15410
+ );
15411
+ return true;
15412
+ };
15413
+ const overlays = [];
15414
+ const redoLayers = /* @__PURE__ */ new Map();
15415
+ for (const prevLayer of prev.overlays ?? []) {
15416
+ let o = prevLayer;
15417
+ const refMedia = o.kind !== "text" && o.kind !== "html" ? mediaRefId(o.key) : null;
15418
+ const owner = o.anchor ? mediaKey3(o.anchor.media) : null;
15419
+ const goes = (k) => k !== null && dropped.has(k) && !redoByOld.has(k);
15420
+ const gone = goes(owner) && owner || goes(refMedia) && refMedia || null;
15421
+ if (gone) {
15422
+ lose(gone, "layers", o.id);
15423
+ continue;
15424
+ }
15425
+ const byOwner = owner === null ? void 0 : redoByOld.get(owner);
15426
+ if (byOwner && o.anchor) {
15427
+ o = { ...o, anchor: { ...o.anchor, media: byOwner.to } };
15428
+ redoLayers.set(o.id, byOwner);
15429
+ }
15430
+ const byRef = refMedia === null ? void 0 : redoByOld.get(refMedia);
15431
+ if (byRef && o.kind !== "text" && o.kind !== "html") {
15432
+ o = { ...o, key: `media:${byRef.to}` };
15433
+ redoLayers.set(o.id, byRef);
15434
+ }
15435
+ if (o.anchor || o.start === void 0) {
15436
+ overlays.push(o);
15437
+ continue;
15438
+ }
15439
+ const t = place2(o.start);
15440
+ if (t === null) {
15441
+ if (!loseOver(o.start, `layer ${o.id}`))
15442
+ lose(footageAt(o.start) ?? "", "layers", o.id);
15443
+ continue;
15444
+ }
15445
+ const over = redoByOld.get(footageAt(o.start) ?? "\0");
15446
+ if (over) redoLayers.set(o.id, over);
15447
+ if (t !== o.start) report.shifted.push(o.id);
15448
+ overlays.push(t === o.start ? o : { ...o, start: t });
15449
+ }
15450
+ for (const o of fresh.doc.overlays ?? []) {
15451
+ const k = fresh.owners.get(o.id);
15452
+ if (k === void 0 || !newIds.has(k)) continue;
15453
+ const d = o.anchor ? 0 : deltaOf(k);
15454
+ overlays.push(
15455
+ d && o.start !== void 0 ? { ...o, start: round32(o.start + d) } : o
15456
+ );
15457
+ }
15458
+ if (overlays.length) doc.overlays = overlays;
15459
+ else delete doc.overlays;
15460
+ const objects = [];
15461
+ for (const ob of prev.objects ?? []) {
15462
+ if (!ob.span) {
15463
+ objects.push(ob);
15464
+ continue;
15465
+ }
15466
+ const t = place2(ob.span.start);
15467
+ if (t === null) {
15468
+ if (!loseOver(ob.span.start, `prop ${ob.id}`))
15469
+ lose(footageAt(ob.span.start) ?? "", "layers", ob.id);
15470
+ continue;
15471
+ }
15472
+ if (t !== ob.span.start) report.shifted.push(ob.id);
15473
+ objects.push(
15474
+ t === ob.span.start ? ob : { ...ob, span: { ...ob.span, start: t } }
15475
+ );
15476
+ }
15477
+ for (const ob of fresh.doc.objects ?? []) {
15478
+ const k = fresh.owners.get(ob.id);
15479
+ if (k === void 0 || !newIds.has(k)) continue;
15480
+ const d = deltaOf(k);
15481
+ objects.push(
15482
+ d && ob.span ? { ...ob, span: { ...ob.span, start: round32(ob.span.start + d) } } : ob
15483
+ );
15484
+ }
15485
+ if (objects.length) doc.objects = objects;
15486
+ else delete doc.objects;
15487
+ const allPrev = /* @__PURE__ */ new Set([...dropped, ...kept]);
15488
+ const audio = [];
15489
+ for (const a of prev.audio) {
15490
+ const m = /^(.*)-sound$/.exec(a.id);
15491
+ const k = m && allPrev.has(m[1]) ? m[1] : null;
15492
+ if (k === null) {
15493
+ audio.push(a);
15494
+ continue;
15495
+ }
15496
+ const r = redoByOld.get(k);
15497
+ if (r) {
15498
+ r.entry.flagged.push(
15499
+ `sound ${a.id}: it was the old recording's own sound; dropped`
15500
+ );
15501
+ continue;
15502
+ }
15503
+ if (dropped.has(k)) {
15504
+ lose(k, "sound", a.id);
15505
+ continue;
15506
+ }
15507
+ const t = follow(a.start);
15508
+ if (t === null) {
15509
+ lose(k, "sound", a.id);
15510
+ continue;
15511
+ }
15512
+ if (t !== a.start) report.shifted.push(a.id);
15513
+ audio.push(t === a.start ? a : { ...a, start: t });
15514
+ }
15515
+ for (const a of fresh.doc.audio) {
15516
+ const k = fresh.owners.get(a.id);
15517
+ if (k === void 0 || !newIds.has(k)) continue;
15518
+ const d = deltaOf(k);
15519
+ audio.push(d ? { ...a, start: round32(a.start + d) } : a);
15520
+ }
15521
+ doc.audio = audio;
15522
+ if (typeof prev.still === "number") {
15523
+ const t = place2(prev.still);
15524
+ if (t === null) {
15525
+ delete doc.still;
15526
+ report.notes.push(
15527
+ `the still (${prev.still.toFixed(2)} s) played over footage that is gone; the cover falls back to its default (vos push --still sets one)`
15528
+ );
15529
+ } else if (t !== prev.still) {
15530
+ doc.still = t;
15531
+ report.shifted.push("still");
15532
+ }
15533
+ }
15534
+ const pieces = ratedSegments9(doc);
15535
+ if (doc.overlays) {
15536
+ doc.overlays = doc.overlays.flatMap((o) => {
15537
+ const r = o.anchor ? resolveAnchor6(doc, o.anchor, pieces) : null;
15538
+ const redone = redoLayers.get(o.id);
15539
+ if (o.anchor && r && r.start === null && redone) {
15540
+ redone.entry.flagged.push(
15541
+ `layer ${o.id}: anchored to step ${String(o.anchor.step)}, which the new recording does not have, skipped, or the cut leaves out; dropped`
15542
+ );
15543
+ return [];
15544
+ }
15545
+ if (redone) redone.entry.layers.push(o.id);
15546
+ return r && r.start !== null && o.start !== void 0 && r.start !== o.start ? [{ ...o, start: r.start }] : [o];
15547
+ });
15548
+ if (!doc.overlays.length) delete doc.overlays;
15549
+ }
15550
+ const prevPieces = ratedSegments9(prev);
15551
+ const wasEnding = new Set(
15552
+ (prev.overlays ?? []).filter((o) => {
15553
+ if (!o.anchor) return false;
15554
+ const r = resolveAnchor6(prev, o.anchor, prevPieces);
15555
+ return r.start !== null && r.start >= prevFootage - 1e-3;
15556
+ }).map((o) => o.id)
15557
+ );
15558
+ const overrun = (doc.overlays ?? []).filter((o) => {
15559
+ if (!wasEnding.has(o.id)) return false;
15560
+ const start = layerStart3(o, doc, pieces);
15561
+ return start !== null && start < newFootage - 1e-3;
15562
+ }).map((o) => o.id);
15563
+ if (overrun.length)
15564
+ report.notes.push(
15565
+ `${overrun.join(", ")} played after the footage and, anchored to ${overrun.length === 1 ? "its" : "their"} step, now ${overrun.length === 1 ? "lands" : "land"} over the footage that follows; anchor ${overrun.length === 1 ? "it" : "them"} to the last input's end, or move ${overrun.length === 1 ? "it" : "them"}`
15566
+ );
15567
+ const labelOf = (k) => {
15568
+ const x = record.inputs.find((r) => keysOf(r).includes(k));
15569
+ return x?.path ? where(x.path) : k ? `media ${k}` : "the primary";
15570
+ };
15571
+ for (const j of [...changed, ...removed]) {
15572
+ if (redoneFrom.has(j)) continue;
15573
+ const x = record.inputs[j];
15574
+ for (const k of keysOf(x)) {
15575
+ const c = dropCount.get(k) ?? { segments: 0, spans: 0 };
15576
+ const items = droppedItems.get(k) ?? { layers: [], sound: [] };
15577
+ report.dropped.push({
15578
+ input: labelOf(k),
15579
+ id: k,
15580
+ why: changed.includes(j) ? "changed" : "removed",
15581
+ segments: c.segments,
15582
+ spans: c.spans,
15583
+ layers: items.layers,
15584
+ sound: items.sound
15585
+ });
15586
+ }
15587
+ }
15588
+ for (const k of dropped) {
15589
+ if (report.dropped.some((d) => d.id === k) || redoByOld.has(k)) continue;
15590
+ const c = dropCount.get(k) ?? { segments: 0, spans: 0 };
15591
+ const items = droppedItems.get(k) ?? { layers: [], sound: [] };
15592
+ report.dropped.push({
15593
+ input: `media ${k}`,
15594
+ id: k,
15595
+ why: "removed",
15596
+ segments: c.segments,
15597
+ spans: c.spans,
15598
+ layers: items.layers,
15599
+ sound: items.sound
15600
+ });
15601
+ }
15602
+ if (Math.abs(newFootage - prevFootage) > 1e-3) {
15603
+ const film = doc.audio.filter((a) => !/-sound$/.test(a.id));
15604
+ if (film.length)
15605
+ report.notes.push(
15606
+ `the footage now runs ${newFootage.toFixed(2)} s (was ${prevFootage.toFixed(2)} s); ${film.map((a) => a.id).join(", ")} kept ${film.length === 1 ? "its" : "their"} seconds (vos score places a score again)`
15607
+ );
15608
+ }
15609
+ report.rerecorded = [...redoByOld.values()].map((r) => r.entry);
15610
+ const newLabels = inputs.filter((_, i) => isNew(i)).map((x) => x.part.label);
15611
+ const notes = fresh.notes.filter(
15612
+ (n) => newLabels.some((l) => n.startsWith(`${l}:`))
15613
+ );
15614
+ const copies = fresh.copies.filter(
15615
+ (c) => [...newIds, ...redoTo].some((k) => k !== "" && c.to.startsWith(`${k}/`))
15616
+ );
15617
+ return {
15618
+ plan: {
15619
+ doc,
15620
+ copies,
15621
+ media: fresh.media,
15622
+ actions: fresh.actions,
15623
+ notes,
15624
+ ids: fresh.ids,
15625
+ owners: fresh.owners
15626
+ },
15627
+ report,
15628
+ newIds,
15629
+ dropped
15630
+ };
15631
+ }
15632
+ function rejoinLines(r) {
15633
+ const lines = [];
15634
+ const n = (v, one, many = `${one}s`) => `${v} ${v === 1 ? one : many}`;
15635
+ if (r.kept.length)
15636
+ lines.push(
15637
+ `kept the edits of ${r.kept.map((k) => `${k.label} (${k.id || "primary"}${k.moved ? `, found at its new path; was ${k.moved}` : ""})`).join(", ")}`
15638
+ );
15639
+ if (r.added.length)
15640
+ lines.push(`added ${r.added.map((a) => `${a.label} \u2192 ${a.id}`).join(", ")}`);
15641
+ for (const x of r.rerecorded) {
15642
+ lines.push(
15643
+ `re-recorded ${x.label}${x.was ? ` (was ${x.was})` : ""}: ${x.from} \u2192 ${x.id}; its edits re-timed by its steps: ${[
15644
+ n(x.segments, "segment"),
15645
+ n(x.spans, "span"),
15646
+ x.layers.length ? `layers ${x.layers.join(", ")}` : "0 layers"
15647
+ ].join(", ")}`
15648
+ );
15649
+ for (const f of x.flagged) lines.push(` ${f}`);
15650
+ }
15651
+ for (const d of r.dropped) {
15652
+ const what = [
15653
+ n(d.segments, "segment"),
15654
+ n(d.spans, "span"),
15655
+ ...d.layers.length ? [`layers ${d.layers.join(", ")}`] : [],
15656
+ ...d.sound.length ? [`sound ${d.sound.join(", ")}`] : []
15657
+ ].join(", ");
15658
+ lines.push(
15659
+ `dropped ${d.input} (${d.id || "primary"}): ${d.why === "changed" ? "its footage changed since the join" : "no longer an input"}; ${what} went with it`
15660
+ );
15661
+ }
15662
+ if (r.regrouped)
15663
+ lines.push(
15664
+ "the inputs changed order, so the segments play in argument order again (each input\u2019s own segments in their edited order)"
15665
+ );
15666
+ if (r.shifted.length)
15667
+ lines.push(
15668
+ `${r.shifted.join(", ")} moved with the footage ${r.shifted.length === 1 ? "it plays" : "they play"} over`
15669
+ );
15670
+ lines.push(...r.notes);
15671
+ return lines;
15672
+ }
15673
+
15674
+ // src/plugin/join.ts
15675
+ var FULL_BLEED_FRAME = {
15676
+ inset: { top: 0, right: 0, bottom: 0, left: 0 },
15677
+ radius: 0,
15678
+ shadow: 0,
15679
+ shadowContact: 0,
15680
+ border: 0,
15681
+ fit: "cover"
15682
+ };
15683
+ var round33 = (v) => Math.round(v * 1e3) / 1e3;
15684
+ var FILE_SOUND_FADE = 0.15;
15685
+ var prefixed = (id, key) => `${id}/${takeRelativeFile(key)}`;
15686
+ function fileDoc(key, probed, t0 = 0) {
15687
+ const meta = {
15688
+ dpr: 1,
15689
+ zoom: 1,
15690
+ t0,
15691
+ durationMs: probed.durationMs,
15692
+ width: probed.width,
15693
+ height: probed.height,
15694
+ captureWidth: probed.width,
15695
+ captureHeight: probed.height,
15696
+ fps: probed.fps > 0 ? probed.fps : 30,
15697
+ hasAudio: probed.hasAudio,
15698
+ producer: "ingest"
15699
+ };
15700
+ const doc = projectFromArtifact2({ videoKey: key, cursor: [], meta }, key, {
15701
+ footage: true
15702
+ }).doc;
15703
+ doc.segments = [{ in: 0, out: round33(probed.durationMs / 1e3) }];
15704
+ return doc;
15705
+ }
15706
+ function endingLayers(doc) {
15707
+ const pieces = ratedSegments10(doc);
15708
+ const end = totalDuration3(pieces);
15709
+ if (!(end > 0)) return [];
15710
+ return (doc.overlays ?? []).filter((o) => {
15711
+ const start = layerStart4(o, doc, pieces);
15712
+ return start !== null && start >= end - 1e-3;
15713
+ });
15714
+ }
15715
+ function dropEnding(d, label, notes) {
15716
+ const ending = endingLayers(d);
15717
+ const legacy = d.endCard !== void 0;
15718
+ if (!ending.length && !legacy) return;
15719
+ const gone = new Set(ending);
15720
+ d.overlays = (d.overlays ?? []).filter((o) => !gone.has(o));
15721
+ if (!d.overlays.length) delete d.overlays;
15722
+ if (legacy) delete d.endCard;
14355
15723
  const named = [
14356
15724
  ...ending.map((o) => o.id),
14357
15725
  ...legacy ? ["its endCard"] : []
@@ -14361,9 +15729,9 @@ function dropEnding(d, label, notes) {
14361
15729
  );
14362
15730
  }
14363
15731
  function segs0(d) {
14364
- return d.segments.length ? d.segments[0] : { in: 0, out: round32(d.source.meta.durationMs / 1e3) };
15732
+ return d.segments.length ? d.segments[0] : { in: 0, out: round33(d.source.meta.durationMs / 1e3) };
14365
15733
  }
14366
- function joinDocs(parts) {
15734
+ function joinDocs(parts, opts = {}) {
14367
15735
  if (parts.length < 2)
14368
15736
  throw new UsageError("vos join needs two inputs or more");
14369
15737
  const [first, ...rest] = parts;
@@ -14390,14 +15758,28 @@ function joinDocs(parts) {
14390
15758
  ];
14391
15759
  for (const ref of refs)
14392
15760
  copy({
14393
- from: join26(first.dir, takeRelativeFile(ref.key)),
15761
+ from: join27(first.dir, takeRelativeFile(ref.key)),
14394
15762
  to: takeRelativeFile(ref.key),
14395
15763
  base: first.dir
14396
15764
  });
14397
15765
  }
14398
15766
  if (!doc.segments.length)
14399
- doc.segments = [{ in: 0, out: round32(doc.source.meta.durationMs / 1e3) }];
14400
- const taken = new Set((doc.media ?? []).map((m) => m.id));
15767
+ doc.segments = [{ in: 0, out: round33(doc.source.meta.durationMs / 1e3) }];
15768
+ const taken = /* @__PURE__ */ new Set([
15769
+ ...(doc.media ?? []).map((m) => m.id),
15770
+ ...opts.reserved ?? [],
15771
+ ...rest.flatMap((p) => [
15772
+ ...p.id ? [p.id] : [],
15773
+ ...Object.values(p.subIds ?? {})
15774
+ ])
15775
+ ]);
15776
+ const ids = [
15777
+ {
15778
+ id: "",
15779
+ sub: Object.fromEntries((doc.media ?? []).map((m) => [m.id, m.id]))
15780
+ }
15781
+ ];
15782
+ const owners = /* @__PURE__ */ new Map();
14401
15783
  const allocate = () => {
14402
15784
  let n = 1;
14403
15785
  while (taken.has(`m${n}`)) n++;
@@ -14410,10 +15792,17 @@ function joinDocs(parts) {
14410
15792
  for (const part of rest) {
14411
15793
  const d = structuredClone(migrateMotion3(part.doc));
14412
15794
  if (part !== rest[rest.length - 1]) dropEnding(d, part.label, notes);
14413
- const pid = allocate();
15795
+ const pid = part.id ?? allocate();
14414
15796
  const idMap = /* @__PURE__ */ new Map([["", pid]]);
14415
- for (const m of d.media ?? []) idMap.set(m.id, allocate());
14416
- const mapMedia = (k) => idMap.get(mediaKey2(k)) ?? pid;
15797
+ for (const m of d.media ?? [])
15798
+ idMap.set(m.id, part.subIds?.[m.id] ?? allocate());
15799
+ ids.push({
15800
+ id: pid,
15801
+ sub: Object.fromEntries(
15802
+ (d.media ?? []).map((m) => [m.id, idMap.get(m.id)])
15803
+ )
15804
+ });
15805
+ const mapMedia = (k) => idMap.get(mediaKey4(k)) ?? pid;
14417
15806
  joined.push({ label: part.label, id: pid });
14418
15807
  actions.set(pid, part.actions);
14419
15808
  if (part.file) {
@@ -14452,20 +15841,22 @@ function joinDocs(parts) {
14452
15841
  for (const ref of refs) {
14453
15842
  const to = prefixed(pid, ref.key);
14454
15843
  copy({
14455
- from: join26(part.dir, takeRelativeFile(ref.key)),
15844
+ from: join27(part.dir, takeRelativeFile(ref.key)),
14456
15845
  to,
14457
15846
  base: part.dir
14458
15847
  });
14459
15848
  ref.set(to);
14460
15849
  }
14461
15850
  }
15851
+ const script = !d.source.script && part.actions ? publicScript(part.actions) : void 0;
14462
15852
  media.push({
14463
15853
  ...d.source,
14464
15854
  id: pid,
15855
+ ...script ? { script } : {},
14465
15856
  ...part.kind === "file" ? { frame: { ...FULL_BLEED_FRAME } } : {}
14466
15857
  });
14467
15858
  for (const m of d.media ?? []) media.push({ ...m, id: idMap.get(m.id) });
14468
- const segs = d.segments.length ? d.segments : [{ in: 0, out: round32(d.source.meta.durationMs / 1e3) }];
15859
+ const segs = d.segments.length ? d.segments : [{ in: 0, out: round33(d.source.meta.durationMs / 1e3) }];
14469
15860
  const at2 = doc.segments.length;
14470
15861
  doc.segments.push(...segs.map((s) => ({ ...s, media: mapMedia(s.media) })));
14471
15862
  const ns = (id) => `${pid}-${id}`;
@@ -14490,30 +15881,31 @@ function joinDocs(parts) {
14490
15881
  placed.push({ part, d, pid, firstSegment: at2, mapMedia });
14491
15882
  }
14492
15883
  if (media.length) doc.media = media;
14493
- const extents = segmentOutputExtents3(doc);
15884
+ const extents = segmentOutputExtents4(doc);
14494
15885
  for (const { part, d, pid, firstSegment, mapMedia } of placed) {
14495
15886
  const offset = extents[firstSegment]?.start ?? 0;
14496
- const ownPieces = ratedSegments9(d);
15887
+ const ownPieces = ratedSegments10(d);
14497
15888
  const ns = (id) => `${pid}-${id}`;
14498
15889
  let dropped = 0;
14499
15890
  for (const o of d.overlays ?? []) {
14500
15891
  const next = { ...o, id: ns(o.id) };
14501
15892
  if (next.kind !== "text" && next.kind !== "html") {
14502
- const ref = mediaRefId(next.key);
15893
+ const ref = mediaRefId2(next.key);
14503
15894
  if (ref !== null) next.key = `media:${mapMedia(ref)}`;
14504
15895
  }
14505
15896
  if (o.anchor) {
14506
15897
  next.anchor = { ...o.anchor, media: mapMedia(o.anchor.media) };
15898
+ } else if (o.start === void 0) {
14507
15899
  } else if (o.pin?.step !== void 0) {
14508
- const r = resolveAnchor6(d, { step: o.pin.step }, ownPieces);
15900
+ const r = resolveAnchor7(d, { step: o.pin.step }, ownPieces);
14509
15901
  if (r.start !== null) {
14510
15902
  next.anchor = {
14511
15903
  step: o.pin.step,
14512
15904
  media: pid,
14513
- offset: round32(o.start - r.start)
15905
+ offset: round33(o.start - r.start)
14514
15906
  };
14515
15907
  } else {
14516
- next.start = round32(o.start + offset);
15908
+ next.start = round33(o.start + offset);
14517
15909
  }
14518
15910
  delete next.pin;
14519
15911
  dropped++;
@@ -14522,10 +15914,11 @@ function joinDocs(parts) {
14522
15914
  delete next.pin;
14523
15915
  dropped++;
14524
15916
  }
14525
- next.start = round32(o.start + offset);
15917
+ next.start = round33(o.start + offset);
14526
15918
  }
14527
15919
  ;
14528
15920
  (doc.overlays ??= []).push(next);
15921
+ owners.set(next.id, pid);
14529
15922
  }
14530
15923
  if (dropped)
14531
15924
  notes.push(
@@ -14538,18 +15931,19 @@ function joinDocs(parts) {
14538
15931
  notes.push(`${part.label}: its own sound is left out (--mute)`);
14539
15932
  } else if (seg && at2) {
14540
15933
  const len = Math.max(0, seg.out - seg.in);
14541
- const fade = round32(Math.min(FILE_SOUND_FADE, len / 4));
15934
+ const fade = round33(Math.min(FILE_SOUND_FADE, len / 4));
14542
15935
  const id = `${pid}-sound`;
15936
+ owners.set(id, pid);
14543
15937
  doc.audio = [
14544
15938
  ...doc.audio,
14545
15939
  {
14546
15940
  id,
14547
15941
  key: d.source.videoKey,
14548
- name: basename12(part.file.path),
14549
- start: round32(at2.start),
14550
- in: round32(seg.in),
14551
- out: round32(seg.out),
14552
- duration: round32(d.source.meta.durationMs / 1e3),
15942
+ name: basename13(part.file.path),
15943
+ start: round33(at2.start),
15944
+ in: round33(seg.in),
15945
+ out: round33(seg.out),
15946
+ duration: round33(d.source.meta.durationMs / 1e3),
14553
15947
  gain: 1,
14554
15948
  fadeIn: fade,
14555
15949
  fadeOut: fade
@@ -14561,11 +15955,11 @@ function joinDocs(parts) {
14561
15955
  }
14562
15956
  }
14563
15957
  for (const ob of d.objects ?? []) {
14564
- ;
15958
+ owners.set(ns(ob.id), pid);
14565
15959
  (doc.objects ??= []).push({
14566
15960
  ...ob,
14567
15961
  id: ns(ob.id),
14568
- ...ob.span ? { span: { ...ob.span, start: round32(ob.span.start + offset) } } : {}
15962
+ ...ob.span ? { span: { ...ob.span, start: round33(ob.span.start + offset) } } : {}
14569
15963
  });
14570
15964
  }
14571
15965
  }
@@ -14578,26 +15972,40 @@ function joinDocs(parts) {
14578
15972
  notes.push(
14579
15973
  `${first.label}: --mute leaves nothing out (the first input's sound is the film's own)`
14580
15974
  );
14581
- const pieces = ratedSegments9(doc);
15975
+ const pieces = ratedSegments10(doc);
14582
15976
  for (const o of doc.overlays ?? []) {
14583
- if (!o.anchor) continue;
14584
- const r = resolveAnchor6(doc, o.anchor, pieces);
15977
+ if (!o.anchor || o.start === void 0) continue;
15978
+ const r = resolveAnchor7(doc, o.anchor, pieces);
14585
15979
  if (r.start !== null) o.start = r.start;
14586
15980
  }
14587
- return { doc, copies: [...copies.values()], media: joined, actions, notes };
15981
+ return {
15982
+ doc,
15983
+ copies: [...copies.values()],
15984
+ media: joined,
15985
+ actions,
15986
+ notes,
15987
+ ids,
15988
+ owners
15989
+ };
14588
15990
  }
14589
- var BOOLEAN_FLAGS13 = /* @__PURE__ */ new Set(["json", "help", "force", "captions"]);
15991
+ var BOOLEAN_FLAGS13 = /* @__PURE__ */ new Set([
15992
+ "json",
15993
+ "help",
15994
+ "force",
15995
+ "keep-edits",
15996
+ "captions"
15997
+ ]);
14590
15998
  var MULTI = /* @__PURE__ */ new Set(["mute"]);
14591
- var USAGE3 = "vos join <take> <take|file.mp4>\u2026 --out <dir> [--force] [--captions] [--mute <file>]... [--json]";
15999
+ var USAGE3 = "vos join <take> <take|file.mp4>\u2026 --out <dir> [--keep-edits|--force] [--captions] [--zooms per-beat] [--mute <file>]... [--json]";
14592
16000
  var within = (inner, outer) => {
14593
16001
  const rel = relative5(outer, inner);
14594
16002
  return rel === "" || !rel.startsWith(`..${sep4}`) && rel !== ".." && !isAbsolute4(rel);
14595
16003
  };
14596
16004
  async function readPart(arg) {
14597
16005
  const path = resolve17(arg);
14598
- if (!existsSync24(path)) throw new UsageError(`${arg}: no such take or file`);
16006
+ if (!existsSync25(path)) throw new UsageError(`${arg}: no such take or file`);
14599
16007
  if (statSync7(path).isDirectory()) {
14600
- if (!existsSync24(join26(path, "meta.json")))
16008
+ if (!existsSync25(join27(path, "meta.json")))
14601
16009
  throw new UsageError(
14602
16010
  `${arg}: not a take directory (no meta.json); a video file is joined by its path`
14603
16011
  );
@@ -14634,8 +16042,16 @@ async function cmdJoin(argv) {
14634
16042
  const r = createReporter(flags.json === true);
14635
16043
  const outArg = strFlag(flags, "out");
14636
16044
  const force = flags.force === true;
16045
+ const keepEdits = flags["keep-edits"] === true;
14637
16046
  const withCaptions = flags.captions === true;
16047
+ const zoomMode = zoomModeOf(strFlag(flags, "zooms"));
16048
+ if (zoomMode && typeof zoomMode === "object")
16049
+ throw new UsageError(zoomMode.error);
14638
16050
  if (positionals.length < 2 || !outArg) throw new UsageError(USAGE3);
16051
+ if (force && keepEdits)
16052
+ throw new UsageError(
16053
+ "--force replaces the joined take and --keep-edits keeps its edits; pass one"
16054
+ );
14639
16055
  const out = resolve17(outArg);
14640
16056
  const inputs = positionals.map((p) => resolve17(p));
14641
16057
  const muted = /* @__PURE__ */ new Set();
@@ -14653,42 +16069,85 @@ async function cmdJoin(argv) {
14653
16069
  `--out ${outArg} overlaps the input ${positionals[i]}; write the joined take somewhere of its own`
14654
16070
  );
14655
16071
  }
14656
- if (existsSync24(out)) {
14657
- if (!force)
16072
+ let rejoin = false;
16073
+ if (existsSync25(out)) {
16074
+ if (!force && !keepEdits)
14658
16075
  throw new UsageError(
14659
- `--out ${outArg} exists; pass --force to replace it, or name a new directory`
14660
- );
14661
- const isTake = existsSync24(join26(out, "doc.json")) || existsSync24(join26(out, "meta.json"));
14662
- if (!isTake && readdirSync5(out).length > 0)
14663
- throw new UsageError(
14664
- `--force replaces a take directory, and ${outArg} holds other files; name a new directory`
16076
+ `--out ${outArg} exists; pass --keep-edits to join again and keep every edit made since (its trims, spans, layers, frame, sound), --force to replace it, or name a new directory`
14665
16077
  );
16078
+ if (keepEdits) {
16079
+ if (!existsSync25(join27(out, "doc.json")))
16080
+ throw new UsageError(
16081
+ `--keep-edits: ${outArg} holds no doc.json, so there are no edits to keep; name a new directory, or --force to replace it`
16082
+ );
16083
+ rejoin = true;
16084
+ } else {
16085
+ const isTake = existsSync25(join27(out, "doc.json")) || existsSync25(join27(out, "meta.json"));
16086
+ if (!isTake && readdirSync5(out).length > 0)
16087
+ throw new UsageError(
16088
+ `--force replaces a take directory, and ${outArg} holds other files; name a new directory`
16089
+ );
16090
+ }
14666
16091
  if (within(process.cwd(), out))
14667
16092
  throw new UsageError(
14668
16093
  `--out ${outArg} is the working directory or above it; name a new directory`
14669
16094
  );
14670
16095
  }
14671
16096
  const parts = [];
16097
+ const read = [];
14672
16098
  for (const [i, p] of positionals.entries()) {
14673
16099
  r.log(`reading ${p}\u2026`);
14674
- parts.push({
16100
+ const part = {
14675
16101
  ...await readPart(p),
14676
16102
  ...muted.has(i) ? { mute: true } : {}
16103
+ };
16104
+ parts.push(part);
16105
+ const drift = part.kind === "take" ? scriptDrift(part.doc.source.meta.steps ?? [], part.actions?.steps) : null;
16106
+ if (drift) r.warn(`${p}: ${scriptDriftLine(drift)}`);
16107
+ read.push({
16108
+ part,
16109
+ path: relative5(out, inputs[i]) || ".",
16110
+ footage: await partFootage(part)
14677
16111
  });
14678
16112
  }
14679
- const plan = joinDocs(parts);
16113
+ if (keepEdits && !rejoin)
16114
+ r.log(
16115
+ `note: --keep-edits: ${outArg} does not exist yet, so this is a fresh join`
16116
+ );
16117
+ let plan;
16118
+ let rejoined = null;
16119
+ if (rejoin) {
16120
+ const prev = JSON.parse(
16121
+ await readFile17(join27(out, "doc.json"), "utf8")
16122
+ );
16123
+ let record = await readJoinRecord(out);
16124
+ if (!record) {
16125
+ record = await recordFromFootage(out, prev);
16126
+ r.log(
16127
+ `note: ${outArg} carries no ${JOIN_RECORD_NAME} (joined by an older vos), so its inputs are matched by the footage it holds; a video file joined then was re-encoded on the way in and reads as new`
16128
+ );
16129
+ }
16130
+ rejoined = rejoinDocs(migrateMotion3(prev), record, read, {
16131
+ where: (p) => relative5(process.cwd(), resolve17(out, p)) || "."
16132
+ });
16133
+ plan = rejoined.plan;
16134
+ } else plan = joinDocs(parts);
16135
+ const only = rejoined ? rejoined.newIds : void 0;
16136
+ const labelOf = new Map([
16137
+ ["", positionals[0]],
16138
+ ...plan.media.map((m) => [m.id, m.label])
16139
+ ]);
14680
16140
  let captions = null;
14681
16141
  if (withCaptions) {
14682
- captions = landStepCaptions(plan.doc, plan.actions);
14683
- const labelOf = new Map([
14684
- ["", positionals[0]],
14685
- ...plan.media.map((m) => [m.id, m.label])
14686
- ]);
16142
+ captions = landStepCaptions(plan.doc, plan.actions, { only });
14687
16143
  for (const k of captions.uncaptioned)
14688
16144
  plan.notes.push(
14689
16145
  `no step in ${labelOf.get(k) ?? `media ${k}`} carries a caption`
14690
16146
  );
14691
16147
  }
16148
+ let perBeat = null;
16149
+ if (zoomMode === "per-beat")
16150
+ perBeat = oneZoomPerBeat(plan.doc, plan.actions, { only });
14692
16151
  const lint = lintDoc(plan.doc);
14693
16152
  if (lint.problems.length) {
14694
16153
  r.log(
@@ -14706,25 +16165,36 @@ async function cmdJoin(argv) {
14706
16165
  );
14707
16166
  c.from = found.file;
14708
16167
  }
14709
- if (existsSync24(out)) await rm4(out, { recursive: true, force: true });
14710
- await mkdir11(out, { recursive: true });
16168
+ if (rejoined) {
16169
+ for (const k of rejoined.dropped) {
16170
+ if (!/^[A-Za-z0-9_-]+$/.test(k)) continue;
16171
+ const dir = join27(out, k);
16172
+ if (existsSync25(dir) && statSync7(dir).isDirectory())
16173
+ await rm4(dir, { recursive: true, force: true });
16174
+ }
16175
+ } else {
16176
+ if (existsSync25(out)) await rm4(out, { recursive: true, force: true });
16177
+ await mkdir11(out, { recursive: true });
16178
+ }
14711
16179
  for (const c of plan.copies) {
14712
- const to = join26(out, c.to);
16180
+ const to = join27(out, c.to);
14713
16181
  await mkdir11(dirname9(to), { recursive: true });
14714
16182
  if (c.remux) {
14715
16183
  const remuxed = await copyRecording(c.from, to, c.remux);
14716
16184
  if (!remuxed && c.remux !== "mov")
14717
16185
  plan.notes.push(
14718
- `could not read ${basename12(c.from)}'s audio in Node, so it was copied as it is; its length comes from the container`
16186
+ `could not read ${basename13(c.from)}'s audio in Node, so it was copied as it is; its length comes from the container`
14719
16187
  );
14720
16188
  } else await copyFile3(c.from, to);
14721
16189
  }
14722
16190
  const doc = plan.doc;
14723
- const added = new Set(plan.media.map((m) => `${m.id}-sound`));
16191
+ const added = new Set(
16192
+ plan.media.filter((m) => !only || only.has(m.id)).map((m) => `${m.id}-sound`)
16193
+ );
14724
16194
  for (const clip of doc.audio) {
14725
16195
  if (!added.has(clip.id) || !isTakeRelativeKey(clip.key)) continue;
14726
16196
  try {
14727
- const m = await measureTrack(join26(out, takeRelativeFile(clip.key)));
16197
+ const m = await measureTrack(join27(out, takeRelativeFile(clip.key)));
14728
16198
  if (m.lufs !== null)
14729
16199
  clip.loudness = loudnessFact(
14730
16200
  { lufs: m.lufs, peakDb: m.peakDb },
@@ -14736,33 +16206,350 @@ async function cmdJoin(argv) {
14736
16206
  );
14737
16207
  }
14738
16208
  }
14739
- await writeJson(join26(out, "meta.json"), doc.source.meta, true);
16209
+ await writeJson(join27(out, "meta.json"), doc.source.meta, true);
14740
16210
  if (doc.source.cursor.length)
14741
- await writeJson(join26(out, "cursor.json"), doc.source.cursor);
16211
+ await writeJson(join27(out, "cursor.json"), doc.source.cursor);
14742
16212
  if (parts[0].actions)
14743
- await writeJson(join26(out, "actions.json"), parts[0].actions, true);
14744
- await writeJson(join26(out, "doc.json"), doc, true);
14745
- const extents = segmentOutputExtents3(doc);
16213
+ await writeJson(join27(out, "actions.json"), parts[0].actions, true);
16214
+ await writeJson(join27(out, "doc.json"), doc, true);
16215
+ await writeJson(
16216
+ join27(out, JOIN_RECORD_NAME),
16217
+ joinRecordOf(read, plan.ids),
16218
+ true
16219
+ );
16220
+ const extents = segmentOutputExtents4(doc);
14746
16221
  const length = extents.length ? extents[extents.length - 1].end : 0;
14747
16222
  for (const n of plan.notes) r.log(`note: ${n}`);
14748
16223
  for (const w of lint.warnings) r.log(`warning: ${w}`);
14749
- const captionLine = captions ? `
14750
- ${captions.landed.length} step caption${captions.landed.length === 1 ? "" : "s"} landed` + (captions.skipped.length ? `; not landed: ${captions.skipped.join("; ")}` : "") : "";
16224
+ const stepCount = (id) => (doc.media ?? []).find((m) => m.id === id)?.meta.steps?.length ?? 0;
16225
+ const newMedia = only ? plan.media.filter((m) => only.has(m.id)) : [];
16226
+ const stepped = newMedia.filter((m) => stepCount(m.id) > 0);
16227
+ const bare = newMedia.filter((m) => stepCount(m.id) === 0);
16228
+ const bareWords = bare.length ? `${bare.map((m) => m.label).join(", ")} ${bare.length === 1 ? "has" : "have"} no recorded steps` : "";
16229
+ const onNew = only ? ` on ${stepped.map((m) => m.label).join(", ")}` : "";
16230
+ const captionLine = !captions ? "" : only && !newMedia.length ? "\n --captions: no new input to caption (a re-join captions only the inputs it adds; the kept ones keep their captions as edited)" : only && !stepped.length ? `
16231
+ --captions: ${bareWords}, so there is nothing to caption` : `
16232
+ ${captions.landed.length} step caption${captions.landed.length === 1 ? "" : "s"} landed${onNew}` + (captions.skipped.length ? `; not landed: ${captions.skipped.join("; ")}` : "") + (bareWords ? `; ${bareWords}` : "");
16233
+ const beatLine = !perBeat ? "" : only && !newMedia.length ? "\n --zooms per-beat: no new input to re-plan (a re-join re-plans only the inputs it adds; the kept ones keep their zooms as edited)" : only && !stepped.length ? `
16234
+ --zooms per-beat: ${bareWords}, so there are no beats to plan` : `
16235
+ ${only ? `${stepped.map((m) => m.label).join(", ")}: ` : ""}${perBeatLine(perBeat, (k) => labelOf.get(k) ?? `media ${k}`)}${bareWords ? `; ${bareWords}` : ""}`;
16236
+ const rejoinText = rejoined ? rejoinLines(rejoined.report).map((l) => `
16237
+ ${l}`).join("") : "";
14751
16238
  r.done(
14752
16239
  {
14753
16240
  out,
14754
16241
  primary: positionals[0],
14755
16242
  media: plan.media,
14756
16243
  segments: doc.segments.length,
14757
- seconds: round32(length),
16244
+ seconds: round33(length),
14758
16245
  files: plan.copies.map((c) => c.to),
14759
16246
  notes: plan.notes,
14760
16247
  warnings: lint.warnings,
14761
- ...captions ? { captions } : {}
16248
+ ...captions ? { captions } : {},
16249
+ ...perBeat ? { perBeat } : {},
16250
+ ...rejoined ? { keptEdits: rejoined.report } : {}
14762
16251
  },
14763
- `Joined ${parts.length} inputs \u2192 ${out} (${length.toFixed(1)}s, ${doc.segments.length} segments)
14764
- ${positionals[0]} is the primary; ${plan.media.map((m) => `${m.label} \u2192 ${m.id}`).join(", ")}${captionLine}
14765
- Next: vos frames ${outArg} --times 0,25%,50%,75%,100%; vos render ${outArg}`
16252
+ `${rejoined ? "Joined again, keeping the edits:" : "Joined"} ${parts.length} inputs \u2192 ${out} (${length.toFixed(1)}s, ${doc.segments.length} segments)
16253
+ ${positionals[0]} is the primary; ${plan.media.map((m) => `${m.label} \u2192 ${m.id}`).join(", ")}${rejoinText}${captionLine}${beatLine}
16254
+ Next: vos info ${outArg}; vos frames ${outArg} --times 0,25%,50%,75%,100%; vos render ${outArg}`
16255
+ );
16256
+ return EXIT_OK;
16257
+ }
16258
+
16259
+ // src/plugin/timeline.ts
16260
+ import { join as join28, resolve as resolve18 } from "path";
16261
+ import {
16262
+ docFreezes as docFreezes2,
16263
+ docMediaList as docMediaList4,
16264
+ docOutputDuration as docOutputDuration4,
16265
+ cameraSpanExtents as cameraSpanExtents3,
16266
+ docStillTime as docStillTime2,
16267
+ freezeOutputExtents,
16268
+ mediaKey as mediaKey5,
16269
+ migrateMotion as migrateMotion4,
16270
+ overlayPlainText,
16271
+ longestOutputExtent as longestOutputExtent3,
16272
+ ratedSegments as ratedSegments11,
16273
+ resolveOverlayAnchors as resolveOverlayAnchors2,
16274
+ segmentOutputExtents as segmentOutputExtents5,
16275
+ spanOutputExtents as spanOutputExtents3
16276
+ } from "@vosjs/studio-core";
16277
+ import { totalDuration as totalDuration4 } from "@vosjs/timeline";
16278
+ var r32 = (v) => Math.round(v * 1e3) / 1e3;
16279
+ var ext = (e) => e ? { start: r32(e.start), end: r32(e.end) } : null;
16280
+ function ratedWithoutFreezes(doc) {
16281
+ return ratedSegments11({
16282
+ ...doc,
16283
+ freeze: void 0,
16284
+ segments: doc.segments.map((s) => {
16285
+ const { hold: _hold, ...rest } = s;
16286
+ void _hold;
16287
+ return rest;
16288
+ })
16289
+ });
16290
+ }
16291
+ function takeTimeline(rawDoc, actions = /* @__PURE__ */ new Map()) {
16292
+ const doc = migrateMotion4(rawDoc);
16293
+ const rated = ratedSegments11(doc);
16294
+ const round = (e) => ({
16295
+ start: r32(e.start),
16296
+ end: r32(e.end)
16297
+ });
16298
+ const span = (sIn, sOut, media2) => {
16299
+ const all = spanOutputExtents3(rated, sIn, sOut, media2).map(round);
16300
+ return {
16301
+ output: all[0] ?? null,
16302
+ ...all.length > 1 ? { also: all.slice(1) } : {}
16303
+ };
16304
+ };
16305
+ const stepSpan = (sIn, sOut, media2) => {
16306
+ const all = spanOutputExtents3(rated, sIn, sOut, media2).map(round);
16307
+ const longest = longestOutputExtent3(rated, sIn, sOut, media2);
16308
+ const main = longest ? all.find(
16309
+ (e) => Math.abs(e.start - r32(longest.start)) < 1e-6 && Math.abs(e.end - r32(longest.end)) < 1e-6
16310
+ ) : void 0;
16311
+ const others = all.filter((e) => e !== main);
16312
+ return {
16313
+ output: main ?? null,
16314
+ ...others.length ? { also: others } : {}
16315
+ };
16316
+ };
16317
+ const cameraSpan = (sIn, sOut, media2) => {
16318
+ const moves = cameraSpanExtents3(rated, sIn, sOut, media2).map(round);
16319
+ const rests = spanOutputExtents3(rated, sIn, sOut, media2).map(round).filter(
16320
+ (e) => !moves.some(
16321
+ (m) => m.start >= e.start - 1e-6 && m.end <= e.end + 1e-6
16322
+ )
16323
+ );
16324
+ return {
16325
+ output: moves[0] ?? null,
16326
+ ...moves.length > 1 ? { also: moves.slice(1) } : {},
16327
+ ...rests.length ? { rests } : {}
16328
+ };
16329
+ };
16330
+ const media = docMediaList4(doc).map(({ id, source }) => ({
16331
+ id,
16332
+ file: source.videoKey,
16333
+ duration: r32((source.meta.durationMs || 0) / 1e3),
16334
+ steps: (source.meta.steps ?? []).map((s) => {
16335
+ const caption = captionOf(
16336
+ s,
16337
+ actions.get(id),
16338
+ source.script,
16339
+ source.meta.steps ?? []
16340
+ );
16341
+ const out = Math.max(s.tEnd, s.tStart + 1e-3);
16342
+ return {
16343
+ step: s.step,
16344
+ ...s.id !== void 0 ? { id: s.id } : {},
16345
+ do: s.do,
16346
+ in: r32(s.tStart),
16347
+ out: r32(s.tEnd),
16348
+ ...caption ? { caption } : {},
16349
+ ...s.skipped ? { skipped: true } : {},
16350
+ ...stepSpan(s.tStart, out, id || void 0)
16351
+ };
16352
+ })
16353
+ }));
16354
+ const docSegments = doc.segments.length ? doc.segments : [{ in: 0, out: doc.source.meta.durationMs / 1e3 }];
16355
+ const extents = segmentOutputExtents5(doc);
16356
+ const segments = docSegments.map((s, index) => ({
16357
+ index,
16358
+ media: mediaKey5(s.media),
16359
+ in: r32(s.in),
16360
+ out: r32(s.out),
16361
+ start: r32(extents[index]?.start ?? 0),
16362
+ end: r32(extents[index]?.end ?? 0)
16363
+ }));
16364
+ const freezeMarks = freezeOutputExtents(
16365
+ ratedWithoutFreezes(doc),
16366
+ docFreezes2(doc)
16367
+ );
16368
+ const freeze = docFreezes2(doc).map((f) => {
16369
+ const m = freezeMarks.get(f.id);
16370
+ return {
16371
+ id: f.id,
16372
+ media: mediaKey5(f.media),
16373
+ at: r32(f.at),
16374
+ seconds: r32(f.seconds),
16375
+ output: m ? ext({ start: m.t, end: m.t + m.duration }) : null
16376
+ };
16377
+ });
16378
+ const resolved = resolveOverlayAnchors2(doc, rated);
16379
+ const rawById = new Map((doc.overlays ?? []).map((o) => [o.id, o]));
16380
+ const overlays = (resolved.overlays ?? []).flatMap((o) => {
16381
+ const start = o.start;
16382
+ if (start === void 0) return [];
16383
+ const raw = rawById.get(o.id);
16384
+ const text = o.kind === "text" ? overlayPlainText(o) : o.kind === "html" ? void 0 : o.key;
16385
+ return [
16386
+ {
16387
+ id: o.id,
16388
+ kind: o.kind ?? "text",
16389
+ ...text !== void 0 ? { text } : {},
16390
+ start: r32(start),
16391
+ end: r32(start + o.duration),
16392
+ ...raw?.anchor ? { anchor: raw.anchor } : {},
16393
+ ...o.from ? { from: o.from } : {}
16394
+ }
16395
+ ];
16396
+ });
16397
+ const footage = totalDuration4(rated);
16398
+ return {
16399
+ media,
16400
+ segments,
16401
+ speed: (doc.speed ?? []).map((s) => ({
16402
+ id: s.id,
16403
+ media: mediaKey5(s.media),
16404
+ in: r32(s.in),
16405
+ out: r32(s.out),
16406
+ rate: s.rate,
16407
+ ...s.source ? { source: s.source } : {},
16408
+ ...span(s.in, s.out, s.media)
16409
+ })),
16410
+ freeze,
16411
+ zoom: doc.zoom.map((z) => ({
16412
+ id: z.id,
16413
+ media: mediaKey5(z.media),
16414
+ in: r32(z.in),
16415
+ out: r32(z.out),
16416
+ level: z.level,
16417
+ ...z.source ? { source: z.source } : {},
16418
+ ...cameraSpan(z.in, z.out, z.media)
16419
+ })),
16420
+ tilt: (doc.tilt ?? []).map((t) => ({
16421
+ id: t.id,
16422
+ media: mediaKey5(t.media),
16423
+ in: r32(t.in),
16424
+ out: r32(t.out),
16425
+ ...cameraSpan(t.in, t.out, t.media)
16426
+ })),
16427
+ overlays,
16428
+ objects: (doc.objects ?? []).map((ob) => ({
16429
+ id: ob.id,
16430
+ start: r32(ob.span?.start ?? 0),
16431
+ end: ob.span ? r32(ob.span.start + ob.span.duration) : null
16432
+ })),
16433
+ audio: doc.audio.map((a) => {
16434
+ const len = a.loop && a.loopLen ? a.loopLen : a.out - a.in;
16435
+ return {
16436
+ id: a.id,
16437
+ ...a.name ? { name: a.name } : {},
16438
+ start: r32(a.start),
16439
+ end: r32(a.start + len),
16440
+ in: r32(a.in),
16441
+ out: r32(a.out)
16442
+ };
16443
+ }),
16444
+ footage: r32(footage),
16445
+ end: r32(docOutputDuration4(doc)),
16446
+ still: docStillTime2(doc)
16447
+ };
16448
+ }
16449
+ var f2 = (v) => v.toFixed(2);
16450
+ var range = (a, b) => `${f2(a)}\u2013${f2(b)}`;
16451
+ var ranges = (list) => list.map((a) => range(a.start, a.end)).join(", ");
16452
+ var outText = (e, also, rests) => `${e ? range(e.start, e.end) : rests?.length ? "(camera at rest)" : "(cut)"}${also?.length ? `, also ${ranges(also)}` : ""}${rests?.length ? `${e ? "," : ""} at rest in ${ranges(rests)}` : ""}`;
16453
+ var mediaName = (m) => m || "primary";
16454
+ function formatTimeline(t, label) {
16455
+ const lines = [];
16456
+ const pad = (s, n) => s.padEnd(n);
16457
+ lines.push(
16458
+ `${label}: output ${f2(t.end)} s (footage ${f2(t.footage)} s), ${t.media.length} media`
16459
+ );
16460
+ for (const m of t.media) {
16461
+ lines.push("");
16462
+ lines.push(
16463
+ `${mediaName(m.id)} ${m.file} ${f2(m.duration)} s source, ${m.steps.length} step${m.steps.length === 1 ? "" : "s"}`
16464
+ );
16465
+ if (m.steps.length)
16466
+ lines.push(
16467
+ ` ${pad("step", 5)} ${pad("id", 16)} ${pad("do", 8)} ${pad("source", 14)} ${pad("output", 14)} caption`
16468
+ );
16469
+ for (const s of m.steps)
16470
+ lines.push(
16471
+ ` ${pad(String(s.step), 5)} ${pad(s.id ?? "-", 16)} ${pad(s.do, 8)} ${pad(range(s.in, s.out), 14)} ${pad(s.skipped ? "(skipped)" : outText(s.output), 14)} ${s.caption ? JSON.stringify(s.caption) : ""}${s.also?.length && !s.skipped ? ` (also ${ranges(s.also)})` : ""}`.trimEnd()
16472
+ );
16473
+ }
16474
+ lines.push("");
16475
+ lines.push("output timeline (source \u2192 output seconds)");
16476
+ lines.push(" segments");
16477
+ for (const s of t.segments)
16478
+ lines.push(
16479
+ ` #${pad(String(s.index), 4)} ${pad(mediaName(s.media), 8)} ${pad(range(s.in, s.out), 14)} \u2192 ${range(s.start, s.end)}`
16480
+ );
16481
+ if (t.speed.length) {
16482
+ lines.push(" speed");
16483
+ for (const s of t.speed)
16484
+ lines.push(
16485
+ ` ${pad(s.id, 12)} ${pad(mediaName(s.media), 8)} ${pad(range(s.in, s.out), 14)} \xD7${pad(String(s.rate), 5)} ${pad(s.source ?? "", 6)} \u2192 ${outText(s.output, s.also)}`
16486
+ );
16487
+ }
16488
+ if (t.freeze.length) {
16489
+ lines.push(" freeze");
16490
+ for (const f of t.freeze)
16491
+ lines.push(
16492
+ ` ${pad(f.id, 12)} ${pad(mediaName(f.media), 8)} at ${pad(f2(f.at), 11)} ${f2(f.seconds)} s \u2192 ${outText(f.output)}`
16493
+ );
16494
+ }
16495
+ if (t.zoom.length) {
16496
+ lines.push(" zoom");
16497
+ for (const z of t.zoom)
16498
+ lines.push(
16499
+ ` ${pad(z.id, 12)} ${pad(mediaName(z.media), 8)} ${pad(range(z.in, z.out), 14)} L${pad(String(z.level), 5)} ${pad(z.source ?? "", 6)} \u2192 ${outText(z.output, z.also, z.rests)}`
16500
+ );
16501
+ }
16502
+ if (t.tilt.length) {
16503
+ lines.push(" tilt");
16504
+ for (const z of t.tilt)
16505
+ lines.push(
16506
+ ` ${pad(z.id, 12)} ${pad(mediaName(z.media), 8)} ${pad(range(z.in, z.out), 14)} \u2192 ${outText(z.output, z.also, z.rests)}`
16507
+ );
16508
+ }
16509
+ if (t.overlays.length) {
16510
+ lines.push(" layers");
16511
+ for (const o of t.overlays) {
16512
+ const anchor = o.anchor ? ` anchor ${o.anchor.media ? `${o.anchor.media}:` : ""}${String(o.anchor.step)}${o.anchor.at === "end" ? " end" : ""}${o.anchor.offset ? ` ${o.anchor.offset > 0 ? "+" : ""}${o.anchor.offset}` : ""}` : "";
16513
+ const words2 = o.text !== void 0 ? ` ${JSON.stringify(o.text)}` : "";
16514
+ lines.push(
16515
+ ` ${pad(o.id, 12)} ${pad(o.kind, 6)} ${range(o.start, o.end)}${anchor}${o.from ? ` (from ${o.from})` : ""}${words2}`
16516
+ );
16517
+ }
16518
+ }
16519
+ if (t.objects.length) {
16520
+ lines.push(" 3D");
16521
+ for (const o of t.objects)
16522
+ lines.push(
16523
+ ` ${pad(o.id, 12)} ${o.end === null ? "always" : range(o.start, o.end)}`
16524
+ );
16525
+ }
16526
+ if (t.audio.length) {
16527
+ lines.push(" sound");
16528
+ for (const a of t.audio)
16529
+ lines.push(
16530
+ ` ${pad(a.id, 12)} ${range(a.start, a.end)} (its file ${range(a.in, a.out)})${a.name ? ` ${a.name}` : ""}`
16531
+ );
16532
+ }
16533
+ lines.push(` still ${f2(t.still)}`);
16534
+ lines.push(` end ${f2(t.end)}`);
16535
+ return lines.join("\n");
16536
+ }
16537
+ async function cmdTakeInfo(argv) {
16538
+ const { positionals, flags } = parseArgs(argv, /* @__PURE__ */ new Set(["json", "help"]));
16539
+ const dir = positionals[0];
16540
+ if (!dir) throw new UsageError("vos info <take> [--json]");
16541
+ const r = createReporter(flags.json === true);
16542
+ const take = await loadTake(dir);
16543
+ if (!take.doc)
16544
+ throw new UsageError(
16545
+ `${dir} has no doc.json yet; vos plan ${dir} writes one`
16546
+ );
16547
+ const drift = scriptDrift(take.meta.steps ?? [], take.actions?.steps);
16548
+ if (drift) r.warn(scriptDriftLine(drift));
16549
+ const timeline = takeTimeline(take.doc, /* @__PURE__ */ new Map([["", take.actions]]));
16550
+ r.done(
16551
+ { take: resolve18(dir), doc: join28(resolve18(dir), "doc.json"), ...timeline },
16552
+ formatTimeline(timeline, dir)
14766
16553
  );
14767
16554
  return EXIT_OK;
14768
16555
  }
@@ -14770,7 +16557,7 @@ async function cmdJoin(argv) {
14770
16557
  // src/plugin/audio.ts
14771
16558
  import { mkdtemp as mkdtemp2, rm as rm5, writeFile as writeFile12 } from "fs/promises";
14772
16559
  import { tmpdir as tmpdir3 } from "os";
14773
- import { basename as basename13, join as join27 } from "path";
16560
+ import { basename as basename14, join as join29 } from "path";
14774
16561
  import { clipGain as clipGain3 } from "@vosjs/studio-core";
14775
16562
  var USAGE4 = "vos audio measure <take> [--json]";
14776
16563
  async function fileFor(dir, key, fetchImpl) {
@@ -14782,8 +16569,8 @@ async function fileFor(dir, key, fetchImpl) {
14782
16569
  if (/^https?:\/\//.test(key)) {
14783
16570
  const res = await fetchImpl(key);
14784
16571
  if (!res.ok) return { skipped: `GET ${key} answered ${res.status}` };
14785
- const tmp = await mkdtemp2(join27(tmpdir3(), "vos-audio-"));
14786
- const path = join27(tmp, basename13(new URL(key).pathname) || "audio");
16572
+ const tmp = await mkdtemp2(join29(tmpdir3(), "vos-audio-"));
16573
+ const path = join29(tmp, basename14(new URL(key).pathname) || "audio");
14787
16574
  await writeFile12(path, new Uint8Array(await res.arrayBuffer()));
14788
16575
  return { path, cleanup: () => rm5(tmp, { recursive: true, force: true }) };
14789
16576
  }
@@ -14865,8 +16652,8 @@ async function cmdAudio(argv) {
14865
16652
 
14866
16653
  // src/plugin/look.ts
14867
16654
  import { mkdir as mkdir12 } from "fs/promises";
14868
- import { existsSync as existsSync25 } from "fs";
14869
- import { join as join28, resolve as resolve18 } from "path";
16655
+ import { existsSync as existsSync26 } from "fs";
16656
+ import { join as join30, resolve as resolve19 } from "path";
14870
16657
  import { headersFor, urlSchemeProblem } from "@vosjs/render-core/record";
14871
16658
  function quote(value) {
14872
16659
  return `"${value.replace(/\s+/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
@@ -15318,7 +17105,7 @@ async function lookAt(page, url, opts) {
15318
17105
  }
15319
17106
  if (opts.waitMs && opts.waitMs > 0) await page.waitForTimeout(opts.waitMs);
15320
17107
  await mkdir12(opts.outDir, { recursive: true });
15321
- const screenshot = join28(opts.outDir, "look.png");
17108
+ const screenshot = join30(opts.outDir, "look.png");
15322
17109
  await page.screenshot({ path: screenshot });
15323
17110
  const scan = await page.evaluate(EXPOSURE_PROBE).catch(() => null);
15324
17111
  const step = ran.length ? ran.length - 1 : -1;
@@ -15408,15 +17195,15 @@ async function cmdLook(argv) {
15408
17195
  if (scheme) throw new UsageError(scheme);
15409
17196
  const r = createReporter(flags.json === true);
15410
17197
  const viewport = parseViewport2(strFlag(flags, "viewport"));
15411
- const outDir = resolve18(strFlag(flags, "out") ?? "look");
17198
+ const outDir = resolve19(strFlag(flags, "out") ?? "look");
15412
17199
  const headed = flags.headed === true;
15413
17200
  const waitRaw = strFlag(flags, "wait");
15414
17201
  const waitMs = waitRaw === void 0 ? 0 : Number(waitRaw);
15415
17202
  if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 6e4)
15416
17203
  throw new UsageError("--wait expects milliseconds, 0 to 60000");
15417
17204
  const storageRaw = strFlag(flags, "storage-state");
15418
- const storageState = storageRaw ? resolve18(storageRaw) : void 0;
15419
- if (storageState && !existsSync25(storageState))
17205
+ const storageState = storageRaw ? resolve19(storageRaw) : void 0;
17206
+ if (storageState && !existsSync26(storageState))
15420
17207
  throw new UsageError(`--storage-state: no such file: ${storageState}`);
15421
17208
  const sessionName = strFlag(flags, "session");
15422
17209
  if (sessionName) {
@@ -15426,7 +17213,7 @@ async function cmdLook(argv) {
15426
17213
  throw new UsageError(
15427
17214
  "--session and --storage-state are two doors to one page: a persistent profile cannot take a storage state. Pass one"
15428
17215
  );
15429
- if (!existsSync25(sessionDir(sessionName)))
17216
+ if (!existsSync26(sessionDir(sessionName)))
15430
17217
  throw new UsageError(
15431
17218
  `no session "${sessionName}". Make one: vos session open <url> --name ${sessionName} (a Chrome window opens; sign in and close it)`
15432
17219
  );
@@ -15489,19 +17276,19 @@ async function cmdLook(argv) {
15489
17276
  import { spawnSync as spawnSync4 } from "child_process";
15490
17277
  import {
15491
17278
  cpSync,
15492
- existsSync as existsSync27,
17279
+ existsSync as existsSync28,
15493
17280
  mkdirSync as mkdirSync6,
15494
17281
  readFileSync as readFileSync11,
15495
17282
  rmSync as rmSync3,
15496
17283
  writeFileSync as writeFileSync6
15497
17284
  } from "fs";
15498
17285
  import { homedir as homedir2 } from "os";
15499
- import { join as join30 } from "path";
17286
+ import { join as join32 } from "path";
15500
17287
  import { chromium as chromium2 } from "playwright";
15501
17288
 
15502
17289
  // src/plugin/machine.ts
15503
- import { existsSync as existsSync26, readFileSync as readFileSync10, readdirSync as readdirSync6, statSync as statSync8 } from "fs";
15504
- import { dirname as dirname10, join as join29 } from "path";
17290
+ import { existsSync as existsSync27, readFileSync as readFileSync10, readdirSync as readdirSync6, statSync as statSync8 } from "fs";
17291
+ import { dirname as dirname10, join as join31 } from "path";
15505
17292
  var AGENT_NAMES = [
15506
17293
  "claude",
15507
17294
  "cursor",
@@ -15521,17 +17308,17 @@ var HOME_DIRS = {
15521
17308
  copilot: [".copilot"]
15522
17309
  };
15523
17310
  function detectAgents(root, opts = {}) {
15524
- const exists = opts.exists ?? existsSync26;
17311
+ const exists = opts.exists ?? existsSync27;
15525
17312
  const out = [];
15526
17313
  for (const agent of AGENT_NAMES) {
15527
17314
  for (const rel of PROJECT_DIRS[agent]) {
15528
- const dir = join29(root, rel);
17315
+ const dir = join31(root, rel);
15529
17316
  if (exists(dir)) out.push({ agent, dir, scope: "project" });
15530
17317
  }
15531
- if (agent === "copilot" && !out.some((h) => h.agent === "copilot") && exists(join29(root, ".github", "copilot-instructions.md"))) {
17318
+ if (agent === "copilot" && !out.some((h) => h.agent === "copilot") && exists(join31(root, ".github", "copilot-instructions.md"))) {
15532
17319
  out.push({
15533
17320
  agent,
15534
- dir: join29(root, ".github", "copilot"),
17321
+ dir: join31(root, ".github", "copilot"),
15535
17322
  scope: "project"
15536
17323
  });
15537
17324
  }
@@ -15539,7 +17326,7 @@ function detectAgents(root, opts = {}) {
15539
17326
  if (opts.global && opts.home) {
15540
17327
  for (const agent of AGENT_NAMES) {
15541
17328
  for (const rel of HOME_DIRS[agent]) {
15542
- const dir = join29(opts.home, rel);
17329
+ const dir = join31(opts.home, rel);
15543
17330
  if (exists(dir)) out.push({ agent, dir, scope: "global" });
15544
17331
  }
15545
17332
  }
@@ -15557,18 +17344,18 @@ function agentsFromFlag(value, root) {
15557
17344
  return {
15558
17345
  homes: wanted.map((agent) => ({
15559
17346
  agent,
15560
- dir: join29(root, PROJECT_DIRS[agent][0]),
17347
+ dir: join31(root, PROJECT_DIRS[agent][0]),
15561
17348
  scope: "project"
15562
17349
  }))
15563
17350
  };
15564
17351
  }
15565
17352
  function skillsDirFor(home) {
15566
- return join29(home.dir, "skills");
17353
+ return join31(home.dir, "skills");
15567
17354
  }
15568
17355
  function fallbackSkillsHome(root) {
15569
- return { agent: "codex", dir: join29(root, ".agents"), scope: "project" };
17356
+ return { agent: "codex", dir: join31(root, ".agents"), scope: "project" };
15570
17357
  }
15571
- function installedSkills(dir, exists = existsSync26, list = (p) => {
17358
+ function installedSkills(dir, exists = existsSync27, list = (p) => {
15572
17359
  try {
15573
17360
  return readdirSync6(p);
15574
17361
  } catch {
@@ -15576,7 +17363,7 @@ function installedSkills(dir, exists = existsSync26, list = (p) => {
15576
17363
  }
15577
17364
  }) {
15578
17365
  if (!exists(dir)) return [];
15579
- return list(dir).filter((name) => exists(join29(dir, name, "SKILL.md"))).sort();
17366
+ return list(dir).filter((name) => exists(join31(dir, name, "SKILL.md"))).sort();
15580
17367
  }
15581
17368
  var RULES_BEGIN = "<!-- vos:begin (written by vos setup; the block is replaced on the next run) -->";
15582
17369
  var RULES_END = "<!-- vos:end -->";
@@ -15609,10 +17396,10 @@ ${RULES_BLOCK}
15609
17396
  }
15610
17397
  return { text, changed: text !== current };
15611
17398
  }
15612
- function rulesFileFor(root, exists = existsSync26) {
15613
- const agents = join29(root, "AGENTS.md");
17399
+ function rulesFileFor(root, exists = existsSync27) {
17400
+ const agents = join31(root, "AGENTS.md");
15614
17401
  if (exists(agents)) return agents;
15615
- const claude = join29(root, "CLAUDE.md");
17402
+ const claude = join31(root, "CLAUDE.md");
15616
17403
  if (exists(claude)) return claude;
15617
17404
  return agents;
15618
17405
  }
@@ -15621,14 +17408,14 @@ function chromeCandidates(platform, env) {
15621
17408
  return ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"];
15622
17409
  if (platform === "win32")
15623
17410
  return [
15624
- join29(
17411
+ join31(
15625
17412
  env["PROGRAMFILES"] ?? "C:\\Program Files",
15626
17413
  "Google",
15627
17414
  "Chrome",
15628
17415
  "Application",
15629
17416
  "chrome.exe"
15630
17417
  ),
15631
- join29(
17418
+ join31(
15632
17419
  env["LOCALAPPDATA"] ?? "",
15633
17420
  "Google",
15634
17421
  "Chrome",
@@ -15714,7 +17501,7 @@ function doctorReport(facts) {
15714
17501
  }
15715
17502
  function bundledSkillsVersion(dir) {
15716
17503
  try {
15717
- const v = readFileSync10(join29(dir, "VERSION"), "utf8").trim();
17504
+ const v = readFileSync10(join31(dir, "VERSION"), "utf8").trim();
15718
17505
  return /^\d+\.\d+\.\d+$/.test(v) ? v : null;
15719
17506
  } catch {
15720
17507
  return null;
@@ -15723,13 +17510,13 @@ function bundledSkillsVersion(dir) {
15723
17510
  function bundledSkillsDir(from) {
15724
17511
  let dir = dirname10(from);
15725
17512
  for (let i = 0; i < 6; i++) {
15726
- const pkg = join29(dir, "package.json");
15727
- if (existsSync26(pkg)) {
17513
+ const pkg = join31(dir, "package.json");
17514
+ if (existsSync27(pkg)) {
15728
17515
  try {
15729
17516
  const name = JSON.parse(readFileSync10(pkg, "utf8")).name;
15730
17517
  if (name === "@vosjs/cli") {
15731
- const skills = join29(dir, "skills");
15732
- return existsSync26(skills) && statSync8(skills).isDirectory() ? skills : null;
17518
+ const skills = join31(dir, "skills");
17519
+ return existsSync27(skills) && statSync8(skills).isDirectory() ? skills : null;
15733
17520
  }
15734
17521
  } catch {
15735
17522
  }
@@ -15765,7 +17552,7 @@ function npx() {
15765
17552
  function bundledChromium() {
15766
17553
  try {
15767
17554
  const p = chromium2.executablePath();
15768
- return p && existsSync27(p) ? p : null;
17555
+ return p && existsSync28(p) ? p : null;
15769
17556
  } catch {
15770
17557
  return null;
15771
17558
  }
@@ -15774,7 +17561,7 @@ function probe2() {
15774
17561
  return probeBrowser({
15775
17562
  platform: process.platform,
15776
17563
  env: process.env,
15777
- exists: existsSync27,
17564
+ exists: existsSync28,
15778
17565
  bundled: bundledChromium
15779
17566
  });
15780
17567
  }
@@ -15859,7 +17646,7 @@ async function cmdSetup(argv) {
15859
17646
  const dest = skillsDirFor(home);
15860
17647
  mkdirSync6(dest, { recursive: true });
15861
17648
  for (const name of installedSkills(dir)) {
15862
- cpSync(join30(dir, name), join30(dest, name), { recursive: true });
17649
+ cpSync(join32(dir, name), join32(dest, name), { recursive: true });
15863
17650
  }
15864
17651
  }
15865
17652
  };
@@ -15927,7 +17714,7 @@ async function cmdSetup(argv) {
15927
17714
  let rules = null;
15928
17715
  if (!noRules) {
15929
17716
  const file = rulesFileFor(root);
15930
- const existing = existsSync27(file) ? readFileSync11(file, "utf8") : null;
17717
+ const existing = existsSync28(file) ? readFileSync11(file, "utf8") : null;
15931
17718
  const { text, changed } = applyRulesBlock(existing);
15932
17719
  if (changed) writeFileSync6(file, text);
15933
17720
  rules = { file, changed };
@@ -16041,7 +17828,7 @@ async function cmdWhoami(argv) {
16041
17828
  function cmdLogout(argv) {
16042
17829
  const { flags } = parseArgs(argv, BOOLEAN_FLAGS14);
16043
17830
  const r = reporterFor(flags);
16044
- const had = existsSync27(CREDENTIALS_PATH);
17831
+ const had = existsSync28(CREDENTIALS_PATH);
16045
17832
  rmSync3(CREDENTIALS_PATH, { force: true });
16046
17833
  const envKey = Boolean(process.env.VOS_API_KEY?.trim());
16047
17834
  r.done(
@@ -16099,7 +17886,8 @@ Take pipeline
16099
17886
  vos record --actions actions.json [--url <url>] [--out take] [--strict] [--dry-run] [--allow-wall] [--keep-frames] [--max-duration <s>] [--ready dom|networkidle] [--storage-state <file>|--session <name>] [--header name=value]... [--confirm <id>]... [--browser-arg=<switch>]... [--background <slug|url|none>] [--verbose] [--json]
16100
17887
  vos record --hosted --actions actions.json [--url <url>] [--out take] [--title "\u2026"] [--folder <id>] [--header name=value]... [--json]
16101
17888
  the take recorded on vos.so's fleet (no browser here): lands as a private vos with its doc and digest, then fetched into --out with its media
16102
- vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<s>]]... [--background <slug|url|none>] [--motion] [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|fade|slide|none] [--transitions slide|fade|scale|none] [--end-card on|none|<doc.json|vosId>] [--captions|--captions none] [--clicks none] [--still <t>] [--release v2.1] [--json]
17889
+ vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<s>]]... [--background <slug|url|none>] [--motion] [--headline "\u2026"] [--kicker "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--music <slug|mood|none>] [--entrance tilt-in|pull-out|rise|fade|slide|none] [--transitions slide|fade|scale|none] [--end-card on|none|<doc.json|vosId>] [--captions|--captions none] [--clicks none] [--still <t>] [--release v2.1] [--zooms per-beat] [--json]
17890
+ --zooms per-beat keeps at most one zoom per beat: the beats are the captioned steps of actions.json (each runs until the next one starts, plus the opening before the first); a step marked "zoom": true in actions.json is its beat's payoff, and the beat keeps the proposal that overlaps it most (one per marked step, never two overlapping), promoted to manual; a beat with no marked step keeps no zoom; when no step of the take is marked, each beat's last click, drag, type or paste stands in (the opening keeps none) and the done line says so; a step marked "zoom": false is never zoomed on; the rest go into doc.rejected like a person deleting them, and a beat with a manual zoom keeps only its manual ones. A media with no captioned step keeps its zooms as planned, and the done line says so
16103
17891
  vos render <take> [out.webm] [--width] [--height] [--fps] [--format webm|mp4] [--parallel N] [--range a..b] [--draft] [--frame <kind>] [--background <url|slug>] [--set <path=value>]... [--json]
16104
17892
  vos frames <take> [--times 0,25%,50%,75%,100%] [--frame <t>] [--at-zooms] [--at-moments] [--at-still] [--size WxH] [--out dir] [--background <url|slug>] [--set <path=value>]... [--strict] [--json]
16105
17893
  --at-zooms stills every zoom span's apex on every media of the take and renders it again with the zooms off: a zoom whose frame matches its zoom-off twin did not render, and says so (exit 2 under --strict)
@@ -16107,6 +17895,8 @@ Take pipeline
16107
17895
  the rendered file itself, no ffmpeg: duration, size, codecs, resolution, frame rate; full-frame scene changes (a warning when a 5 s window holds more than one); loudness (short-term every 3 s, integrated, sample peak; a warning below -23 or above -9 LUFS); the first and last frames as PNGs (a warning when either is near-uniform). Exit 2 on any warning under --strict
16108
17896
  vos deliver <take> --to youtube,x,shorts,github (or all) [--look plate|gradient|dark|none] [--brand BRAND.md] [--set path=value] [--release v2.1] [--out dir] [--range a..b] [--parallel N] [--json]
16109
17897
  vos digest <take> [--out dir] [--full 960] [--crop 640] [--no-frames] [--transcript <file.json>] [--style <doc.json|vosId>] [--json]
17898
+ vos info <take> [--json]
17899
+ the take's two clocks, read-only: per media, every step with its id, what it did, its source seconds (meta.json tStart..tEnd), its caption and where it plays; then the output timeline after trims, speed and freezes: each segment, speed span, freeze, zoom and tilt span (or "(cut)"), every layer with its anchor resolved, each sound clip, the still and the film's end
16110
17900
  vos brand <url> [--out BRAND.md] [--json]
16111
17901
  vos open <take> [--studio <url>] [--print]
16112
17902
  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 (the mark and leader; the kicker is --accent)] [--brand BRAND.md] [--ground #hex] [--accent #hex] [--font "\u2026"] [--body-px <n> (the body size on the delivered frame)] [--id x] [--print] [--json]
@@ -16114,8 +17904,9 @@ Take pipeline
16114
17904
  vos actions from-agent-browser <steps.jsonl> [--out actions.json] [--url <url>] [--viewport WxH] [--json]
16115
17905
  vos ingest <video.webm|mp4> [--cursor <trace.zip|steps.jsonl|cursor.csv>] [--as footage|take] [--out take] [--offset <ms>] [--viewport WxH] [--background <slug|url|none>] [--json]
16116
17906
  a take from a recording someone else made (a cloud agent's PR video, a Playwright run's video, a Loom export): the file becomes recording.<container>, meta.json from its own dimensions and duration, and a trace beside it becomes cursor.json so the planner has clicks to zoom on; without one, nothing is planned and the done event says so. Without a trace the file opens as FINISHED footage (a render from HyperFrames or Remotion, a film): no card, no browser bar, no drawn cursor, its own frame rate; --as take gives a screen demo the card
16117
- vos join <take> <take|file.mp4>... --out <dir> [--force] [--captions] [--mute <file>]... [--json]
16118
- several takes (and plain video files) as ONE take, cut in argument order: the first is the primary (its frame, cursor, export and sound), each next one a media m1, m2, \u2026 with its footage copied, its spans tagged and renamed, its layers moved to where it now starts; a file opens full-bleed and its own sound plays as an audio clip (--mute <file> leaves it out). --captions lands every step's caption anchored to its step, each ending with its clip. The result must pass vos validate before anything is written
17907
+ vos join <take> <take|file.mp4>... --out <dir> [--keep-edits|--force] [--captions] [--zooms per-beat] [--mute <file>]... [--json]
17908
+ several takes (and plain video files) as ONE take, cut in argument order: the first is the primary (its frame, cursor, export and sound), each next one a media m1, m2, \u2026 with its footage copied, its spans tagged and renamed, its layers moved to where it now starts; a file opens full-bleed and its own sound plays as an audio clip (--mute <file> leaves it out). --captions lands every step's caption anchored to its step, starting as the step begins (anchor.at "start"), each ending with its clip. --zooms per-beat keeps a beat's zoom on its steps marked "zoom": true (as vos plan --zooms per-beat). The result must pass vos validate before anything is written
17909
+ --keep-edits joins again over an edited joined take and keeps its edits: inputs are matched to their media by source file (join.json records each input's path and footage hash), a kept input keeps its id and everything on it (segments in their edited order, spans, rejections, anchored layers, its sound), the frame, export, styles and still stay, a new input is joined fresh under a new id (and only it is captioned or re-planned), a re-recorded input (changed footage at its path, or the same script at a new one) keeps its edits re-timed by its steps as vos plan --reuse does, and an input that is gone takes its edits with it, each named. A new input's segments go before the next kept input's; reordering the kept inputs regroups the segments in argument order. The first input must stay the primary. --force replaces the take instead
16119
17910
  vos score <take> --track <slug|file> [--at <step id|output seconds>] [--gain 0..1] [--normalize <lufs|off>] [--fade-in <s>] [--fade-out <s>] [--dry-run] [--json]
16120
17911
  the film's music as one audio clip (id score): reads the track's loudness, finds its strongest swell and offsets the track so the swell lands at --at (a step, resolved through the cut, or output seconds); without --at it starts where the track first rises above its median level. The clip carries its measured loudness and a target, --normalize (default -16 LUFS), with --gain (default 0.5) a trim on it; --normalize off plays it at --gain alone
16121
17912
  vos audio measure <take> [--json]
@@ -16162,7 +17953,8 @@ Platform (vos.so) \u2014 fetch, edit, push, pull, repeat
16162
17953
  signed-in person's line carries for a key, once, no click
16163
17954
  vos fetch <vosId|watch-url> [--out dir] [--media] [--json]
16164
17955
  writes config.json + vos.json (no auth needed for public programs);
16165
- a take vos also gets its doc.json, and --media its footage
17956
+ a take vos also gets its doc.json, and --media its footage, its
17957
+ actions.json and every file that script pastes, beside it
16166
17958
  vos push <config.json|take> [--vos id] [--title t] [--slug s] [--remix-of id]
16167
17959
  [--desc d] [--tags a,b] [--folder <folderId|slug>]
16168
17960
  [--note n] [--label l] [--base versionId] [--override <id>]... [--yes] [--json]
@@ -16184,7 +17976,9 @@ Platform (vos.so) \u2014 fetch, edit, push, pull, repeat
16184
17976
  Without --folder a new vos lands unfiled, at the root of your
16185
17977
  shelf. A document's local files (doc.json: picture and video
16186
17978
  overlays, 3D props, sound; a take's mic and cam too) upload
16187
- with the push, each once. --wait stays until the version's still has
17979
+ with the push, each once, and so does every file a take's
17980
+ actions.json pastes (a picture through the upload door, a text
17981
+ file inside the document; one outside the take is refused). --wait stays until the version's still has
16188
17982
  rendered and prints absolute still and preview links.
16189
17983
  --still <seconds> sets a PROGRAM's cover (doc.json "still", kept
16190
17984
  by every later push); without it the platform picks an early
@@ -16298,9 +18092,11 @@ networkidle was asked for and never came; neither is a strict failure. --max-dur
16298
18092
  the hosted recording cap, read live from GET /api/limits (30 min when the
16299
18093
  origin is unreachable): the capture stops there and says so in one line.
16300
18094
  Three steps beyond the gestures: expect checks the page on camera
16301
- (selector, text or gone; enabled; timeout 5 s); await waits OFF camera
16302
- (until {selector|text|gone}, timeout 600 s, poll 2 s, reload, onTimeout
16303
- fail|continue), so the footage has no gap and the cut lands in
18095
+ (selector, text or gone; enabled; or js, an expression evaluated in the
18096
+ page, truthy or with equals a value, for state an app keeps in script,
18097
+ said with the value it got and kept on meta.steps[].value; timeout 5 s);
18098
+ await waits OFF camera (until {selector|text|gone|js}, timeout 600 s,
18099
+ poll 2 s, reload, onTimeout fail|continue), so the footage has no gap and the cut lands in
16304
18100
  meta.skips[] and becomes a segment boundary; download keeps a file the page
16305
18101
  makes in the take's outputs/ (selector: the download a click starts; src:
16306
18102
  the currentSrc of a video, audio or img; as: the file name), listed in
@@ -16314,7 +18110,14 @@ name) and a list of its recent items, with the mask that hides it;
16314
18110
  notices are information, never a --strict failure. A click, press or type with "guard": "spend" costs
16315
18111
  something real: --dry-run hovers it and never presses, and record presses
16316
18112
  it only with --confirm <id> (repeatable), refusing before any capture
16317
- otherwise. --hosted refuses guarded and download steps.
18113
+ otherwise. --hosted refuses guarded, download, paste and js steps.
18114
+ peers in actions.json add other people OFF camera: each its own browser
18115
+ context (storageState, by a path from the actions file) on the same url,
18116
+ its steps started with a take step or after one (by id); only the take's
18117
+ page is filmed and only its steps make the cursor track, so a peer's work
18118
+ reaches the footage through the app's own sync. A peer takes wait, move,
18119
+ hover, click, type, press, scroll, drag and expect; its misses are skips
18120
+ (--strict fails on them); meta.peers[] places its steps; --hosted refuses it.
16318
18121
  Every step is bounded: a call into a page that stopped answering gives up
16319
18122
  at the step's own timeout (an expect's or an await's, or a gesture's plan
16320
18123
  plus 30 s), the step is a skip with the real time it took, and --strict
@@ -16437,8 +18240,9 @@ lint-gated, so a bad override fails like a bad doc.json):
16437
18240
  first ready loop (the house backdrop the studio opens on), or a flat ground offline
16438
18241
  `;
16439
18242
  async function loadActions(file) {
16440
- const raw = JSON.parse(await readFile16(file, "utf8"));
18243
+ const raw = JSON.parse(await readFile18(file, "utf8"));
16441
18244
  const errors = validateActions(raw);
18245
+ if (!errors.length) errors.push(...jsSyntaxProblems(raw));
16442
18246
  if (errors.length)
16443
18247
  throw new UsageError(`invalid actions file:
16444
18248
  ${errors.join("\n ")}`);
@@ -16579,7 +18383,7 @@ function stepReportNote(rec) {
16579
18383
  ${l}`).join("");
16580
18384
  }
16581
18385
  function skippedLine(s) {
16582
- return `#${s.step}${s.id ? ` ${s.id}` : ""} ${s.do}${s.reason ? `: ${s.reason}` : ` ${s.selector}`}`;
18386
+ return `${s.peer ? `peer ${s.peer} ` : ""}#${s.step}${s.id ? ` ${s.id}` : ""} ${s.do}${s.reason ? `: ${s.reason}` : ` ${s.selector}`}`;
16583
18387
  }
16584
18388
  function takeHeaders(multi) {
16585
18389
  try {
@@ -16595,8 +18399,8 @@ function takeBrowserArgs(multi) {
16595
18399
  function takeStorageState(flags) {
16596
18400
  const raw = strFlag(flags, "storage-state");
16597
18401
  if (!raw) return void 0;
16598
- const path = resolve19(raw);
16599
- if (!existsSync28(path))
18402
+ const path = resolve20(raw);
18403
+ if (!existsSync29(path))
16600
18404
  throw new UsageError(`--storage-state: no such file: ${path}`);
16601
18405
  return path;
16602
18406
  }
@@ -16609,7 +18413,7 @@ function takeSessionName(flags, storageState) {
16609
18413
  throw new UsageError(
16610
18414
  "--session and --storage-state are two doors to one take: a persistent profile cannot take a storage state. Pass one"
16611
18415
  );
16612
- if (!existsSync28(sessionDir(name)))
18416
+ if (!existsSync29(sessionDir(name)))
16613
18417
  throw new UsageError(
16614
18418
  `no session "${name}". Make one: vos session open <url> --name ${name} (a Chrome window opens; sign in and close it)`
16615
18419
  );
@@ -16681,7 +18485,7 @@ async function cmdRecord(argv) {
16681
18485
  const scheme = urlSchemeProblem2(url);
16682
18486
  if (scheme) throw new UsageError(scheme);
16683
18487
  announceEnvNames(actions, r);
16684
- const outDir = resolve19(strFlag(flags, "out") ?? "take");
18488
+ const outDir = resolve20(strFlag(flags, "out") ?? "take");
16685
18489
  const storageState = takeStorageState(flags);
16686
18490
  const sessionName = takeSessionName(flags, storageState);
16687
18491
  const browserArgs = takeBrowserArgs(multi);
@@ -16701,7 +18505,7 @@ async function cmdRecord(argv) {
16701
18505
  );
16702
18506
  const hostedActions = await scriptForHost(
16703
18507
  actions,
16704
- dirname11(resolve19(actionsPath))
18508
+ dirname11(resolve20(actionsPath))
16705
18509
  ).catch((e) => {
16706
18510
  throw new UsageError(e instanceof Error ? e.message : String(e));
16707
18511
  });
@@ -16809,7 +18613,7 @@ async function cmdRecord(argv) {
16809
18613
  sessionName,
16810
18614
  actions,
16811
18615
  (context2) => recordTake(browser2, url, actions, takePaths(outDir), r.log, {
16812
- actionsDir: dirname11(resolve19(actionsPath)),
18616
+ actionsDir: dirname11(resolve20(actionsPath)),
16813
18617
  storageState,
16814
18618
  headers,
16815
18619
  context: context2,
@@ -16836,7 +18640,8 @@ async function cmdRecord(argv) {
16836
18640
  ...s.offCameraMs !== void 0 ? [
16837
18641
  `${s.timedOut ? "timed out after" : "held after"} ${(s.offCameraMs / 1e3).toFixed(1)} s off camera`
16838
18642
  ] : [],
16839
- ...out ? [`\u2192 ${out.name} (${out.bytes} bytes, not kept in a rehearsal)`] : []
18643
+ ...out ? [`\u2192 ${out.name} (${out.bytes} bytes, not kept in a rehearsal)`] : [],
18644
+ ...s.value !== void 0 && !s.skipped ? [`= ${sayValue(s.value)}`] : []
16840
18645
  ].map((n) => ` ${n}`);
16841
18646
  return ` ${s.skipped ? "\u2717" : "\u2713"} #${s.step}${s.id ? ` (${s.id})` : ""} ${s.do}${what}${s.skipped ? ` ${s.reason ?? "NOT FOUND"}` : rect}${s.navigated ? " \u2192 navigated" : ""}${notes.join("")}`;
16842
18647
  });
@@ -16845,6 +18650,16 @@ async function cmdRecord(argv) {
16845
18650
  lines.push(
16846
18651
  ` \u2013 #${i}${step.id ? ` (${step.id})` : ""} ${step.do} not run`
16847
18652
  );
18653
+ for (const pr of rec.peers) {
18654
+ const peer = actions.peers?.find((p) => p.id === pr.id);
18655
+ lines.push(` peer ${pr.id} (off camera):`);
18656
+ for (const ps of pr.steps)
18657
+ lines.push(
18658
+ ` ${ps.skipped ? "\u2717" : "\u2713"} #${ps.step}${ps.id ? ` (${ps.id})` : ""} ${ps.do}${ps.selector ? ` ${ps.selector}` : ""}${ps.with ? ` with ${ps.with}` : ps.after ? ` after ${ps.after}` : ""}${ps.skipped ? ` ${ps.reason ?? "NOT FOUND"}` : ""}${ps.late ? " (late)" : ""}`
18659
+ );
18660
+ for (const i of pr.notRun)
18661
+ lines.push(` \u2013 #${i} ${peer?.steps[i]?.do ?? "step"} not run`);
18662
+ }
16848
18663
  const resolved = steps.filter((s) => !s.skipped).length;
16849
18664
  const failed = strictFails(rec) || rec.stopped !== null;
16850
18665
  const unconfirmed = guardVerdict(actions, confirm);
@@ -16882,7 +18697,8 @@ async function cmdRecord(argv) {
16882
18697
  ready: rec.ready,
16883
18698
  exposures: rec.exposures,
16884
18699
  notices: rec.notices,
16885
- masks: rec.masks
18700
+ masks: rec.masks,
18701
+ ...rec.peers.length ? { peers: rec.peers } : {}
16886
18702
  },
16887
18703
  `${failed ? "REHEARSAL FAILED" : "Rehearsal passed"}: ${resolved}/${actions.steps.length} steps resolved in ${((Date.now() - started) / 1e3).toFixed(1)}s${rec.navTimeout ? " (the first load never reached networkidle)" : rec.ready.capped ? " (the first page never stopped changing)" : ""}
16888
18704
  ${lines.join("\n")}
@@ -16895,7 +18711,7 @@ ${lines.join("\n")}
16895
18711
  }
16896
18712
  const paths = takePaths(outDir);
16897
18713
  const prepare = async () => {
16898
- if (existsSync28(join31(outDir, "meta.json"))) {
18714
+ if (existsSync29(join33(outDir, "meta.json"))) {
16899
18715
  const { prevDoc, kept } = await prepareReRecord(outDir);
16900
18716
  r.log(
16901
18717
  `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" : "")
@@ -16912,7 +18728,7 @@ ${lines.join("\n")}
16912
18728
  sessionName,
16913
18729
  actions,
16914
18730
  (context2) => recordTake(browser, url, actions, paths, r.log, {
16915
- actionsDir: dirname11(resolve19(actionsPath)),
18731
+ actionsDir: dirname11(resolve20(actionsPath)),
16916
18732
  maxDurationSeconds,
16917
18733
  storageState,
16918
18734
  headers,
@@ -16990,11 +18806,12 @@ ${lines.join("\n")}
16990
18806
  ...rec.media.length ? { media: rec.media } : {},
16991
18807
  overruns: rec.overruns,
16992
18808
  pageErrors: rec.pageErrors,
18809
+ ...rec.peers.length ? { peers: rec.peers } : {},
16993
18810
  ...strictFail ? { strictFailed: true } : {}
16994
18811
  },
16995
18812
  strictFail ? `STRICT: take recorded but incomplete \u2014 ${strictReason(rec)}; fix the flow and re-record.${skippedNote}${stepNote}${navTimeoutNote(rec)}${exposureNote(rec)}${noticeNote(rec)}` : `Take ready: ${outDir}
16996
18813
  ${(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 \xB7 ${rec.dead.pct}% dead${deadWarns(rec.dead) ? " \u26A0 " + deadLine(rec.dead) : ""}${skippedNote}${stepNote}${navTimeoutNote(rec)}${pageSoundNote(rec)}
16997
- Next: edit ${join31(outDir, "doc.json")} (optional), then: vos render ${outDir}${exposureNote(rec)}${noticeNote(rec)}`
18814
+ Next: edit ${join33(outDir, "doc.json")} (optional), then: vos render ${outDir}${exposureNote(rec)}${noticeNote(rec)}`
16998
18815
  );
16999
18816
  return strictFail ? EXIT_USAGE : EXIT_OK;
17000
18817
  } finally {
@@ -17021,9 +18838,9 @@ async function cmdCreate2(argv) {
17021
18838
  const scheme = urlSchemeProblem2(url);
17022
18839
  if (scheme) throw new UsageError(scheme);
17023
18840
  announceEnvNames(actions, r);
17024
- const outDir = resolve19(strFlag(flags, "out") ?? "take");
18841
+ const outDir = resolve20(strFlag(flags, "out") ?? "take");
17025
18842
  const format = videoFormat(strFlag(flags, "format"), positionals[0]);
17026
- const out = positionals[0] ?? join31(outDir, `out.${format}`);
18843
+ const out = positionals[0] ?? join33(outDir, `out.${format}`);
17027
18844
  const parallel = numFlag(flags, "parallel", 1);
17028
18845
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
17029
18846
  throw new UsageError("--parallel expects an integer between 1 and 16");
@@ -17037,7 +18854,7 @@ async function cmdCreate2(argv) {
17037
18854
  const confirm = takeConfirm(multi, actions, { dryRun: false, r });
17038
18855
  const paths = takePaths(outDir);
17039
18856
  const prepare = async () => {
17040
- if (existsSync28(join31(outDir, "meta.json"))) {
18857
+ if (existsSync29(join33(outDir, "meta.json"))) {
17041
18858
  const { prevDoc, kept } = await prepareReRecord(outDir);
17042
18859
  r.log(
17043
18860
  `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" : "")
@@ -17054,7 +18871,7 @@ async function cmdCreate2(argv) {
17054
18871
  sessionName,
17055
18872
  actions,
17056
18873
  (context2) => recordTake(browser, url, actions, paths, r.log, {
17057
- actionsDir: dirname11(resolve19(actionsPath)),
18874
+ actionsDir: dirname11(resolve20(actionsPath)),
17058
18875
  maxDurationSeconds,
17059
18876
  storageState,
17060
18877
  headers,
@@ -17170,31 +18987,34 @@ async function cmdPlan(argvIn) {
17170
18987
  const dir = positionals[0];
17171
18988
  if (!dir)
17172
18989
  throw new UsageError(
17173
- 'vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<seconds>]]... [--motion] [--headline "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md]'
18990
+ 'vos plan <take> [--fresh] [--reuse [--from <doc.json>]] [--style <doc.json|vosId>] [--with <doc.json|vosId>[@end|@start|@step:<id>|@<seconds>]]... [--motion] [--headline "\u2026"] [--launch LAUNCH.md] [--brand BRAND.md] [--zooms per-beat]'
17174
18991
  );
17175
18992
  const r = createReporter(flags.json === true);
17176
18993
  if (flags.fresh === true) {
17177
- await rm6(join31(dir, "doc.json"), { force: true });
18994
+ await rm6(join33(dir, "doc.json"), { force: true });
17178
18995
  r.log("note: --fresh discarded the existing doc.json");
17179
18996
  }
17180
18997
  let reuse;
17181
18998
  if (flags.reuse === true) {
17182
- const from = resolve19(strFlag(flags, "from") ?? join31(dir, PREV_DOC_NAME));
17183
- if (!existsSync28(from)) {
18999
+ const from = resolve20(strFlag(flags, "from") ?? join33(dir, PREV_DOC_NAME));
19000
+ if (!existsSync29(from)) {
17184
19001
  throw new UsageError(
17185
19002
  `--reuse: ${from} does not exist \u2014 a re-record into this take writes doc.prev.json, or pass --from <doc.json>`
17186
19003
  );
17187
19004
  }
17188
19005
  reuse = {
17189
19006
  from,
17190
- doc: JSON.parse(await readFile16(from, "utf8"))
19007
+ doc: JSON.parse(await readFile18(from, "utf8"))
17191
19008
  };
17192
19009
  }
17193
19010
  const style = await resolveStyleRef(flags);
17194
- const hasDoc = existsSync28(join31(dir, "doc.json"));
19011
+ const hasDoc = existsSync29(join33(dir, "doc.json"));
17195
19012
  const backdrop = hasDoc ? null : await takeBackdrop(flags, r);
17196
19013
  const release = await releaseInputs(dir, flags, r, multi.with ?? []);
17197
19014
  const stepCaptions = strFlag(flags, "captions") === "steps";
19015
+ const zoomMode = zoomModeOf(strFlag(flags, "zooms"));
19016
+ if (zoomMode && typeof zoomMode === "object")
19017
+ throw new UsageError(zoomMode.error);
17198
19018
  if (stepCaptions) release.launchRoles.captions = "none";
17199
19019
  const motionWanted = !hasDoc || flags.motion === true || flags.fresh === true;
17200
19020
  const stillRaw = strFlag(flags, "still") ?? release.launchRoles.still;
@@ -17229,6 +19049,23 @@ async function cmdPlan(argvIn) {
17229
19049
  captions = landStepCaptions(s.doc, /* @__PURE__ */ new Map([["", take.actions]]));
17230
19050
  await writeJson(take.paths.doc, s.doc, true);
17231
19051
  }
19052
+ const marksTake = await loadTake(dir);
19053
+ const drift = scriptDrift(
19054
+ marksTake.meta.steps ?? [],
19055
+ marksTake.actions?.steps
19056
+ );
19057
+ if (drift) r.warn(scriptDriftLine(drift));
19058
+ const marks = applyZoomMarks(s.doc, /* @__PURE__ */ new Map([["", marksTake.actions]]));
19059
+ if (marks.rejected.length) await writeJson(marksTake.paths.doc, s.doc, true);
19060
+ const marksLine = zoomMarkLine(marks);
19061
+ let perBeat = null;
19062
+ if (zoomMode === "per-beat") {
19063
+ const take = await loadTake(dir);
19064
+ perBeat = oneZoomPerBeat(s.doc, /* @__PURE__ */ new Map([["", take.actions]]));
19065
+ await writeJson(take.paths.doc, s.doc, true);
19066
+ }
19067
+ const zoomAuto = s.doc.zoom.filter((z) => z.source !== "manual").length;
19068
+ const zoomManual = s.doc.zoom.length - zoomAuto;
17232
19069
  const captionLines = captions ? `
17233
19070
  ${captions.landed.length} step caption${captions.landed.length === 1 ? "" : "s"} anchored to their steps` + (captions.skipped.length ? `
17234
19071
  not landed: ${captions.skipped.join("; ")}` : "") + (captions.uncaptioned.length ? `
@@ -17249,20 +19086,23 @@ async function cmdPlan(argvIn) {
17249
19086
  ${s.reuse.flagged.join("\n ")}` : "") : "";
17250
19087
  r.done(
17251
19088
  {
17252
- take: resolve19(dir),
19089
+ take: resolve20(dir),
17253
19090
  fresh: s.fresh,
17254
19091
  cursorKept: s.cursorKept,
17255
- zoomAuto: s.zoomAuto,
17256
- zoomManual: s.zoomManual,
19092
+ zoomAuto,
19093
+ zoomManual,
17257
19094
  duration: s.doc.source.meta.durationMs / 1e3,
17258
19095
  ...s.backdrop !== void 0 ? { backdrop: s.backdrop } : {},
17259
19096
  ...s.styleFrom ? { styleFrom: s.styleFrom, styleFields: s.styleFields } : {},
17260
19097
  ...s.layout ? { layout: s.layout } : {},
17261
19098
  ...s.motion ? { motion: s.motion } : {},
17262
19099
  ...s.reuse ? { reuse: s.reuse } : {},
17263
- ...captions ? { captions } : {}
19100
+ ...captions ? { captions } : {},
19101
+ ...perBeat ? { perBeat } : {}
17264
19102
  },
17265
- `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join31(dir, "doc.json")}: ${s.zoomAuto} auto + ${s.zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${layoutLines}${motionLines}${reuseLines}${captionLines}`
19103
+ `${s.reuse ? "Reused" : s.fresh ? "Planned" : "Refreshed"} ${join33(dir, "doc.json")}: ${zoomAuto} auto + ${zoomManual} manual zoom spans${s.cursorKept ? "" : " (cursor track dropped)"}${layoutLines}${motionLines}${reuseLines}${captionLines}${marksLine ? `
19104
+ ${marksLine}` : ""}${perBeat ? `
19105
+ ${perBeatLine(perBeat)}` : ""}`
17266
19106
  );
17267
19107
  return EXIT_OK;
17268
19108
  }
@@ -17385,12 +19225,12 @@ async function cmdRender(argv) {
17385
19225
  );
17386
19226
  const r = createReporter(flags.json === true);
17387
19227
  const format = videoFormat(strFlag(flags, "format"), positionals[1]);
17388
- const out = positionals[1] ?? join31(dir, `out.${format}`);
19228
+ const out = positionals[1] ?? join33(dir, `out.${format}`);
17389
19229
  const parallel = numFlag(flags, "parallel", 1);
17390
19230
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
17391
19231
  throw new UsageError("--parallel expects an integer between 1 and 16");
17392
19232
  }
17393
- let range;
19233
+ let range2;
17394
19234
  const rangeRaw = strFlag(flags, "range");
17395
19235
  if (rangeRaw !== void 0) {
17396
19236
  const m = /^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)$/.exec(rangeRaw);
@@ -17398,8 +19238,8 @@ async function cmdRender(argv) {
17398
19238
  throw new UsageError(
17399
19239
  "--range expects a..b in OUTPUT seconds (e.g. --range 4..8)"
17400
19240
  );
17401
- range = [Number(m[1]), Number(m[2])];
17402
- if (range[1] <= range[0])
19241
+ range2 = [Number(m[1]), Number(m[2])];
19242
+ if (range2[1] <= range2[0])
17403
19243
  throw new UsageError("--range end must be > start");
17404
19244
  }
17405
19245
  const browser = await launchBrowser();
@@ -17410,7 +19250,7 @@ async function cmdRender(argv) {
17410
19250
  fps: hasFlag(flags, "fps") ? numFlag(flags, "fps", 30) : void 0,
17411
19251
  format,
17412
19252
  parallel,
17413
- range,
19253
+ range: range2,
17414
19254
  draft: flags.draft === true,
17415
19255
  overrides: {
17416
19256
  set: multi.set,
@@ -17430,10 +19270,10 @@ async function cmdRender(argv) {
17430
19270
  r.done(
17431
19271
  {
17432
19272
  ...result,
17433
- ...range ? { range } : {},
19273
+ ...range2 ? { range: range2 } : {},
17434
19274
  ...flags.draft === true ? { draft: true } : {}
17435
19275
  },
17436
- `Wrote ${result.out} (${(result.bytes / 1024).toFixed(0)} KB, ${result.width}x${result.height}@${result.fps}, ${result.duration.toFixed(1)}s, ${result.zoomSpans} zoom spans, ${result.clicks} click effects${result.audio ? ", audio" : ""}${result.chunks > 1 ? `, ${result.chunks} parallel chunks` : ""}${range ? `, range ${range[0]}..${range[1]}s` : ""}${flags.draft === true ? ", DRAFT" : ""})`
19276
+ `Wrote ${result.out} (${(result.bytes / 1024).toFixed(0)} KB, ${result.width}x${result.height}@${result.fps}, ${result.duration.toFixed(1)}s, ${result.zoomSpans} zoom spans, ${result.clicks} click effects${result.audio ? ", audio" : ""}${result.chunks > 1 ? `, ${result.chunks} parallel chunks` : ""}${range2 ? `, range ${range2[0]}..${range2[1]}s` : ""}${flags.draft === true ? ", DRAFT" : ""})`
17437
19277
  );
17438
19278
  return EXIT_OK;
17439
19279
  } finally {
@@ -17461,7 +19301,7 @@ async function cmdFrames(argv) {
17461
19301
  if (!m) throw new UsageError("--size expects WxH (e.g. --size 1280x800)");
17462
19302
  size = { width: Number(m[1]), height: Number(m[2]) };
17463
19303
  }
17464
- const duration = docOutputDuration4(take.doc);
19304
+ const duration = docOutputDuration5(take.doc);
17465
19305
  const frameRaw = strFlag(flags, "frame");
17466
19306
  const timesRaw = strFlag(flags, "times");
17467
19307
  const atZooms = flags["at-zooms"] === true;
@@ -17546,7 +19386,7 @@ async function cmdDeliver(argv) {
17546
19386
  } catch (e) {
17547
19387
  throw new UsageError(e instanceof Error ? e.message : String(e));
17548
19388
  }
17549
- let range;
19389
+ let range2;
17550
19390
  const rangeRaw = strFlag(flags, "range");
17551
19391
  if (rangeRaw !== void 0) {
17552
19392
  const m = /^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)$/.exec(rangeRaw);
@@ -17554,8 +19394,8 @@ async function cmdDeliver(argv) {
17554
19394
  throw new UsageError(
17555
19395
  "--range expects a..b in OUTPUT seconds (e.g. --range 4..24)"
17556
19396
  );
17557
- range = [Number(m[1]), Number(m[2])];
17558
- if (range[1] <= range[0])
19397
+ range2 = [Number(m[1]), Number(m[2])];
19398
+ if (range2[1] <= range2[0])
17559
19399
  throw new UsageError("--range end must be > start");
17560
19400
  }
17561
19401
  const take = await loadTake(dir);
@@ -17581,7 +19421,7 @@ async function cmdDeliver(argv) {
17581
19421
  channels,
17582
19422
  outDir: strFlag(flags, "out"),
17583
19423
  release: strFlag(flags, "release"),
17584
- range,
19424
+ range: range2,
17585
19425
  parallel,
17586
19426
  overrides: {
17587
19427
  set: multi.set,
@@ -17626,15 +19466,15 @@ async function resolveStyleRef(flags) {
17626
19466
  }
17627
19467
  async function resolveDocRef(ref, flags, what) {
17628
19468
  const styleRef = ref;
17629
- const file = existsSync28(styleRef) ? resolve19(
19469
+ const file = existsSync29(styleRef) ? resolve20(
17630
19470
  styleRef,
17631
- existsSync28(join31(styleRef, "doc.json")) ? "doc.json" : ""
19471
+ existsSync29(join33(styleRef, "doc.json")) ? "doc.json" : ""
17632
19472
  ) : null;
17633
- if (file && existsSync28(file)) {
19473
+ if (file && existsSync29(file)) {
17634
19474
  return {
17635
19475
  from: file,
17636
19476
  doc: migrateHostedDoc4(
17637
- JSON.parse(await readFile16(file, "utf8"))
19477
+ JSON.parse(await readFile18(file, "utf8"))
17638
19478
  )
17639
19479
  };
17640
19480
  }
@@ -17686,7 +19526,7 @@ async function cmdDigest(argv) {
17686
19526
  const take = await loadTake(dir);
17687
19527
  if (!take.doc) throw new UsageError(`${dir} has no doc.json \u2014 run plan first`);
17688
19528
  const transcriptPath = strFlag(flags, "transcript");
17689
- const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile16(transcriptPath, "utf8"))) : null;
19529
+ const transcript = transcriptPath ? parseTranscript(JSON.parse(await readFile18(transcriptPath, "utf8"))) : null;
17690
19530
  const style = await resolveStyleRef(flags);
17691
19531
  const noFrames = flags["no-frames"] === true;
17692
19532
  let browser = null;
@@ -17717,7 +19557,7 @@ async function cmdDigest(argv) {
17717
19557
  kinds,
17718
19558
  frames: d.images.full,
17719
19559
  crops: d.images.crop,
17720
- sheet: d.images.sheet ? join31(result.outDir, d.images.sheet) : null,
19560
+ sheet: d.images.sheet ? join33(result.outDir, d.images.sheet) : null,
17721
19561
  scenes: kinds.scene ?? 0,
17722
19562
  sourceDuration: d.take.sourceDuration,
17723
19563
  outputDuration: d.take.outputDuration,
@@ -17745,14 +19585,14 @@ async function cmdOpen(argv) {
17745
19585
  if (!dir) throw new UsageError("vos open <take> [--studio <url>]");
17746
19586
  const r = createReporter(flags.json === true);
17747
19587
  const take = await loadTake(dir);
17748
- if (!existsSync28(take.paths.recording)) {
19588
+ if (!existsSync29(take.paths.recording)) {
17749
19589
  throw new UsageError(`${dir} has no recording \u2014 re-run record`);
17750
19590
  }
17751
19591
  const studio = (strFlag(flags, "studio") ?? platformOrigin()).replace(
17752
19592
  /\/+$/,
17753
19593
  ""
17754
19594
  );
17755
- const server = await startTakeServer(resolve19(dir), {});
19595
+ const server = await startTakeServer(resolve20(dir), {});
17756
19596
  const url = `${studio}/studio?take=${encodeURIComponent(server.base)}`;
17757
19597
  r.event({ event: "open", server: server.base, url });
17758
19598
  r.log(`take served at ${server.base}`);
@@ -17799,7 +19639,7 @@ async function cmdCallout(argv) {
17799
19639
  `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`
17800
19640
  );
17801
19641
  const codeFile = strFlag(flags, "code-file");
17802
- const code = codeFile ? await readFile16(codeFile, "utf8") : strFlag(flags, "code");
19642
+ const code = codeFile ? await readFile18(codeFile, "utf8") : strFlag(flags, "code");
17803
19643
  const words2 = {
17804
19644
  kicker: strFlag(flags, "kicker"),
17805
19645
  title: strFlag(flags, "title"),
@@ -17878,7 +19718,7 @@ async function cmdValidate(argv) {
17878
19718
  const target = positionals[0];
17879
19719
  if (!target) throw new UsageError("vos validate <actions.json|take>");
17880
19720
  const r = createReporter(flags.json === true);
17881
- const kitTarget = target.endsWith("kit.json") ? target : existsSync28(join31(target, "kit.json")) && !isTakeDir(target) ? join31(target, "kit.json") : null;
19721
+ const kitTarget = target.endsWith("kit.json") ? target : existsSync29(join33(target, "kit.json")) && !isTakeDir(target) ? join33(target, "kit.json") : null;
17882
19722
  if (kitTarget) {
17883
19723
  const picture = flags.picture === true;
17884
19724
  const verdict = await validateKit(kitTarget, { picture });
@@ -17910,9 +19750,9 @@ async function cmdValidate(argv) {
17910
19750
  r.done({ valid: true, target }, `${target}: valid actions file`);
17911
19751
  return EXIT_OK;
17912
19752
  }
17913
- if (!isTakeDir(target) && existsSync28(join31(target, "config.json"))) {
19753
+ if (!isTakeDir(target) && existsSync29(join33(target, "config.json"))) {
17914
19754
  const parsed = JSON.parse(
17915
- await readFile16(join31(target, "config.json"), "utf8")
19755
+ await readFile18(join33(target, "config.json"), "utf8")
17916
19756
  );
17917
19757
  const pre = preflightConfig(parsed);
17918
19758
  const problems2 = pre.ok ? [] : pre.issues.map((i) => `config.json: ${i}`);
@@ -17954,7 +19794,7 @@ async function cmdValidate(argv) {
17954
19794
  const problems = [];
17955
19795
  const warnings = [];
17956
19796
  const notes = [];
17957
- if (!existsSync28(take.paths.recording)) {
19797
+ if (!existsSync29(take.paths.recording)) {
17958
19798
  problems.push("missing recording (re-run record)");
17959
19799
  } else {
17960
19800
  const mismatch = containerMismatch(
@@ -17995,16 +19835,12 @@ async function cmdValidate(argv) {
17995
19835
  const found = localFile(rel, target);
17996
19836
  if (!("refused" in found)) continue;
17997
19837
  problems.push(
17998
- existsSync28(resolve19(target, rel)) ? `doc.json: ${ref.where}: ${ref.key} ${found.refused}` : `doc.json: ${ref.where}: ${ref.key} is not a file in the take, so a render plays the film without it (put the file there, or fix the key)`
19838
+ existsSync29(resolve20(target, rel)) ? `doc.json: ${ref.where}: ${ref.key} ${found.refused}` : `doc.json: ${ref.where}: ${ref.key} is not a file in the take, so a render plays the film without it (put the file there, or fix the key)`
17999
19839
  );
18000
19840
  }
18001
19841
  if (flags.picture === true && (take.doc.overlays ?? []).some((o) => o.kind === "html")) {
18002
19842
  const doc = take.doc;
18003
- const times = [
18004
- ...new Set(
18005
- (doc.overlays ?? []).filter((o) => o.kind === "html").map((o) => +(o.start + Math.min(0.35, o.duration / 2)).toFixed(3))
18006
- )
18007
- ];
19843
+ const times = groundCheckTimes(doc);
18008
19844
  const browser = await launchBrowser();
18009
19845
  try {
18010
19846
  const frames = await footageFrames(
@@ -18078,7 +19914,7 @@ async function cmdPush3(argv) {
18078
19914
  );
18079
19915
  const refusal = takePushRefusal({
18080
19916
  vos: strFlag(flags, "vos"),
18081
- trackedVosId: readSyncState(resolve19(dir))?.vosId ?? null,
19917
+ trackedVosId: readSyncState(resolve20(dir))?.vosId ?? null,
18082
19918
  programOnly: ["slug", "desc", "tags", "base", "remix-of", "still"].filter(
18083
19919
  (name) => hasFlag(flags, name)
18084
19920
  )
@@ -18094,7 +19930,7 @@ async function cmdPush3(argv) {
18094
19930
  `--claimable creates a NEW claimable vos; ${iterating.map((f) => `--${f}`).join(", ")} ${iterating.length > 1 ? "apply" : "applies"} to a keyed push`
18095
19931
  );
18096
19932
  const claim = await pushTakeClaimable(
18097
- resolve19(dir),
19933
+ resolve20(dir),
18098
19934
  {
18099
19935
  api: strFlag(flags, "api"),
18100
19936
  origin: strFlag(flags, "origin"),
@@ -18117,7 +19953,7 @@ reference and the only credential.`
18117
19953
  }
18118
19954
  const overrides = multi.override;
18119
19955
  const result = await pushTake(
18120
- resolve19(dir),
19956
+ resolve20(dir),
18121
19957
  {
18122
19958
  key: strFlag(flags, "key"),
18123
19959
  api: strFlag(flags, "api"),
@@ -18172,7 +20008,7 @@ async function cmdPull2(argv) {
18172
20008
  const dir = positionals[0] ?? ".";
18173
20009
  const r = createReporter(flags.json === true);
18174
20010
  const result = await pullTake(
18175
- resolve19(dir),
20011
+ resolve20(dir),
18176
20012
  {
18177
20013
  key: strFlag(flags, "key"),
18178
20014
  api: strFlag(flags, "api"),
@@ -18301,6 +20137,8 @@ async function run(argv) {
18301
20137
  return await cmdCheckVideo(rest);
18302
20138
  case "join":
18303
20139
  return await cmdJoin(rest);
20140
+ case "info":
20141
+ return await cmdTakeInfo(rest);
18304
20142
  case "score":
18305
20143
  return await cmdScore(rest);
18306
20144
  case "audio":
@@ -18316,7 +20154,7 @@ async function run(argv) {
18316
20154
  `);
18317
20155
  return EXIT_USAGE;
18318
20156
  }
18319
- if (e instanceof SetupError || e instanceof SetupEnvError || e instanceof RecordRefusedError || e instanceof PlatformBehindError || e instanceof PushRefusedError) {
20157
+ if (e instanceof SetupError || e instanceof SetupEnvError || e instanceof RecordRefusedError || e instanceof PlatformBehindError || e instanceof PushRefusedError || e instanceof PasteFileRefusal) {
18320
20158
  process.stderr.write(`${e.message}
18321
20159
  `);
18322
20160
  return EXIT_USAGE;
@@ -18389,7 +20227,7 @@ async function cmdSession(argv) {
18389
20227
  if (!arg) throw new UsageError(usage);
18390
20228
  const bad = sessionNameVerdict(arg);
18391
20229
  if (bad) throw new UsageError(bad);
18392
- if (!existsSync28(sessionDir(arg)))
20230
+ if (!existsSync29(sessionDir(arg)))
18393
20231
  throw new UsageError(`no session "${arg}" (vos session list)`);
18394
20232
  const summary3 = await summarizeSession(arg);
18395
20233
  const url = strFlag(flags, "url");
@@ -18466,4 +20304,4 @@ export {
18466
20304
  verbHelp,
18467
20305
  run
18468
20306
  };
18469
- //# sourceMappingURL=chunk-TWAP4BKR.js.map
20307
+ //# sourceMappingURL=chunk-SP5OHTVB.js.map