playhead-cli 0.1.3 → 0.2.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.
Files changed (56) hide show
  1. package/Dockerfile +9 -6
  2. package/README.md +43 -7
  3. package/action.yml +56 -2
  4. package/dist/audio/mux.d.ts +2 -0
  5. package/dist/audio/mux.d.ts.map +1 -1
  6. package/dist/authoring/author.d.ts +8 -2
  7. package/dist/authoring/author.d.ts.map +1 -1
  8. package/dist/authoring/catalog.d.ts.map +1 -1
  9. package/dist/authoring/explore.d.ts.map +1 -1
  10. package/dist/authoring/validate.d.ts.map +1 -1
  11. package/dist/bundle/types.d.ts +2 -0
  12. package/dist/bundle/types.d.ts.map +1 -1
  13. package/dist/bundle/writer.d.ts +6 -1
  14. package/dist/bundle/writer.d.ts.map +1 -1
  15. package/dist/capture/driver-api.d.ts +14 -1
  16. package/dist/capture/driver-api.d.ts.map +1 -1
  17. package/dist/capture/events.d.ts +1 -1
  18. package/dist/capture/events.d.ts.map +1 -1
  19. package/dist/capture/executor.d.ts.map +1 -1
  20. package/dist/capture/playwright-driver.d.ts +22 -2
  21. package/dist/capture/playwright-driver.d.ts.map +1 -1
  22. package/dist/cli/index.js +1969 -514
  23. package/dist/cli/index.js.map +1 -1
  24. package/dist/compose/camera/interpolate.d.ts +16 -3
  25. package/dist/compose/camera/interpolate.d.ts.map +1 -1
  26. package/dist/compose/camera/planner.d.ts.map +1 -1
  27. package/dist/compose/captions.d.ts.map +1 -1
  28. package/dist/compose/index.d.ts.map +1 -1
  29. package/dist/compose/overlays/build.d.ts.map +1 -1
  30. package/dist/compose/overlays/draw.d.ts.map +1 -1
  31. package/dist/compose/render/encoder.d.ts.map +1 -1
  32. package/dist/compose/render/frameStore.d.ts.map +1 -1
  33. package/dist/compose/render/renderer.d.ts.map +1 -1
  34. package/dist/compose/timeline/pacing.d.ts +4 -0
  35. package/dist/compose/timeline/pacing.d.ts.map +1 -1
  36. package/dist/compose/types.d.ts +14 -3
  37. package/dist/compose/types.d.ts.map +1 -1
  38. package/dist/index.js +1299 -382
  39. package/dist/index.js.map +1 -1
  40. package/dist/mcp/bin.js +1833 -726
  41. package/dist/mcp/bin.js.map +1 -1
  42. package/dist/mcp/server.d.ts.map +1 -1
  43. package/dist/shared/version.d.ts +0 -2
  44. package/dist/shared/version.d.ts.map +1 -1
  45. package/dist/spec/locators.d.ts.map +1 -1
  46. package/dist/spec/parse.d.ts.map +1 -1
  47. package/dist/spec/schema.d.ts +263 -2
  48. package/dist/spec/schema.d.ts.map +1 -1
  49. package/dist/verify/checks.d.ts.map +1 -1
  50. package/dist/verify/report.d.ts +33 -6
  51. package/dist/verify/report.d.ts.map +1 -1
  52. package/dist/verify/runner.d.ts +1 -1
  53. package/dist/verify/runner.d.ts.map +1 -1
  54. package/dist/verify/types.d.ts +36 -4
  55. package/dist/verify/types.d.ts.map +1 -1
  56. package/package.json +1 -1
package/dist/mcp/bin.js CHANGED
@@ -76,8 +76,48 @@ var init_log = __esm({
76
76
  }
77
77
  });
78
78
 
79
+ // src/shared/exit.ts
80
+ var exit_exports = {};
81
+ __export(exit_exports, {
82
+ EXIT: () => EXIT,
83
+ FlowError: () => FlowError,
84
+ InfraError: () => InfraError,
85
+ exitCodeFor: () => exitCodeFor
86
+ });
87
+ function exitCodeFor(e) {
88
+ if (e && typeof e === "object") {
89
+ if ("exitCode" in e && typeof e.exitCode === "number") {
90
+ return e.exitCode;
91
+ }
92
+ if (e.name === "SpecError" || e instanceof Object && e.constructor?.name === "SpecError") {
93
+ return EXIT.USAGE;
94
+ }
95
+ }
96
+ return EXIT.INFRA;
97
+ }
98
+ var EXIT, FlowError, InfraError;
99
+ var init_exit = __esm({
100
+ "src/shared/exit.ts"() {
101
+ "use strict";
102
+ EXIT = {
103
+ OK: 0,
104
+ FLOW: 1,
105
+ QUALITY: 2,
106
+ INFRA: 3,
107
+ USAGE: 4
108
+ };
109
+ FlowError = class extends Error {
110
+ exitCode = EXIT.FLOW;
111
+ };
112
+ InfraError = class extends Error {
113
+ exitCode = EXIT.INFRA;
114
+ };
115
+ }
116
+ });
117
+
79
118
  // src/capture/playwright-driver.ts
80
119
  import { chromium } from "playwright";
120
+ import { access } from "fs/promises";
81
121
  function toPw(m) {
82
122
  if ("exact" in m) return m.exact;
83
123
  if ("substring" in m) return m.substring;
@@ -114,12 +154,31 @@ function withTimeout(p, ms) {
114
154
  );
115
155
  });
116
156
  }
157
+ function imageWidth(data, format) {
158
+ try {
159
+ if (format === "png") {
160
+ return data.length >= 24 ? data.readUInt32BE(16) : null;
161
+ }
162
+ let i = 2;
163
+ while (i + 9 < data.length) {
164
+ if (data[i] !== 255) return null;
165
+ const marker = data[i + 1];
166
+ if (marker >= 192 && marker <= 195) return data.readUInt16BE(i + 7);
167
+ const len = data.readUInt16BE(i + 2);
168
+ i += 2 + len;
169
+ }
170
+ return null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
117
175
  var INPUT_TIMEOUT_MS, MASK_INIT_SCRIPT, FALLBACK_NAME_FN, COLLECT_INTERACTABLES_FN, PlaywrightDriver;
118
176
  var init_playwright_driver = __esm({
119
177
  "src/capture/playwright-driver.ts"() {
120
178
  "use strict";
121
179
  init_easing();
122
180
  init_log();
181
+ init_exit();
123
182
  INPUT_TIMEOUT_MS = 5e3;
124
183
  MASK_INIT_SCRIPT = `
125
184
  (() => {
@@ -162,11 +221,24 @@ var init_playwright_driver = __esm({
162
221
  // is tagged the frame it appears \u2014 not seconds later at the next step boundary. Node-side
163
222
  // tagging remains the backstop for rules needing Playwright semantics (role/label/text).
164
223
  let scanTick = 0;
224
+ // querySelectorAll does NOT pierce shadow roots \u2014 but Playwright's tagging does, so a masked
225
+ // element inside a web component (any design system) would be tagged yet never overlaid,
226
+ // filming the secret while masks.json claims coverage (round-2 audit). Walk shadow roots too.
227
+ const deepQuery = (sel) => {
228
+ const out = [];
229
+ const walk = (root) => {
230
+ try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch (e) {}
231
+ const all = root.querySelectorAll('*');
232
+ for (const el of all) if (el.shadowRoot) walk(el.shadowRoot);
233
+ };
234
+ walk(document);
235
+ return out;
236
+ };
165
237
  const scanRules = () => {
166
238
  const rules = window.__playheadMaskCssRules || [];
167
239
  for (const r of rules) {
168
240
  try {
169
- for (const el of document.querySelectorAll(r.css)) {
241
+ for (const el of deepQuery(r.css)) {
170
242
  if (!el.hasAttribute('data-playhead-mask')) el.setAttribute('data-playhead-mask', r.style);
171
243
  }
172
244
  } catch (e) {}
@@ -175,7 +247,7 @@ var init_playwright_driver = __esm({
175
247
  const tick = () => {
176
248
  try {
177
249
  if (scanTick++ % 3 === 0) scanRules(); // every ~3 frames \u2014 cheap, and a 1-frame leak beats a 1-step leak
178
- const tagged = new Set(document.querySelectorAll('[data-playhead-mask]'));
250
+ const tagged = new Set(deepQuery('[data-playhead-mask]'));
179
251
  for (const [el, box] of boxes) {
180
252
  if (!tagged.has(el) || !el.isConnected) { box.remove(); boxes.delete(el); }
181
253
  }
@@ -325,9 +397,23 @@ var init_playwright_driver = __esm({
325
397
  tagCounter = 0;
326
398
  typingMaskLoc = null;
327
399
  async launch(opts) {
400
+ try {
401
+ await access(chromium.executablePath());
402
+ } catch {
403
+ throw new InfraError(
404
+ "Chromium is not installed (one-time setup) \u2014 run: npx playwright install chromium"
405
+ );
406
+ }
328
407
  const env = opts.environment;
329
- this.browser = await chromium.launch({ headless: opts.headless ?? true });
408
+ this.forcedDsf = opts.dpr > 1 && process.env.PLAYHEAD_CAPTURE !== "screenshot" ? opts.dpr : 1;
409
+ this.browser = await chromium.launch({
410
+ headless: opts.headless ?? true,
411
+ ...this.forcedDsf > 1 ? { args: [`--force-device-scale-factor=${this.forcedDsf}`] } : {}
412
+ });
413
+ const chromeMajor = this.browser.version().split(".")[0] ?? this.browser.version();
414
+ const uaPlatform = process.platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
330
415
  this.context = await this.browser.newContext({
416
+ userAgent: `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`,
331
417
  viewport: { width: opts.viewport.w, height: opts.viewport.h },
332
418
  deviceScaleFactor: opts.dpr,
333
419
  colorScheme: env?.colorScheme ?? "light",
@@ -366,7 +452,10 @@ var init_playwright_driver = __esm({
366
452
  });
367
453
  this.page.on("console", (msg) => this.pushConsole(msg.type(), msg.text()));
368
454
  this.page.on("pageerror", (err) => this.pushConsole("pageerror", err.message));
369
- this.page.on("crash", () => this.pushConsole("crash", "page crashed"));
455
+ this.page.on("crash", () => {
456
+ this.pushConsole("crash", "page crashed");
457
+ this.crashed = true;
458
+ });
370
459
  const dialogPolicy = opts.dialogs ?? "accept";
371
460
  this.page.on("dialog", (dialog) => {
372
461
  this.pushConsole("dialog", `${dialog.type()}("${dialog.message()}") \u2192 ${dialogPolicy}`);
@@ -383,6 +472,12 @@ var init_playwright_driver = __esm({
383
472
  await this.page.mouse.move(this.cursor.x, this.cursor.y);
384
473
  }
385
474
  consoleBuf = [];
475
+ /** Browser-level forced device scale (--force-device-scale-factor); 1 = not forced. */
476
+ forcedDsf = 1;
477
+ crashed = false;
478
+ assertAlive() {
479
+ if (this.crashed) throw new Error("page crashed \u2014 the browser renderer died (see console.json)");
480
+ }
386
481
  pushConsole(type, text) {
387
482
  this.consoleBuf.push({ t: Date.now(), type, text: text.slice(0, 500) });
388
483
  if (this.consoleBuf.length > 500) this.consoleBuf.shift();
@@ -500,6 +595,7 @@ var init_playwright_driver = __esm({
500
595
  return l;
501
596
  }
502
597
  async resolveTarget(loc, timeoutMs) {
598
+ this.assertAlive();
503
599
  const locator = this.toLocator(loc);
504
600
  try {
505
601
  await locator.waitFor({ state: "visible", timeout: timeoutMs });
@@ -563,6 +659,8 @@ var init_playwright_driver = __esm({
563
659
  if (loc.frames && loc.frames.length > 0) {
564
660
  const box = await withTimeout(locator.boundingBox(), 3e3).catch(() => null);
565
661
  if (box) geom.bbox = { x: box.x, y: box.y, w: box.width, h: box.height };
662
+ const main = await withTimeout(this.page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })), 2e3).catch(() => null);
663
+ if (main) geom.scroll = main;
566
664
  }
567
665
  let role = null;
568
666
  let name = null;
@@ -654,12 +752,69 @@ var init_playwright_driver = __esm({
654
752
  * through the checks a raw coordinate click bypasses (a toast/sticky header drifting under
655
753
  * the point between measure and click silently redirects a coordinate click).
656
754
  */
755
+ async setInputFiles(loc, filePath) {
756
+ await this.toLocator(loc).setInputFiles(filePath, { timeout: INPUT_TIMEOUT_MS * 2 });
757
+ }
758
+ /** Real drag: hover the source, press, glide to the destination in eased steps (recording
759
+ * pressed-cursor waypoints for the film), release over the target's current position. */
760
+ async dragTo(from, to, timeoutMs) {
761
+ const src = this.toLocator(from);
762
+ const dst = this.toLocator(to);
763
+ await src.hover({ timeout: timeoutMs });
764
+ const a = await src.boundingBox();
765
+ if (!a) throw new Error(`drag source vanished: ${from.raw}`);
766
+ const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 };
767
+ const tDown = Date.now();
768
+ await withTimeout(this.page.mouse.down(), INPUT_TIMEOUT_MS);
769
+ const path = [{ x: start.x, y: start.y, t: tDown }];
770
+ const b = await dst.boundingBox();
771
+ if (!b) {
772
+ await this.page.mouse.up().catch(() => {
773
+ });
774
+ throw new Error(`drag destination not found: ${to.raw}`);
775
+ }
776
+ const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
777
+ const STEPS = 24;
778
+ for (let i = 1; i <= STEPS; i++) {
779
+ const u = minJerk(i / STEPS);
780
+ const p = { x: start.x + (end.x - start.x) * u, y: start.y + (end.y - start.y) * u };
781
+ await withTimeout(this.page.mouse.move(p.x, p.y), INPUT_TIMEOUT_MS);
782
+ path.push({ x: p.x, y: p.y, t: Date.now() });
783
+ await sleep(18);
784
+ }
785
+ const tUp = Date.now();
786
+ await withTimeout(this.page.mouse.up(), INPUT_TIMEOUT_MS);
787
+ this.cursor = end;
788
+ return { tDown, tUp, path };
789
+ }
657
790
  async actClick(loc, opts) {
658
791
  const locator = this.toLocator(loc);
659
- const tDown = Date.now();
792
+ await locator.hover({ timeout: opts?.timeoutMs ?? 1e4 }).catch(() => {
793
+ });
794
+ await withTimeout(
795
+ this.page.evaluate(() => new Promise(requestAnimationFrame).then(() => new Promise(requestAnimationFrame))),
796
+ 1200
797
+ ).catch(() => {
798
+ });
799
+ await this.captureNow();
800
+ const before = Date.now();
801
+ await locator.evaluate((el) => {
802
+ const w = el.ownerDocument.defaultView;
803
+ if (w) {
804
+ w.__phTDown = null;
805
+ el.addEventListener("pointerdown", () => w.__phTDown = Date.now(), { once: true, capture: true });
806
+ }
807
+ }).catch(() => {
808
+ });
660
809
  if (opts?.double) await locator.dblclick({ delay: 60, timeout: opts?.timeoutMs ?? 1e4 });
661
- else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4 });
662
- return { tDown, tUp: Date.now() };
810
+ else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4, ...opts?.button ? { button: opts.button } : {} });
811
+ const tUp = Date.now();
812
+ const browserTDown = await withTimeout(
813
+ locator.evaluate((el) => el.ownerDocument.defaultView?.__phTDown ?? null),
814
+ 1500
815
+ ).catch(() => null);
816
+ const tDown = typeof browserTDown === "number" ? browserTDown - this.clockOffset : Math.max(before, tUp - 90);
817
+ return { tDown, tUp };
663
818
  }
664
819
  async selectOption(loc, value) {
665
820
  const locator = this.toLocator(loc);
@@ -668,8 +823,6 @@ var init_playwright_driver = __esm({
668
823
  } catch {
669
824
  await locator.selectOption(value);
670
825
  }
671
- await withTimeout(this.page.keyboard.press("Escape"), INPUT_TIMEOUT_MS).catch(() => {
672
- });
673
826
  await withTimeout(
674
827
  locator.evaluate((el) => el.blur?.()),
675
828
  2e3
@@ -705,6 +858,22 @@ var init_playwright_driver = __esm({
705
858
  }
706
859
  }
707
860
  async expectState(loc, opts, timeoutMs) {
861
+ if (opts.url !== void 0) {
862
+ const want = opts.url;
863
+ const matches = (u) => {
864
+ const m = /^\/(.+)\/([a-z]*)$/.exec(want);
865
+ return m ? new RegExp(m[1], m[2]).test(u) : u.includes(want);
866
+ };
867
+ const deadlineUrl = Date.now() + timeoutMs;
868
+ while (!matches(this.page.url())) {
869
+ if (Date.now() > deadlineUrl) {
870
+ throw new Error(`Expectation not met for URL: expected ${want}, got ${this.page.url()}`);
871
+ }
872
+ await sleep(100);
873
+ }
874
+ if (!loc) return;
875
+ }
876
+ if (!loc) return;
708
877
  const locator = this.toLocator(loc);
709
878
  const deadline = Date.now() + timeoutMs;
710
879
  let lastErr = "condition not met";
@@ -720,6 +889,18 @@ var init_playwright_driver = __esm({
720
889
  const visible = await locator.first().isVisible();
721
890
  if (opts.visible !== void 0 && visible !== opts.visible) {
722
891
  lastErr = `expected visible=${opts.visible}, got ${visible}`;
892
+ } else if (opts.value !== void 0) {
893
+ const v = await locator.inputValue({ timeout: 1e3 }).catch(() => null);
894
+ if (v === opts.value) return;
895
+ lastErr = `expected value ${JSON.stringify(opts.value)}, got ${JSON.stringify(v)}`;
896
+ } else if (opts.disabled !== void 0) {
897
+ const d = await locator.isDisabled({ timeout: 1e3 }).catch(() => null);
898
+ if (d === opts.disabled) return;
899
+ lastErr = `expected disabled=${opts.disabled}, got ${d}`;
900
+ } else if (opts.checked !== void 0) {
901
+ const c = await locator.isChecked({ timeout: 1e3 }).catch(() => null);
902
+ if (c === opts.checked) return;
903
+ lastErr = `expected checked=${opts.checked}, got ${c}`;
723
904
  } else if (opts.text !== void 0) {
724
905
  const content = visible ? await locator.first().innerText() : "";
725
906
  if (!content.includes(opts.text)) {
@@ -761,6 +942,7 @@ var init_playwright_driver = __esm({
761
942
  }
762
943
  static LONG_REQUEST_MS = 2e3;
763
944
  async settle(opts) {
945
+ this.assertAlive();
764
946
  this.installNetTracking();
765
947
  const start = Date.now();
766
948
  let capped = true;
@@ -803,7 +985,20 @@ var init_playwright_driver = __esm({
803
985
  })
804
986
  ),
805
987
  1800
806
- ).catch(() => {
988
+ ).catch(async (e) => {
989
+ if (/context.*destroyed|navigat/i.test(e.message ?? "")) {
990
+ await this.page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
991
+ });
992
+ await withTimeout(
993
+ this.page.evaluate(
994
+ () => new Promise((resolve2) => {
995
+ setTimeout(resolve2, 400);
996
+ })
997
+ ),
998
+ 1e3
999
+ ).catch(() => {
1000
+ });
1001
+ }
807
1002
  });
808
1003
  await this.nextFrame();
809
1004
  }
@@ -860,10 +1055,56 @@ var init_playwright_driver = __esm({
860
1055
  // Headless Chromium's CDP screencast is hard-locked to CSS-pixel resolution and ignores
861
1056
  // deviceScaleFactor, so a paced Page.captureScreenshot loop with clip.scale is what actually
862
1057
  // yields 2x frames (the zoom headroom the camera planner needs).
1058
+ screencastActive = false;
863
1059
  async startCapture(onFrame, opts) {
864
1060
  this.captureOnFrame = onFrame;
865
1061
  this.captureOpts = opts;
866
1062
  this.captureActive = true;
1063
+ if (process.env.PLAYHEAD_CAPTURE !== "screenshot") {
1064
+ try {
1065
+ const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
1066
+ const expectedW = Math.round(vp.width * opts.scale);
1067
+ let sizeChecked = false;
1068
+ this.captureCdp.on("Page.screencastFrame", (ev) => {
1069
+ void this.captureCdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
1070
+ });
1071
+ if (!this.captureActive || !this.captureOnFrame || !this.screencastActive) return;
1072
+ const data = Buffer.from(ev.data, "base64");
1073
+ if (!sizeChecked) {
1074
+ sizeChecked = true;
1075
+ const w = imageWidth(data, opts.format);
1076
+ if (w !== null && w < expectedW * 0.9) {
1077
+ log.warn(
1078
+ `screencast emits ${w}px-wide frames (need ${expectedW} for zoom headroom) \u2014 reverting to the 2x screenshot loop`
1079
+ );
1080
+ this.screencastActive = false;
1081
+ void withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1082
+ });
1083
+ this.startScreenshotLoop(opts);
1084
+ return;
1085
+ }
1086
+ }
1087
+ const tNodeMs = ev.metadata?.timestamp ? ev.metadata.timestamp * 1e3 - this.clockOffset : Date.now();
1088
+ this.lastFrameNodeMs = tNodeMs;
1089
+ this.captureOnFrame({ data, tNodeMs });
1090
+ });
1091
+ await this.captureCdp.send("Page.startScreencast", {
1092
+ format: opts.format,
1093
+ quality: opts.quality,
1094
+ maxWidth: expectedW,
1095
+ maxHeight: Math.round(vp.height * opts.scale),
1096
+ // Compositor paints at up to ~60; halve toward the requested rate.
1097
+ everyNthFrame: Math.max(1, Math.round(60 / Math.max(15, opts.fps * 1.25)))
1098
+ });
1099
+ this.screencastActive = true;
1100
+ return;
1101
+ } catch (e) {
1102
+ log.warn(`screencast unavailable (${e.message.split("\n")[0]}) \u2014 falling back to the paced screenshot loop`);
1103
+ }
1104
+ }
1105
+ this.startScreenshotLoop(opts);
1106
+ }
1107
+ startScreenshotLoop(opts) {
867
1108
  const intervalMs = 1e3 / opts.fps;
868
1109
  this.captureLoop = (async () => {
869
1110
  while (this.captureActive) {
@@ -874,7 +1115,8 @@ var init_playwright_driver = __esm({
874
1115
  }
875
1116
  })();
876
1117
  }
877
- /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one). */
1118
+ /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one).
1119
+ * Resolves true iff a frame was actually stored. */
878
1120
  grabFrame() {
879
1121
  if (this.inflightGrab) return this.inflightGrab;
880
1122
  this.inflightGrab = this.doGrab().finally(() => {
@@ -884,7 +1126,7 @@ var init_playwright_driver = __esm({
884
1126
  }
885
1127
  grabFailures = 0;
886
1128
  async doGrab() {
887
- if (!this.captureOpts || !this.captureOnFrame) return;
1129
+ if (!this.captureOpts || !this.captureOnFrame) return false;
888
1130
  try {
889
1131
  const opts = this.captureOpts;
890
1132
  const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
@@ -893,7 +1135,9 @@ var init_playwright_driver = __esm({
893
1135
  this.captureCdp.send("Page.captureScreenshot", {
894
1136
  format: opts.format,
895
1137
  quality: opts.quality,
896
- clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale },
1138
+ // Under --force-device-scale-factor the surface is already scaled divide it out
1139
+ // or screenshots come back double-scaled (5120-wide).
1140
+ clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale / this.forcedDsf },
897
1141
  captureBeyondViewport: false
898
1142
  }),
899
1143
  1500
@@ -902,7 +1146,9 @@ var init_playwright_driver = __esm({
902
1146
  this.lastFrameNodeMs = tNodeMs;
903
1147
  this.grabFailures = 0;
904
1148
  this.captureOnFrame({ data: Buffer.from(shot.data, "base64"), tNodeMs });
905
- } catch {
1149
+ return true;
1150
+ } catch (err) {
1151
+ log.debug(`frame grab failed: ${err.message.split("\n")[0]}`);
906
1152
  this.grabFailures += 1;
907
1153
  if (this.grabFailures >= 2) {
908
1154
  try {
@@ -914,6 +1160,7 @@ var init_playwright_driver = __esm({
914
1160
  } catch {
915
1161
  }
916
1162
  }
1163
+ return false;
917
1164
  }
918
1165
  }
919
1166
  async readScroll() {
@@ -927,6 +1174,11 @@ var init_playwright_driver = __esm({
927
1174
  }
928
1175
  async stopCapture() {
929
1176
  this.captureActive = false;
1177
+ if (this.screencastActive) {
1178
+ this.screencastActive = false;
1179
+ await withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1180
+ });
1181
+ }
930
1182
  await this.captureLoop?.catch(() => {
931
1183
  });
932
1184
  this.captureLoop = null;
@@ -935,19 +1187,45 @@ var init_playwright_driver = __esm({
935
1187
  return this.lastFrameNodeMs;
936
1188
  }
937
1189
  async captureNow() {
938
- if (this.inflightGrab) await this.inflightGrab.catch(() => {
939
- });
940
- await this.grabFrame();
1190
+ if (this.inflightGrab) await this.inflightGrab.catch(() => false);
1191
+ for (let i = 0; i < 3; i++) {
1192
+ if (await this.grabFrame()) return;
1193
+ await sleep(120);
1194
+ }
1195
+ log.warn("captureNow: no frame stored after 3 attempts \u2014 footage may hold a stale state here");
941
1196
  }
942
1197
  };
943
1198
  }
944
1199
  });
945
1200
 
946
1201
  // src/spec/locators.ts
1202
+ function splitSegments(raw) {
1203
+ const parts = [];
1204
+ let cur = "";
1205
+ let quote2 = null;
1206
+ for (let i = 0; i < raw.length; i++) {
1207
+ const ch = raw[i];
1208
+ if (quote2) {
1209
+ cur += ch;
1210
+ if (ch === quote2) quote2 = null;
1211
+ } else if (ch === '"' || ch === "'") {
1212
+ quote2 = ch;
1213
+ cur += ch;
1214
+ } else if (ch === ">" && raw[i + 1] === ">") {
1215
+ parts.push(cur.trim());
1216
+ cur = "";
1217
+ i += 1;
1218
+ } else {
1219
+ cur += ch;
1220
+ }
1221
+ }
1222
+ parts.push(cur.trim());
1223
+ return parts;
1224
+ }
947
1225
  function parseLocator(input) {
948
1226
  const raw = input.trim();
949
1227
  let nth;
950
- const parts = raw.split(">>").map((p) => p.trim());
1228
+ const parts = splitSegments(raw);
951
1229
  const frames = [];
952
1230
  while (parts.length > 0 && parts[0].startsWith("frame=")) {
953
1231
  const sel = unquote(parts.shift().slice("frame=".length).trim());
@@ -1088,7 +1366,7 @@ function kindOf(el) {
1088
1366
  function quote(s) {
1089
1367
  const clean = s.replace(/\s+/g, " ").trim();
1090
1368
  if (clean.includes('"')) {
1091
- return `/${escapeRegex(clean).replace(/\//g, "\\/")}/`;
1369
+ return `/${escapeRegex(clean).replace(/\//g, "\\/").replace(/>/g, "\\x3e")}/`;
1092
1370
  }
1093
1371
  return `"${clean}"`;
1094
1372
  }
@@ -1121,7 +1399,12 @@ async function snapshotPage(driver) {
1121
1399
  let unique = false;
1122
1400
  let ambiguous = null;
1123
1401
  for (const cand of candidates) {
1124
- const count = await driver.countMatches(parseLocator(cand)).catch(() => 0);
1402
+ let count = 0;
1403
+ try {
1404
+ count = await driver.countMatches(parseLocator(cand)).catch(() => 0);
1405
+ } catch {
1406
+ continue;
1407
+ }
1125
1408
  if (count === 1) {
1126
1409
  chosen = cand;
1127
1410
  unique = true;
@@ -1327,7 +1610,7 @@ function flattenSteps(spec) {
1327
1610
  });
1328
1611
  return out;
1329
1612
  }
1330
- var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
1613
+ var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, rightclickStep, uploadStep, dragStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
1331
1614
  var init_schema = __esm({
1332
1615
  "src/spec/schema.ts"() {
1333
1616
  "use strict";
@@ -1344,7 +1627,7 @@ var init_schema = __esm({
1344
1627
  }
1345
1628
  });
1346
1629
  focusHint = z.union([z.literal("target"), z.literal("wide"), locatorString]);
1347
- EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout"];
1630
+ EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout", "optional"];
1348
1631
  stepExtras = {
1349
1632
  caption: z.string().optional(),
1350
1633
  /** The SPOKEN line for TTS narration — unconstrained by the caption card's size. Resolution:
@@ -1356,7 +1639,10 @@ var init_schema = __esm({
1356
1639
  * scene structure (scenes stay narrative). */
1357
1640
  shot: z.enum(["cut", "continue"]).optional(),
1358
1641
  /** Per-step budget override (ms) for finding the target / meeting the expectation. */
1359
- timeout: z.number().int().positive().optional()
1642
+ timeout: z.number().int().positive().optional(),
1643
+ /** A failing optional step is SKIPPED (warned, unfilmed beat) instead of killing the whole
1644
+ * capture — for cookie banners, A/B'd tooltips, and other environment noise. */
1645
+ optional: z.boolean().optional()
1360
1646
  };
1361
1647
  targetOrShorthand = z.union([
1362
1648
  locatorString,
@@ -1373,9 +1659,15 @@ var init_schema = __esm({
1373
1659
  target: locatorString,
1374
1660
  text: z.string(),
1375
1661
  mask: z.boolean().default(false),
1662
+ /** Select-all + overwrite instead of appending — editing a pre-filled field without this
1663
+ * produces "Janenew value". */
1664
+ clear: z.boolean().default(false),
1376
1665
  ...stepExtras
1377
1666
  }).strict();
1378
1667
  pressStep = z.object({ action: z.literal("press"), keys: z.string(), ...stepExtras }).strict();
1668
+ rightclickStep = z.object({ action: z.literal("rightclick"), target: locatorString, ...stepExtras }).strict();
1669
+ uploadStep = z.object({ action: z.literal("upload"), target: locatorString, file: z.string(), ...stepExtras }).strict();
1670
+ dragStep = z.object({ action: z.literal("drag"), target: locatorString, to: locatorString, ...stepExtras }).strict();
1379
1671
  selectStep = z.object({
1380
1672
  action: z.literal("select"),
1381
1673
  target: locatorString,
@@ -1390,13 +1682,24 @@ var init_schema = __esm({
1390
1682
  }).strict();
1391
1683
  expectStep = z.object({
1392
1684
  action: z.literal("expect"),
1393
- target: locatorString,
1685
+ target: locatorString.optional(),
1394
1686
  visible: z.boolean().optional(),
1395
1687
  text: z.string().optional(),
1396
1688
  /** Exact number of matching elements (e.g. rows in a filtered table). */
1397
1689
  count: z.number().int().min(0).optional(),
1690
+ /** Current page URL must CONTAIN this substring (or match when wrapped /like this/). */
1691
+ url: z.string().optional(),
1692
+ /** Form control's current value. */
1693
+ value: z.string().optional(),
1694
+ /** Element enabled/disabled and checked state. */
1695
+ disabled: z.boolean().optional(),
1696
+ checked: z.boolean().optional(),
1398
1697
  ...stepExtras
1399
- }).strict();
1698
+ }).strict().refine((s) => s.target !== void 0 || s.url !== void 0, {
1699
+ message: "expect needs a target locator (element assertions) and/or a url"
1700
+ }).refine((s) => s.target !== void 0 || s.visible === void 0 && s.text === void 0 && s.count === void 0 && s.value === void 0 && s.disabled === void 0 && s.checked === void 0, {
1701
+ message: "element assertions (visible/text/count/value/disabled/checked) need a target"
1702
+ });
1400
1703
  waitStep = z.object({
1401
1704
  action: z.literal("wait"),
1402
1705
  ms: z.number().int().positive().optional(),
@@ -1411,12 +1714,15 @@ var init_schema = __esm({
1411
1714
  pointerStep,
1412
1715
  typeStep,
1413
1716
  pressStep,
1717
+ rightclickStep,
1718
+ uploadStep,
1719
+ dragStep,
1414
1720
  selectStep,
1415
1721
  scrollStep,
1416
1722
  expectStep,
1417
1723
  waitStep
1418
1724
  ]);
1419
- ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "select", "scroll", "expect", "wait"];
1725
+ ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "rightclick", "upload", "drag", "select", "scroll", "expect", "wait"];
1420
1726
  authoredStep = z.record(z.string(), z.unknown()).superRefine((obj, ctx2) => {
1421
1727
  const actions = ACTION_KEYS.filter((k) => k in obj);
1422
1728
  if (actions.length !== 1) {
@@ -1519,7 +1825,10 @@ var init_schema = __esm({
1519
1825
  }).strict().refine(
1520
1826
  (a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
1521
1827
  { message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
1522
- ).optional(),
1828
+ ).refine((a) => a.provider !== "kokoro" || a.rate === void 0 || a.rate >= 80 && a.rate <= 140, {
1829
+ message: "kokoro rate is playback speed \xD7100 (100 = normal, sensible range 80\u2013140) \u2014 a say-style words-per-minute value like 178 would speak absurdly fast",
1830
+ path: ["rate"]
1831
+ }).optional(),
1523
1832
  /** Closing card. When set, the video ends on a title-card-styled end card. */
1524
1833
  endCard: z.object({
1525
1834
  title: z.string().min(1),
@@ -1538,13 +1847,24 @@ var init_schema = __esm({
1538
1847
  style: z.enum(["solid", "blur"]).default("solid")
1539
1848
  }).strict()
1540
1849
  ).default([]),
1850
+ /** Steps that run BEFORE recording starts and never appear on film — dismiss a cookie-consent
1851
+ * banner, close a first-run tour, prime app state. Same step grammar as scenes. */
1852
+ setup: z.array(authoredStep.pipe(stepSchema)).default([]),
1541
1853
  scenes: z.array(
1542
1854
  z.object({
1543
1855
  id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scene ids are lowercase kebab-case"),
1544
1856
  title: z.string().optional(),
1545
1857
  steps: z.array(authoredStep.pipe(stepSchema)).min(1)
1546
1858
  }).strict()
1547
- ).min(1)
1859
+ ).min(1).superRefine((scenes, ctx2) => {
1860
+ const seen = /* @__PURE__ */ new Set();
1861
+ scenes.forEach((s, i) => {
1862
+ if (seen.has(s.id)) {
1863
+ ctx2.addIssue({ code: "custom", path: [i, "id"], message: `duplicate scene id "${s.id}" \u2014 ids must be unique (check the extended base spec too)` });
1864
+ }
1865
+ seen.add(s.id);
1866
+ });
1867
+ })
1548
1868
  }).strict();
1549
1869
  ASPECTS = {
1550
1870
  "16:9": { viewport: { w: 1280, h: 720 }, resolution: { w: 1920, h: 1080 } },
@@ -1554,108 +1874,442 @@ var init_schema = __esm({
1554
1874
  }
1555
1875
  });
1556
1876
 
1557
- // src/shared/exit.ts
1558
- var EXIT, FlowError, InfraError;
1559
- var init_exit = __esm({
1560
- "src/shared/exit.ts"() {
1561
- "use strict";
1562
- EXIT = {
1563
- OK: 0,
1564
- FLOW: 1,
1565
- QUALITY: 2,
1566
- INFRA: 3,
1567
- USAGE: 4
1568
- };
1569
- FlowError = class extends Error {
1570
- exitCode = EXIT.FLOW;
1571
- };
1572
- InfraError = class extends Error {
1573
- exitCode = EXIT.INFRA;
1574
- };
1575
- }
1576
- });
1577
-
1578
- // src/authoring/validate.ts
1579
- var validate_exports = {};
1580
- __export(validate_exports, {
1581
- formatValidateResult: () => formatValidateResult,
1582
- validateLive: () => validateLive
1877
+ // src/spec/parse.ts
1878
+ var parse_exports = {};
1879
+ __export(parse_exports, {
1880
+ SpecError: () => SpecError,
1881
+ loadSpec: () => loadSpec,
1882
+ parseSpec: () => parseSpec
1583
1883
  });
1584
- async function validateLive(spec, opts) {
1585
- const driver = opts?.driver ?? new PlaywrightDriver();
1586
- const owned = !opts?.driver;
1587
- const issues = [];
1588
- let checked = 0;
1589
- let stateDiverged = false;
1590
- if (owned) {
1591
- await driver.launch({
1592
- viewport: resolveViewport(spec),
1593
- dpr: 1,
1594
- headless: opts?.headless ?? true,
1595
- ...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {}
1596
- });
1884
+ import { readFile } from "fs/promises";
1885
+ import { dirname as dirname2, resolve as resolvePath } from "path";
1886
+ import { parseDocument, LineCounter } from "yaml";
1887
+ function suggest(key) {
1888
+ let best = null;
1889
+ let bestD = 3;
1890
+ for (const k of KNOWN_KEYS) {
1891
+ const d = editDistance(key.toLowerCase(), k.toLowerCase());
1892
+ if (d > 0 && d < bestD) {
1893
+ bestD = d;
1894
+ best = k;
1895
+ }
1597
1896
  }
1598
- try {
1599
- await driver.goto(spec.app.url);
1600
- await driver.settle({ idleMs: 300, capMs: 5e3 });
1601
- for (const a of flattenSteps(spec)) {
1602
- const stepRef = `${a.sceneId}/${a.stepIndex}`;
1603
- const step = a.step;
1604
- const push = async (locator, severity, message) => {
1605
- issues.push({
1606
- stepRef,
1607
- step: step.action,
1608
- locator,
1609
- severity,
1610
- message: stateDiverged ? `${message} (after an earlier failure \u2014 state may have diverged)` : message,
1611
- suggestions: severity === "error" ? await suggestFor(driver, locator) : []
1612
- });
1613
- };
1614
- const locators = [];
1615
- if ("target" in step && step.target) locators.push({ value: step.target, kind: "target" });
1616
- if (step.action === "wait" && step.for) locators.push({ value: step.for, kind: "wait" });
1617
- if (step.focus && step.focus !== "target" && step.focus !== "wide") {
1618
- locators.push({ value: step.focus, kind: "focus" });
1619
- }
1620
- for (const l of locators) {
1621
- checked += 1;
1622
- const count = await driver.countMatches(parseLocator(l.value)).catch(() => 0);
1623
- if (count === 0 && l.kind === "target") {
1624
- await push(l.value, "error", "no matching element on the current screen");
1625
- } else if (count === 0 && l.kind !== "target") {
1626
- await push(l.value, "warn", `${l.kind} locator matches nothing yet (may appear after the action)`);
1627
- } else if (count > 1 && !l.value.includes(">> nth=")) {
1628
- await push(l.value, "warn", `ambiguous: ${count} matches \u2014 add '>> nth=N' to pick one`);
1629
- }
1630
- }
1631
- try {
1632
- await fastExecute(driver, step);
1633
- } catch (e) {
1634
- if ("target" in step && step.target) {
1635
- const already = issues.some((i) => i.stepRef === stepRef && i.severity === "error");
1636
- if (!already) await push(step.target ?? "(none)", "error", `step failed to execute: ${firstLine2(e.message)}`);
1637
- }
1638
- stateDiverged = true;
1639
- }
1640
- const focus = step.focus;
1641
- if (focus && focus !== "target" && focus !== "wide") {
1642
- const loc = parseLocator(focus);
1643
- let n = 0;
1644
- for (let i = 0; i < 8 && n === 0; i++) {
1645
- n = await driver.countMatches(loc).catch(() => 0);
1646
- if (n === 0) await sleep3(250);
1647
- }
1648
- if (n === 0) await push(focus, "error", "focus locator never appeared after the action");
1649
- if (n > 0) {
1650
- const idx = issues.findIndex((i) => i.stepRef === stepRef && i.locator === focus && i.severity === "warn");
1651
- if (idx >= 0) issues.splice(idx, 1);
1652
- }
1653
- }
1897
+ return best;
1898
+ }
1899
+ function parseSpec(yamlText, sourcePath = "<inline>") {
1900
+ const lineCounter = new LineCounter();
1901
+ const doc = parseDocument(yamlText, { lineCounter, keepSourceTokens: true });
1902
+ if (doc.errors.length > 0) {
1903
+ const first = doc.errors[0];
1904
+ throw new SpecError(`${sourcePath}: not valid YAML \u2014 ${first.message}`);
1905
+ }
1906
+ const raw = doc.toJS();
1907
+ if (raw && typeof raw === "object" && "extends" in raw) {
1908
+ throw new SpecError(`${sourcePath}: "extends" needs a file on disk \u2014 load the spec with loadSpec/the CLI, not inline text`);
1909
+ }
1910
+ return validateResolved(interpolate(raw, sourcePath), sourcePath, doc, lineCounter);
1911
+ }
1912
+ async function loadSpec(path) {
1913
+ const merged = await loadRaw(resolvePath(path), 0);
1914
+ const text = await readFile(resolvePath(path), "utf8");
1915
+ const lineCounter = new LineCounter();
1916
+ const doc = parseDocument(text, { lineCounter });
1917
+ const leaf = doc.toJS() ?? {};
1918
+ const arrLen = (v) => Array.isArray(v) ? v.length : 0;
1919
+ const offsets = {
1920
+ scenes: arrLen(merged.scenes) - arrLen(leaf.scenes),
1921
+ masking: arrLen(merged.masking) - arrLen(leaf.masking)
1922
+ };
1923
+ return validateResolved(interpolate(merged, path), path, doc, lineCounter, offsets);
1924
+ }
1925
+ async function loadRaw(path, depth) {
1926
+ if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
1927
+ const text = await readFile(path, "utf8");
1928
+ const doc = parseDocument(text);
1929
+ if (doc.errors.length > 0) throw new SpecError(`${path}: not valid YAML \u2014 ${doc.errors[0].message}`);
1930
+ const raw = doc.toJS() ?? {};
1931
+ const base = raw.extends;
1932
+ delete raw.extends;
1933
+ if (base === void 0) return raw;
1934
+ if (typeof base !== "string") throw new SpecError(`${path}: "extends" must be a path string`);
1935
+ const baseObj = await loadRaw(resolvePath(dirname2(path), base), depth + 1);
1936
+ return mergeSpecs(baseObj, raw);
1937
+ }
1938
+ function mergeSpecs(base, child) {
1939
+ const out = { ...base };
1940
+ for (const [k, v] of Object.entries(child)) {
1941
+ const b = out[k];
1942
+ if (Array.isArray(b) && Array.isArray(v) && (k === "masking" || k === "scenes")) {
1943
+ out[k] = [...b, ...v];
1944
+ } else if (isPlainObject(b) && isPlainObject(v)) {
1945
+ out[k] = mergeSpecs(b, v);
1946
+ } else {
1947
+ out[k] = v;
1654
1948
  }
1655
- } finally {
1656
- if (owned) await driver.close();
1657
1949
  }
1658
- return { ok: !issues.some((i) => i.severity === "error"), checked, issues };
1950
+ return out;
1951
+ }
1952
+ function isPlainObject(v) {
1953
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1954
+ }
1955
+ function interpolate(raw, sourcePath) {
1956
+ if (!isPlainObject(raw)) return raw;
1957
+ const varsIn = isPlainObject(raw.vars) ? raw.vars : {};
1958
+ const vars = /* @__PURE__ */ new Map();
1959
+ for (const [name, value] of Object.entries(varsIn)) {
1960
+ vars.set(name, resolveEnv(String(value), sourcePath));
1961
+ }
1962
+ for (let pass = 0; pass < 6; pass++) {
1963
+ let changed = false;
1964
+ for (const [name, value] of vars) {
1965
+ const next = value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (m, ref) => {
1966
+ if (ref === name) throw new SpecError(`${sourcePath}: variable {{${name}}} references itself`);
1967
+ const v = vars.get(ref);
1968
+ return v !== void 0 && !v.includes(`{{${name}}}`) ? v : m;
1969
+ });
1970
+ if (next !== value) {
1971
+ vars.set(name, next);
1972
+ changed = true;
1973
+ }
1974
+ }
1975
+ if (!changed) break;
1976
+ if (pass === 5) throw new SpecError(`${sourcePath}: variable references did not resolve after 6 passes \u2014 circular vars?`);
1977
+ }
1978
+ for (const [name, value] of vars) {
1979
+ const m = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(value);
1980
+ if (m && vars.has(m[1])) {
1981
+ throw new SpecError(`${sourcePath}: circular variable reference \u2014 {{${name}}} and {{${m[1]}}} depend on each other`);
1982
+ }
1983
+ }
1984
+ const doc = { ...raw };
1985
+ delete doc.vars;
1986
+ const seen = (s) => {
1987
+ const withEnv = resolveEnv(s, sourcePath);
1988
+ return withEnv.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, name) => {
1989
+ const v = vars.get(name);
1990
+ if (v === void 0) {
1991
+ const near = suggest(name) ?? [...vars.keys()].find((k) => editDistance(k, name) <= 2);
1992
+ throw new SpecError(
1993
+ `${sourcePath}: unknown variable {{${name}}}${near ? ` \u2014 did you mean {{${near}}}?` : ""} (declared vars: ${[...vars.keys()].join(", ") || "none"})`
1994
+ );
1995
+ }
1996
+ return v;
1997
+ });
1998
+ };
1999
+ const walk = (v) => {
2000
+ if (typeof v === "string") return seen(v);
2001
+ if (Array.isArray(v)) return v.map(walk);
2002
+ if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, walk(val)]));
2003
+ return v;
2004
+ };
2005
+ return walk(doc);
2006
+ }
2007
+ function resolveEnv(s, sourcePath) {
2008
+ return s.replace(/\$\{env\.([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, dflt) => {
2009
+ const v = process.env[name];
2010
+ if (v !== void 0) return v;
2011
+ if (dflt !== void 0) return dflt;
2012
+ throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
2013
+ });
2014
+ }
2015
+ function validateResolved(resolved, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
2016
+ const result = specSchema.safeParse(resolved);
2017
+ if (!result.success) {
2018
+ const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter, offsets));
2019
+ throw new SpecError(`${sourcePath}: invalid spec
2020
+ - ${issues.join("\n - ")}`, issues);
2021
+ }
2022
+ return result.data;
2023
+ }
2024
+ function formatIssue(iss, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
2025
+ const where = iss.path.length ? humanPath(iss.path) : "spec";
2026
+ const pos = positionOf(iss.path, doc, lineCounter, offsets);
2027
+ const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
2028
+ if (iss.code === "unrecognized_keys") {
2029
+ const keys = iss.keys;
2030
+ const hints = keys.map((k) => ({ k, near: suggest(k) })).map((h) => h.near ? `"${h.k}" (did you mean "${h.near}"?)` : `"${h.k}"`).join(", ");
2031
+ return `${at}${where}: unknown key${keys.length > 1 ? "s" : ""} ${hints}`;
2032
+ }
2033
+ return `${at}${where}: ${iss.message}`;
2034
+ }
2035
+ function positionOf(path, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
2036
+ if ((path[0] === "scenes" || path[0] === "masking") && typeof path[1] === "number") {
2037
+ const off = offsets[path[0]];
2038
+ if (off > 0) {
2039
+ const adjusted = path[1] - off;
2040
+ if (adjusted < 0) return null;
2041
+ path = [path[0], adjusted, ...path.slice(2)];
2042
+ }
2043
+ }
2044
+ for (let depth = path.length; depth > 0; depth--) {
2045
+ try {
2046
+ const node = doc.getIn(path.slice(0, depth), true);
2047
+ if (node?.range) {
2048
+ const pos = lineCounter.linePos(node.range[0]);
2049
+ return { line: pos.line, col: pos.col };
2050
+ }
2051
+ } catch {
2052
+ }
2053
+ }
2054
+ return null;
2055
+ }
2056
+ function humanPath(path) {
2057
+ return path.map((seg, i) => typeof seg === "number" ? `[${seg}]` : i === 0 ? String(seg) : `.${String(seg)}`).join("");
2058
+ }
2059
+ var SpecError, KNOWN_KEYS;
2060
+ var init_parse = __esm({
2061
+ "src/spec/parse.ts"() {
2062
+ "use strict";
2063
+ init_schema();
2064
+ SpecError = class extends Error {
2065
+ constructor(message, issues = []) {
2066
+ super(message);
2067
+ this.issues = issues;
2068
+ }
2069
+ issues;
2070
+ };
2071
+ KNOWN_KEYS = [
2072
+ "playhead",
2073
+ "title",
2074
+ "subtitle",
2075
+ "app",
2076
+ "output",
2077
+ "theme",
2078
+ "masking",
2079
+ "scenes",
2080
+ "vars",
2081
+ "extends",
2082
+ "setup",
2083
+ "url",
2084
+ "viewport",
2085
+ "storageState",
2086
+ "compareUrl",
2087
+ "assertLoggedIn",
2088
+ "environment",
2089
+ "network",
2090
+ "dialogs",
2091
+ "settle",
2092
+ "timezone",
2093
+ "locale",
2094
+ "colorScheme",
2095
+ "fixedTime",
2096
+ "reducedMotion",
2097
+ "block",
2098
+ "stub",
2099
+ "har",
2100
+ "status",
2101
+ "body",
2102
+ "contentType",
2103
+ "idleMs",
2104
+ "capMs",
2105
+ "kind",
2106
+ "aspect",
2107
+ "resolution",
2108
+ "fps",
2109
+ "pacing",
2110
+ "dwellScale",
2111
+ "audio",
2112
+ "endCard",
2113
+ "narration",
2114
+ "provider",
2115
+ "voice",
2116
+ "rate",
2117
+ "sfx",
2118
+ "music",
2119
+ "file",
2120
+ "gainDb",
2121
+ "accent",
2122
+ "captions",
2123
+ "target",
2124
+ "style",
2125
+ "id",
2126
+ "steps",
2127
+ "shot",
2128
+ "timeout",
2129
+ "goto",
2130
+ "click",
2131
+ "dblclick",
2132
+ "hover",
2133
+ "type",
2134
+ "press",
2135
+ "select",
2136
+ "scroll",
2137
+ "expect",
2138
+ "wait",
2139
+ "caption",
2140
+ "focus",
2141
+ "mask",
2142
+ "text",
2143
+ "value",
2144
+ "keys",
2145
+ "ms",
2146
+ "by",
2147
+ "visible",
2148
+ "hidden",
2149
+ "count",
2150
+ "for",
2151
+ "state",
2152
+ "x",
2153
+ "y",
2154
+ "optional",
2155
+ "clear",
2156
+ "to",
2157
+ "disabled",
2158
+ "checked",
2159
+ "rightclick",
2160
+ "upload",
2161
+ "drag"
2162
+ ];
2163
+ }
2164
+ });
2165
+
2166
+ // src/authoring/validate.ts
2167
+ var validate_exports = {};
2168
+ __export(validate_exports, {
2169
+ formatValidateResult: () => formatValidateResult,
2170
+ validateLive: () => validateLive
2171
+ });
2172
+ async function validateLive(spec, opts) {
2173
+ const driver = opts?.driver ?? new PlaywrightDriver();
2174
+ const owned = !opts?.driver;
2175
+ const issues = [];
2176
+ let checked = 0;
2177
+ let stateDiverged = false;
2178
+ if (owned) {
2179
+ await driver.launch({
2180
+ viewport: resolveViewport(spec),
2181
+ dpr: 1,
2182
+ headless: opts?.headless ?? true,
2183
+ ...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {},
2184
+ ...spec.app.environment ? { environment: spec.app.environment } : {},
2185
+ ...spec.app.network ? { network: spec.app.network } : {},
2186
+ ...spec.app.dialogs ? { dialogs: spec.app.dialogs } : {}
2187
+ });
2188
+ }
2189
+ const settleCfg = { idleMs: spec.app.settle?.idleMs ?? 300, capMs: spec.app.settle?.capMs ?? 5e3 };
2190
+ const vp = resolveViewport(spec);
2191
+ const res = resolveResolution(spec);
2192
+ const plane2 = { vpW: vp.w, vpH: vp.h, outW: res.w, outH: res.h };
2193
+ try {
2194
+ await driver.goto(spec.app.url);
2195
+ await driver.settle(settleCfg);
2196
+ if (spec.app.assertLoggedIn) {
2197
+ const n = await driver.countMatches(parseLocator(spec.app.assertLoggedIn)).catch(() => 0);
2198
+ if (n === 0) {
2199
+ issues.push({
2200
+ stepRef: "(setup)",
2201
+ step: "assertLoggedIn",
2202
+ locator: spec.app.assertLoggedIn,
2203
+ severity: "error",
2204
+ message: "assertLoggedIn matches nothing \u2014 storageState stale? Re-run: playhead login",
2205
+ suggestions: []
2206
+ });
2207
+ }
2208
+ }
2209
+ for (const m of spec.masking) {
2210
+ checked += 1;
2211
+ const n = await driver.countMatches(parseLocator(m.target)).catch(() => 0);
2212
+ if (n === 0) {
2213
+ issues.push({
2214
+ stepRef: "(masking)",
2215
+ step: "mask",
2216
+ locator: m.target,
2217
+ severity: "warn",
2218
+ message: "masking rule matches nothing on the initial screen (fine if the element appears later \u2014 verify the render)",
2219
+ suggestions: []
2220
+ });
2221
+ }
2222
+ }
2223
+ const setupSteps = spec.setup.map((step, i) => ({
2224
+ sceneId: "setup",
2225
+ sceneIndex: -1,
2226
+ stepIndex: i,
2227
+ ordinal: 0,
2228
+ step
2229
+ }));
2230
+ for (const a of [...setupSteps, ...flattenSteps(spec)]) {
2231
+ const stepRef = `${a.sceneId}/${a.stepIndex}`;
2232
+ const step = a.step;
2233
+ const push = async (locator, severity, message) => {
2234
+ issues.push({
2235
+ stepRef,
2236
+ step: step.action,
2237
+ locator,
2238
+ severity,
2239
+ message: stateDiverged ? `${message} (after an earlier failure \u2014 state may have diverged)` : message,
2240
+ suggestions: severity === "error" ? await suggestFor(driver, locator) : []
2241
+ });
2242
+ };
2243
+ const locators = [];
2244
+ if ("target" in step && step.target) locators.push({ value: step.target, kind: "target" });
2245
+ if (step.action === "drag") locators.push({ value: step.to, kind: "target" });
2246
+ if (step.action === "wait" && step.for) locators.push({ value: step.for, kind: "wait" });
2247
+ if (step.focus && step.focus !== "target" && step.focus !== "wide") {
2248
+ locators.push({ value: step.focus, kind: "focus" });
2249
+ }
2250
+ for (const l of locators) {
2251
+ checked += 1;
2252
+ const count = await driver.countMatches(parseLocator(l.value)).catch(() => 0);
2253
+ if (count === 0 && l.kind === "target") {
2254
+ await push(l.value, "error", "no matching element on the current screen");
2255
+ } else if (count === 0 && l.kind !== "target") {
2256
+ await push(l.value, "warn", `${l.kind} locator matches nothing yet (may appear after the action)`);
2257
+ } else if (count > 1 && !l.value.includes(">> nth=")) {
2258
+ await push(l.value, "warn", `ambiguous: ${count} matches \u2014 add '>> nth=N' to pick one`);
2259
+ }
2260
+ }
2261
+ if (a.sceneIndex >= 0 && "target" in step && step.target && FRAMED_ACTIONS.has(step.action)) {
2262
+ try {
2263
+ const t = await driver.resolveTarget(parseLocator(step.target), 3e3);
2264
+ const violation = framingViolation(t.bbox, plane2);
2265
+ if (violation) await push(step.target, "warn", violation);
2266
+ } catch {
2267
+ }
2268
+ }
2269
+ try {
2270
+ await fastExecute(driver, step);
2271
+ } catch (e) {
2272
+ if ("target" in step && step.target) {
2273
+ const already = issues.some((i) => i.stepRef === stepRef && i.severity === "error");
2274
+ if (!already) await push(step.target ?? "(none)", "error", `step failed to execute: ${firstLine2(e.message)}`);
2275
+ }
2276
+ stateDiverged = true;
2277
+ }
2278
+ const focus = step.focus;
2279
+ if (focus && focus !== "target" && focus !== "wide") {
2280
+ const loc = parseLocator(focus);
2281
+ let n = 0;
2282
+ for (let i = 0; i < 8 && n === 0; i++) {
2283
+ n = await driver.countMatches(loc).catch(() => 0);
2284
+ if (n === 0) await sleep3(250);
2285
+ }
2286
+ if (n === 0) await push(focus, "error", "focus locator never appeared after the action");
2287
+ if (n > 0) {
2288
+ const idx = issues.findIndex((i) => i.stepRef === stepRef && i.locator === focus && i.severity === "warn");
2289
+ if (idx >= 0) issues.splice(idx, 1);
2290
+ try {
2291
+ const t = await driver.resolveTarget(loc, 3e3);
2292
+ const violation = framingViolation(t.bbox, plane2);
2293
+ if (violation) await push(focus, "warn", violation);
2294
+ } catch {
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+ } finally {
2300
+ if (owned) await driver.close();
2301
+ }
2302
+ return { ok: !issues.some((i) => i.severity === "error"), checked, issues };
2303
+ }
2304
+ function framingViolation(bbox, plane2) {
2305
+ const cam = clampCamera(
2306
+ { cx: bbox.x + bbox.w / 2, cy: bbox.y + bbox.h / 2, zoom: minZoom(plane2) },
2307
+ plane2
2308
+ );
2309
+ const proj = projectRect(bbox, cam, plane2);
2310
+ if (rectContains({ x: 0, y: 0, w: plane2.outW, h: plane2.outH }, proj, 2)) return null;
2311
+ const fullBleed = bbox.x <= 2 || bbox.y <= 2 || bbox.x + bbox.w >= plane2.vpW - 2 || bbox.y + bbox.h >= plane2.vpH - 2;
2312
+ return fullBleed ? "full-bleed target (touches the viewport edge) \u2014 no camera can frame it with the 2px margin verification requires; target a smaller element inside it, or make this a scroll/wait step" : "target is larger than the widest camera view \u2014 verification's target-in-frame check will fail; use focus: on a smaller payoff element";
1659
2313
  }
1660
2314
  async function fastExecute(driver, step) {
1661
2315
  const FAST_TIMEOUT = step.timeout ?? 8e3;
@@ -1691,13 +2345,28 @@ async function fastExecute(driver, step) {
1691
2345
  else if (step.by) await driver.scrollBy(step.by);
1692
2346
  await driver.waitForScrollSettle(1500);
1693
2347
  break;
2348
+ case "rightclick": {
2349
+ await driver.resolveTarget(parseLocator(step.target), FAST_TIMEOUT);
2350
+ await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: FAST_TIMEOUT });
2351
+ break;
2352
+ }
2353
+ case "upload":
2354
+ await driver.setInputFiles(parseLocator(step.target), step.file);
2355
+ break;
2356
+ case "drag":
2357
+ await driver.dragTo(parseLocator(step.target), parseLocator(step.to), FAST_TIMEOUT);
2358
+ break;
1694
2359
  case "expect":
1695
2360
  await driver.expectState(
1696
- parseLocator(step.target),
2361
+ step.target ? parseLocator(step.target) : null,
1697
2362
  {
1698
2363
  ...step.visible !== void 0 ? { visible: step.visible } : {},
1699
2364
  ...step.text !== void 0 ? { text: step.text } : {},
1700
- ...step.count !== void 0 ? { count: step.count } : {}
2365
+ ...step.count !== void 0 ? { count: step.count } : {},
2366
+ ...step.url !== void 0 ? { url: step.url } : {},
2367
+ ...step.value !== void 0 ? { value: step.value } : {},
2368
+ ...step.disabled !== void 0 ? { disabled: step.disabled } : {},
2369
+ ...step.checked !== void 0 ? { checked: step.checked } : {}
1701
2370
  },
1702
2371
  FAST_TIMEOUT
1703
2372
  );
@@ -1745,6 +2414,7 @@ function formatValidateResult(res) {
1745
2414
  );
1746
2415
  return lines.join("\n");
1747
2416
  }
2417
+ var FRAMED_ACTIONS;
1748
2418
  var init_validate = __esm({
1749
2419
  "src/authoring/validate.ts"() {
1750
2420
  "use strict";
@@ -1754,6 +2424,7 @@ var init_validate = __esm({
1754
2424
  init_schema();
1755
2425
  init_explore();
1756
2426
  init_geometry();
2427
+ FRAMED_ACTIONS = /* @__PURE__ */ new Set(["click", "dblclick", "hover", "type", "select", "expect"]);
1757
2428
  }
1758
2429
  });
1759
2430
 
@@ -1769,8 +2440,8 @@ __export(jira_exports, {
1769
2440
  reportToJira: () => reportToJira
1770
2441
  });
1771
2442
  import { readFile as readFile5, stat } from "fs/promises";
1772
- import { join as join9, basename } from "path";
1773
- import { existsSync as existsSync4 } from "fs";
2443
+ import { join as join10, basename } from "path";
2444
+ import { existsSync as existsSync5 } from "fs";
1774
2445
  function jiraConfigFromEnv() {
1775
2446
  const baseUrl = process.env.JIRA_BASE_URL;
1776
2447
  const email = process.env.JIRA_EMAIL;
@@ -1834,17 +2505,17 @@ async function addComment(cfg, issueKey, adfBody) {
1834
2505
  }
1835
2506
  async function collectEvidence(outDir) {
1836
2507
  const candidates = [
1837
- join9(outDir, "out.mp4"),
1838
- join9(outDir, "failure.mp4"),
1839
- join9(outDir, "verify", "verdict.json"),
1840
- join9(outDir, "verify", "contact-sheet.png"),
1841
- join9(outDir, "junit.xml"),
1842
- join9(outDir, "capture", "failure.json"),
1843
- join9(outDir, "capture", "console.json")
2508
+ join10(outDir, "out.mp4"),
2509
+ join10(outDir, "failure.mp4"),
2510
+ join10(outDir, "verify", "verdict.json"),
2511
+ join10(outDir, "verify", "contact-sheet.png"),
2512
+ join10(outDir, "junit.xml"),
2513
+ join10(outDir, "capture", "failure.json"),
2514
+ join10(outDir, "capture", "console.json")
1844
2515
  ];
1845
2516
  const files = [];
1846
2517
  for (const f of candidates) {
1847
- if (!existsSync4(f)) continue;
2518
+ if (!existsSync5(f)) continue;
1848
2519
  const s = await stat(f);
1849
2520
  if (s.size > 95 * 1024 * 1024) {
1850
2521
  log.warn(`skipping ${basename(f)} (${(s.size / 1e6).toFixed(0)}MB \u2014 larger than Jira's usual attachment ceiling)`);
@@ -1856,11 +2527,11 @@ async function collectEvidence(outDir) {
1856
2527
  throw new InfraError(`no Playhead artifacts found under ${outDir} \u2014 expected out.mp4/failure.mp4, verify/verdict.json, \u2026`);
1857
2528
  }
1858
2529
  let verdict;
1859
- const verdictPath = join9(outDir, "verify", "verdict.json");
1860
- if (existsSync4(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
2530
+ const verdictPath = join10(outDir, "verify", "verdict.json");
2531
+ if (existsSync5(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
1861
2532
  let failure;
1862
- const failurePath = join9(outDir, "capture", "failure.json");
1863
- if (existsSync4(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
2533
+ const failurePath = join10(outDir, "capture", "failure.json");
2534
+ if (existsSync5(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
1864
2535
  const outcome = failure ? "flow-failed" : verdict?.verdict ?? "unknown";
1865
2536
  return { outcome, ...verdict ? { verdict } : {}, ...failure ? { failure } : {}, files };
1866
2537
  }
@@ -1929,12 +2600,33 @@ import { mkdtemp, writeFile as writeFile7, mkdir as mkdir5, readFile as readFile
1929
2600
 
1930
2601
  // src/shared/version.ts
1931
2602
  import { createRequire } from "module";
1932
- var PLAYHEAD_VERSION = createRequire(import.meta.url)("../../package.json").version;
2603
+ import { fileURLToPath } from "url";
2604
+ import { dirname, join } from "path";
2605
+ import { existsSync, readFileSync } from "fs";
2606
+ function resolveOwnPackageJson() {
2607
+ let dir = dirname(fileURLToPath(import.meta.url));
2608
+ for (let i = 0; i < 6; i++) {
2609
+ const p = join(dir, "package.json");
2610
+ if (existsSync(p)) {
2611
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
2612
+ if (pkg.name === "playhead-cli" || pkg.name === "playhead") return { version: pkg.version ?? "0.0.0" };
2613
+ }
2614
+ const parent = dirname(dir);
2615
+ if (parent === dir) break;
2616
+ dir = parent;
2617
+ }
2618
+ try {
2619
+ return createRequire(import.meta.url)("../../package.json");
2620
+ } catch {
2621
+ return { version: "0.0.0" };
2622
+ }
2623
+ }
2624
+ var PLAYHEAD_VERSION = resolveOwnPackageJson().version;
1933
2625
 
1934
2626
  // src/mcp/server.ts
1935
2627
  init_explore();
1936
2628
  import { tmpdir } from "os";
1937
- import { join as join10, resolve, isAbsolute } from "path";
2629
+ import { join as join11, resolve, isAbsolute } from "path";
1938
2630
  import { z as z2 } from "zod";
1939
2631
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1940
2632
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -2037,25 +2729,46 @@ async function authorSpec(opts) {
2037
2729
  const owned = !opts.driver;
2038
2730
  const steps = [];
2039
2731
  log.info(`authoring "${opts.goal}" against ${opts.url}`);
2040
- if (owned) await driver.launch({ viewport: opts.viewport, dpr: 2, headless: opts.headless });
2732
+ if (owned)
2733
+ await driver.launch({
2734
+ viewport: opts.viewport,
2735
+ dpr: 2,
2736
+ headless: opts.headless,
2737
+ ...opts.storageStatePath ? { storageStatePath: opts.storageStatePath } : {}
2738
+ });
2041
2739
  try {
2042
2740
  await driver.goto(opts.url);
2043
2741
  await driver.settle(SETTLE);
2742
+ let feedback;
2743
+ let consecutiveFailures = 0;
2044
2744
  for (let i = 0; i < opts.maxSteps; i++) {
2045
2745
  const snap = await snapshotPage(driver);
2046
- const decision = await decide(opts.goal, snap, steps);
2746
+ const decision = await decide(opts.goal, snap, steps, feedback);
2047
2747
  if (decision.done || !decision.action) {
2048
- log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
2748
+ if (decision.done) log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
2749
+ else log.warn(`agent returned no action (${decision.reason ?? "no reason"}) \u2014 stopping`);
2049
2750
  break;
2050
2751
  }
2752
+ if (decision.locator && !snap.catalog.some((e) => e.locator === decision.locator)) {
2753
+ feedback = `Your locator ${decision.locator} is NOT in the catalog \u2014 copy one verbatim from the list.`;
2754
+ consecutiveFailures += 1;
2755
+ log.warn(`agent invented a locator (${decision.locator}); asking it to pick from the catalog`);
2756
+ if (consecutiveFailures >= 3) throw new Error("authoring stuck: 3 consecutive invalid steps \u2014 the goal may not be reachable from this screen");
2757
+ continue;
2758
+ }
2051
2759
  const step = toAuthoredStep(decision);
2052
2760
  log.step(`${steps.length + 1}. ${describe(step)}${decision.reason ? ` \u2014 ${decision.reason}` : ""}`);
2053
2761
  try {
2054
2762
  await perform(driver, step);
2055
2763
  await driver.settle(SETTLE);
2056
2764
  steps.push(step);
2765
+ feedback = void 0;
2766
+ consecutiveFailures = 0;
2057
2767
  } catch (e) {
2058
- log.warn(`step failed (${e.message}); asking the agent to adjust`);
2768
+ feedback = `Your last step FAILED: ${describe(step)} \u2014 ${e.message.split("\n")[0]}. Choose a different step.`;
2769
+ consecutiveFailures += 1;
2770
+ log.warn(`step failed (${e.message.split("\n")[0]}); feeding the failure back to the agent`);
2771
+ if (consecutiveFailures >= 3) throw new Error(`authoring stuck: 3 consecutive step failures (last: ${describe(step)})`);
2059
2772
  }
2060
2773
  }
2061
2774
  if (steps.length === 0) throw new Error("authoring produced no steps \u2014 the goal may not be reachable from this URL");
@@ -2063,11 +2776,20 @@ async function authorSpec(opts) {
2063
2776
  title: opts.title ?? capitalize(opts.goal),
2064
2777
  url: opts.url,
2065
2778
  viewport: opts.viewport,
2066
- kind: "walkthrough",
2779
+ kind: opts.kind ?? "walkthrough",
2067
2780
  steps
2068
2781
  };
2069
- await writeFile(opts.outPath, serializeSpec(spec));
2070
- log.ok(`wrote ${steps.length}-step spec \u2192 ${opts.outPath}`);
2782
+ const yaml = serializeSpec(spec);
2783
+ const { parseSpec: parseSpec2 } = await Promise.resolve().then(() => (init_parse(), parse_exports));
2784
+ try {
2785
+ parseSpec2(yaml, opts.outPath);
2786
+ } catch (e) {
2787
+ await writeFile(opts.outPath, yaml);
2788
+ throw new Error(`authored spec failed validation \u2014 written to ${opts.outPath} for inspection:
2789
+ ${e.message}`);
2790
+ }
2791
+ await writeFile(opts.outPath, yaml);
2792
+ log.ok(`wrote ${steps.length}-step spec \u2192 ${opts.outPath} (validated)`);
2071
2793
  log.info(`next: playhead render ${opts.outPath}`);
2072
2794
  return { specPath: opts.outPath, steps };
2073
2795
  } finally {
@@ -2084,9 +2806,16 @@ function claudeDecider() {
2084
2806
  if (!clientPromise) clientPromise = import("@anthropic-ai/sdk").then((m) => new m.default());
2085
2807
  return clientPromise;
2086
2808
  };
2087
- return async (goal, snap, soFar) => {
2809
+ return async (goal, snap, soFar, feedback) => {
2088
2810
  const client = await getClient();
2089
- const catalog = snap.catalog.map((e) => ` ${e.locator}${e.options ? ` (options: ${e.options.join(" | ")})` : ""}${e.unique ? "" : " [ambiguous]"}`).join("\n");
2811
+ let catalog = snap.catalog.map((e) => ` ${e.locator}${e.options ? ` (options: ${e.options.join(" | ")})` : ""}${e.unique ? "" : " [ambiguous]"}`).join("\n");
2812
+ const CATALOG_CAP = 12e3;
2813
+ if (catalog.length > CATALOG_CAP) {
2814
+ const kept = catalog.slice(0, CATALOG_CAP);
2815
+ const dropped = catalog.slice(CATALOG_CAP).split("\n").length;
2816
+ catalog = kept + `
2817
+ \u2026 (catalog truncated \u2014 ${dropped} more elements not shown)`;
2818
+ }
2090
2819
  const history = soFar.length ? soFar.map((s, i) => ` ${i + 1}. ${describe(s)}`).join("\n") : " (none yet)";
2091
2820
  const userMsg = [
2092
2821
  `GOAL: ${goal}`,
@@ -2097,6 +2826,7 @@ function claudeDecider() {
2097
2826
  ``,
2098
2827
  `STEPS SO FAR:`,
2099
2828
  history,
2829
+ ...feedback ? [``, `IMPORTANT \u2014 PREVIOUS ATTEMPT: ${feedback}`] : [],
2100
2830
  ``,
2101
2831
  `Emit the next step (or done=true if the goal is fully shown).`
2102
2832
  ].join("\n");
@@ -2163,259 +2893,31 @@ function describe(s) {
2163
2893
  return `type "${s.mask ? "\u2022\u2022\u2022\u2022" : s.text}" into ${s.locator}`;
2164
2894
  case "select":
2165
2895
  return `select "${s.value}" in ${s.locator}`;
2166
- case "press":
2167
- return `press ${s.keys}`;
2168
- case "expect":
2169
- return `expect ${s.locator}`;
2170
- default:
2171
- return `${s.action} ${s.locator}`;
2172
- }
2173
- }
2174
- function capitalize(s) {
2175
- return s.charAt(0).toUpperCase() + s.slice(1);
2176
- }
2177
-
2178
- // src/spec/parse.ts
2179
- init_schema();
2180
- import { readFile } from "fs/promises";
2181
- import { dirname, resolve as resolvePath } from "path";
2182
- import { parseDocument, LineCounter } from "yaml";
2183
- var SpecError = class extends Error {
2184
- constructor(message, issues = []) {
2185
- super(message);
2186
- this.issues = issues;
2187
- }
2188
- issues;
2189
- };
2190
- var KNOWN_KEYS = [
2191
- "playhead",
2192
- "title",
2193
- "subtitle",
2194
- "app",
2195
- "output",
2196
- "theme",
2197
- "masking",
2198
- "scenes",
2199
- "vars",
2200
- "extends",
2201
- "url",
2202
- "viewport",
2203
- "storageState",
2204
- "compareUrl",
2205
- "assertLoggedIn",
2206
- "environment",
2207
- "network",
2208
- "dialogs",
2209
- "settle",
2210
- "timezone",
2211
- "locale",
2212
- "colorScheme",
2213
- "fixedTime",
2214
- "reducedMotion",
2215
- "block",
2216
- "stub",
2217
- "har",
2218
- "status",
2219
- "body",
2220
- "contentType",
2221
- "idleMs",
2222
- "capMs",
2223
- "kind",
2224
- "aspect",
2225
- "resolution",
2226
- "fps",
2227
- "pacing",
2228
- "dwellScale",
2229
- "audio",
2230
- "endCard",
2231
- "narration",
2232
- "provider",
2233
- "voice",
2234
- "rate",
2235
- "sfx",
2236
- "music",
2237
- "file",
2238
- "gainDb",
2239
- "accent",
2240
- "captions",
2241
- "target",
2242
- "style",
2243
- "id",
2244
- "steps",
2245
- "shot",
2246
- "timeout",
2247
- "goto",
2248
- "click",
2249
- "dblclick",
2250
- "hover",
2251
- "type",
2252
- "press",
2253
- "select",
2254
- "scroll",
2255
- "expect",
2256
- "wait",
2257
- "caption",
2258
- "focus",
2259
- "mask",
2260
- "text",
2261
- "value",
2262
- "keys",
2263
- "ms",
2264
- "by",
2265
- "visible",
2266
- "hidden",
2267
- "count",
2268
- "for",
2269
- "state",
2270
- "x",
2271
- "y"
2272
- ];
2273
- function suggest(key) {
2274
- let best = null;
2275
- let bestD = 3;
2276
- for (const k of KNOWN_KEYS) {
2277
- const d = editDistance(key.toLowerCase(), k.toLowerCase());
2278
- if (d > 0 && d < bestD) {
2279
- bestD = d;
2280
- best = k;
2281
- }
2282
- }
2283
- return best;
2284
- }
2285
- function parseSpec(yamlText, sourcePath = "<inline>") {
2286
- const lineCounter = new LineCounter();
2287
- const doc = parseDocument(yamlText, { lineCounter, keepSourceTokens: true });
2288
- if (doc.errors.length > 0) {
2289
- const first = doc.errors[0];
2290
- throw new SpecError(`${sourcePath}: not valid YAML \u2014 ${first.message}`);
2291
- }
2292
- const raw = doc.toJS();
2293
- if (raw && typeof raw === "object" && "extends" in raw) {
2294
- throw new SpecError(`${sourcePath}: "extends" needs a file on disk \u2014 load the spec with loadSpec/the CLI, not inline text`);
2295
- }
2296
- return validateResolved(interpolate(raw, sourcePath), sourcePath, doc, lineCounter);
2297
- }
2298
- async function loadSpec(path) {
2299
- const merged = await loadRaw(resolvePath(path), 0);
2300
- const text = await readFile(resolvePath(path), "utf8");
2301
- const lineCounter = new LineCounter();
2302
- const doc = parseDocument(text, { lineCounter });
2303
- return validateResolved(interpolate(merged, path), path, doc, lineCounter);
2304
- }
2305
- async function loadRaw(path, depth) {
2306
- if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
2307
- const text = await readFile(path, "utf8");
2308
- const doc = parseDocument(text);
2309
- if (doc.errors.length > 0) throw new SpecError(`${path}: not valid YAML \u2014 ${doc.errors[0].message}`);
2310
- const raw = doc.toJS() ?? {};
2311
- const base = raw.extends;
2312
- delete raw.extends;
2313
- if (base === void 0) return raw;
2314
- if (typeof base !== "string") throw new SpecError(`${path}: "extends" must be a path string`);
2315
- const baseObj = await loadRaw(resolvePath(dirname(path), base), depth + 1);
2316
- return mergeSpecs(baseObj, raw);
2317
- }
2318
- function mergeSpecs(base, child) {
2319
- const out = { ...base };
2320
- for (const [k, v] of Object.entries(child)) {
2321
- const b = out[k];
2322
- if (Array.isArray(b) && Array.isArray(v) && (k === "masking" || k === "scenes")) {
2323
- out[k] = [...b, ...v];
2324
- } else if (isPlainObject(b) && isPlainObject(v)) {
2325
- out[k] = mergeSpecs(b, v);
2326
- } else {
2327
- out[k] = v;
2328
- }
2329
- }
2330
- return out;
2331
- }
2332
- function isPlainObject(v) {
2333
- return typeof v === "object" && v !== null && !Array.isArray(v);
2334
- }
2335
- function interpolate(raw, sourcePath) {
2336
- if (!isPlainObject(raw)) return raw;
2337
- const varsIn = isPlainObject(raw.vars) ? raw.vars : {};
2338
- const vars = /* @__PURE__ */ new Map();
2339
- for (const [name, value] of Object.entries(varsIn)) {
2340
- vars.set(name, resolveEnv(String(value), sourcePath));
2341
- }
2342
- const doc = { ...raw };
2343
- delete doc.vars;
2344
- const seen = (s) => {
2345
- const withEnv = resolveEnv(s, sourcePath);
2346
- return withEnv.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, name) => {
2347
- const v = vars.get(name);
2348
- if (v === void 0) {
2349
- const near = suggest(name) ?? [...vars.keys()].find((k) => editDistance(k, name) <= 2);
2350
- throw new SpecError(
2351
- `${sourcePath}: unknown variable {{${name}}}${near ? ` \u2014 did you mean {{${near}}}?` : ""} (declared vars: ${[...vars.keys()].join(", ") || "none"})`
2352
- );
2353
- }
2354
- return v;
2355
- });
2356
- };
2357
- const walk = (v) => {
2358
- if (typeof v === "string") return seen(v);
2359
- if (Array.isArray(v)) return v.map(walk);
2360
- if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, walk(val)]));
2361
- return v;
2362
- };
2363
- return walk(doc);
2364
- }
2365
- function resolveEnv(s, sourcePath) {
2366
- return s.replace(/\$\{env\.([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, dflt) => {
2367
- const v = process.env[name];
2368
- if (v !== void 0) return v;
2369
- if (dflt !== void 0) return dflt;
2370
- throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
2371
- });
2372
- }
2373
- function validateResolved(resolved, sourcePath, doc, lineCounter) {
2374
- const result = specSchema.safeParse(resolved);
2375
- if (!result.success) {
2376
- const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter));
2377
- throw new SpecError(`${sourcePath}: invalid spec
2378
- - ${issues.join("\n - ")}`, issues);
2379
- }
2380
- return result.data;
2381
- }
2382
- function formatIssue(iss, sourcePath, doc, lineCounter) {
2383
- const where = iss.path.length ? humanPath(iss.path) : "spec";
2384
- const pos = positionOf(iss.path, doc, lineCounter);
2385
- const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
2386
- if (iss.code === "unrecognized_keys") {
2387
- const keys = iss.keys;
2388
- const hints = keys.map((k) => ({ k, near: suggest(k) })).map((h) => h.near ? `"${h.k}" (did you mean "${h.near}"?)` : `"${h.k}"`).join(", ");
2389
- return `${at}${where}: unknown key${keys.length > 1 ? "s" : ""} ${hints}`;
2390
- }
2391
- return `${at}${where}: ${iss.message}`;
2392
- }
2393
- function positionOf(path, doc, lineCounter) {
2394
- for (let depth = path.length; depth > 0; depth--) {
2395
- try {
2396
- const node = doc.getIn(path.slice(0, depth), true);
2397
- if (node?.range) {
2398
- const pos = lineCounter.linePos(node.range[0]);
2399
- return { line: pos.line, col: pos.col };
2400
- }
2401
- } catch {
2402
- }
2896
+ case "press":
2897
+ return `press ${s.keys}`;
2898
+ case "expect":
2899
+ return `expect ${s.locator}`;
2900
+ default:
2901
+ return `${s.action} ${s.locator}`;
2403
2902
  }
2404
- return null;
2405
2903
  }
2406
- function humanPath(path) {
2407
- return path.map((seg, i) => typeof seg === "number" ? `[${seg}]` : i === 0 ? String(seg) : `.${String(seg)}`).join("");
2904
+ function capitalize(s) {
2905
+ return s.charAt(0).toUpperCase() + s.slice(1);
2408
2906
  }
2409
2907
 
2908
+ // src/mcp/server.ts
2909
+ init_parse();
2910
+
2410
2911
  // src/capture/executor.ts
2411
2912
  init_schema();
2412
2913
  init_locators();
2413
2914
  init_playwright_driver();
2414
- import { join as join2 } from "path";
2915
+ import { join as join3 } from "path";
2415
2916
 
2416
2917
  // src/bundle/writer.ts
2417
2918
  import { mkdir, writeFile as writeFile2 } from "fs/promises";
2418
- import { join } from "path";
2919
+ import { createHash as createHash2 } from "crypto";
2920
+ import { join as join2 } from "path";
2419
2921
  import sharp from "sharp";
2420
2922
 
2421
2923
  // src/bundle/hash.ts
@@ -2432,11 +2934,12 @@ function sha256File(path) {
2432
2934
  }
2433
2935
 
2434
2936
  // src/bundle/writer.ts
2937
+ init_log();
2435
2938
  var BundleWriter = class {
2436
2939
  constructor(dir, captureFormat) {
2437
2940
  this.dir = dir;
2438
2941
  this.captureFormat = captureFormat;
2439
- this.ready = mkdir(join(dir, "frames"), { recursive: true }).then(() => {
2942
+ this.ready = mkdir(join2(dir, "frames"), { recursive: true }).then(() => {
2440
2943
  });
2441
2944
  }
2442
2945
  dir;
@@ -2445,14 +2948,22 @@ var BundleWriter = class {
2445
2948
  pendingWrites = [];
2446
2949
  maskSamples = [];
2447
2950
  counter = 0;
2951
+ failedFrames = /* @__PURE__ */ new Set();
2952
+ writeError;
2448
2953
  ready;
2449
- /** Queue a frame write. `t` is capture-relative ms. */
2954
+ /** Queue a frame write. `t` is capture-relative ms. Each frame's BYTES are hashed at write
2955
+ * time — the index entry carries the digest, so the framesIndex hash transitively covers
2956
+ * every pixel in the bundle. (Round-2 audit: filename+timestamp hashing left frame images
2957
+ * freely swappable under a passing attest.) */
2450
2958
  addFrame(data, t) {
2451
2959
  this.counter += 1;
2452
2960
  const name = `${String(this.counter).padStart(6, "0")}.${this.captureFormat === "jpeg" ? "jpg" : "png"}`;
2453
- this.frames.push({ f: name, t: round1(t) });
2961
+ this.frames.push({ f: name, t: round1(t), h: createHash2("sha256").update(data).digest("hex") });
2454
2962
  this.pendingWrites.push(
2455
- this.ready.then(() => writeFile2(join(this.dir, "frames", name), data))
2963
+ this.ready.then(() => writeFile2(join2(this.dir, "frames", name), data)).catch((e) => {
2964
+ this.failedFrames.add(name);
2965
+ this.writeError ??= e;
2966
+ })
2456
2967
  );
2457
2968
  }
2458
2969
  addMaskSample(sample) {
@@ -2464,21 +2975,28 @@ var BundleWriter = class {
2464
2975
  async finish(meta) {
2465
2976
  await this.ready;
2466
2977
  await Promise.all(this.pendingWrites);
2978
+ if (this.failedFrames.size > 0) {
2979
+ this.frames = this.frames.filter((f) => !this.failedFrames.has(f.f));
2980
+ log.warn(`${this.failedFrames.size} frame write(s) failed (${this.writeError?.message?.split("\n")[0]}) \u2014 those frames dropped from the index`);
2981
+ }
2467
2982
  this.frames.sort((a, b) => a.t - b.t);
2468
2983
  let frameW = meta.viewport.w * meta.dpr;
2469
2984
  let frameH = meta.viewport.h * meta.dpr;
2470
2985
  const first = this.frames[0];
2471
2986
  if (first) {
2472
- const info = await sharp(join(this.dir, "frames", first.f)).metadata();
2987
+ const info = await sharp(join2(this.dir, "frames", first.f)).metadata();
2473
2988
  if (info.width && info.height) {
2474
2989
  frameW = info.width;
2475
2990
  frameH = info.height;
2476
2991
  }
2477
2992
  }
2993
+ meta.events.meta.frameW = frameW;
2994
+ meta.events.meta.frameH = frameH;
2995
+ const spec = redactMaskedText(meta.spec);
2478
2996
  const manifest = {
2479
2997
  schema: "playhead/bundle@1",
2480
2998
  playheadVersion: PLAYHEAD_VERSION,
2481
- specHash: sha256Json(meta.spec),
2999
+ specHash: sha256Json(spec),
2482
3000
  appUrl: meta.appUrl,
2483
3001
  viewport: meta.viewport,
2484
3002
  dpr: meta.dpr,
@@ -2497,15 +3015,24 @@ var BundleWriter = class {
2497
3015
  ...meta.failure ? { failure: meta.failure } : {}
2498
3016
  };
2499
3017
  await Promise.all([
2500
- writeFile2(join(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
2501
- writeFile2(join(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
2502
- writeFile2(join(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
2503
- writeFile2(join(this.dir, "spec.resolved.json"), JSON.stringify(meta.spec, null, 2)),
2504
- writeFile2(join(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
3018
+ writeFile2(join2(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
3019
+ writeFile2(join2(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
3020
+ writeFile2(join2(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
3021
+ writeFile2(join2(this.dir, "spec.resolved.json"), JSON.stringify(spec, null, 2)),
3022
+ writeFile2(join2(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
2505
3023
  ]);
2506
3024
  return manifest;
2507
3025
  }
2508
3026
  };
3027
+ function redactMaskedText(spec) {
3028
+ const copy = JSON.parse(JSON.stringify(spec));
3029
+ for (const scene of copy.scenes) {
3030
+ for (const step of scene.steps) {
3031
+ if (step.action === "type" && step.mask && step.text) step.text = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
3032
+ }
3033
+ }
3034
+ return copy;
3035
+ }
2509
3036
  function round1(n) {
2510
3037
  return Math.round(n * 10) / 10;
2511
3038
  }
@@ -2521,7 +3048,7 @@ var TYPE_DELAY_MS = 45;
2521
3048
  var STEP_BUDGET_MS = 45e3;
2522
3049
  var CAPTURE_DEADLINE_MS = 10 * 6e4;
2523
3050
  async function capture(spec, opts) {
2524
- const bundleDir = join2(opts.outDir, "capture");
3051
+ const bundleDir = join3(opts.outDir, "capture");
2525
3052
  const driver = opts.driver ?? new PlaywrightDriver();
2526
3053
  const dpr = 2;
2527
3054
  const format = opts.captureFormat ?? "jpeg";
@@ -2555,18 +3082,20 @@ async function capture(spec, opts) {
2555
3082
  try {
2556
3083
  clockOffsetMs = await driver.measureClockOffset();
2557
3084
  t0 = Date.now();
3085
+ let recordEvents = !opts.fromScene;
2558
3086
  driver.onNavigation((url, tNode) => {
2559
- events.push({ type: "navigation", url, t: rel(tNode) });
3087
+ if (recordEvents) events.push({ type: "navigation", url, t: rel(tNode) });
2560
3088
  });
2561
3089
  const startFilming = () => driver.startCapture((frame) => writer.addFrame(frame.data, rel(frame.tNodeMs)), {
2562
3090
  format,
2563
3091
  quality: opts.quality ?? 82,
2564
3092
  scale: dpr,
2565
- // 24 requested 20-22 achieved with the screenshot loop's ~25ms capture cost; frame
2566
- // blending at compose smooths the rest. (True 30+ needs the screencast source — planned.)
2567
- fps: 24
3093
+ // The screencast source delivers paint-driven frames up to ~30fps during motion; the
3094
+ // paced screenshot fallback (PLAYHEAD_CAPTURE=screenshot) tops out ~20.
3095
+ fps: 30
2568
3096
  });
2569
- if (!opts.fromScene) await startFilming();
3097
+ const preRoll = spec.setup.length > 0 || Boolean(opts.fromScene);
3098
+ if (!preRoll) await startFilming();
2570
3099
  await driver.installMaskRules(maskRules);
2571
3100
  await driver.goto(spec.app.url);
2572
3101
  await applyMasksAndSample(driver, maskRules, writer, now);
@@ -2578,7 +3107,15 @@ async function capture(spec, opts) {
2578
3107
  );
2579
3108
  });
2580
3109
  }
2581
- if (!opts.fromScene) await driver.captureNow();
3110
+ if (!preRoll) await driver.captureNow();
3111
+ if (spec.setup.length > 0) {
3112
+ log.info(`running ${spec.setup.length} setup step(s) (state only, not filmed)`);
3113
+ for (const [i, s] of spec.setup.entries()) {
3114
+ currentStepRef = `setup/${i}`;
3115
+ await fastForwardStep(driver, s);
3116
+ }
3117
+ currentStepRef = "";
3118
+ }
2582
3119
  let steps = flattenSteps(spec);
2583
3120
  const totalSteps = steps.length;
2584
3121
  if (opts.fromScene) {
@@ -2591,7 +3128,10 @@ async function capture(spec, opts) {
2591
3128
  }
2592
3129
  currentStepRef = "";
2593
3130
  steps = steps.slice(idx);
3131
+ }
3132
+ if (preRoll) {
2594
3133
  await driver.settle(settleCfg);
3134
+ recordEvents = true;
2595
3135
  await startFilming();
2596
3136
  await driver.captureNow();
2597
3137
  }
@@ -2601,11 +3141,20 @@ async function capture(spec, opts) {
2601
3141
  throw new Error(`capture watchdog: exceeded ${CAPTURE_DEADLINE_MS / 6e4} minutes at step ${currentStepRef}`);
2602
3142
  }
2603
3143
  log.step(`${addressed.ordinal}/${totalSteps} ${describeStep(addressed)}`);
2604
- const event = await withStepBudget(
2605
- executeStep(driver, addressed, { rel, now }, spec.app.url),
2606
- STEP_BUDGET_MS,
2607
- currentStepRef
2608
- );
3144
+ let event;
3145
+ try {
3146
+ event = await withStepBudget(
3147
+ executeStep(driver, addressed, { rel, now }, spec.app.url),
3148
+ STEP_BUDGET_MS,
3149
+ currentStepRef
3150
+ );
3151
+ } catch (e) {
3152
+ if (addressed.step.optional) {
3153
+ log.warn(`optional step ${currentStepRef} skipped: ${e.message.split("\n")[0]}`);
3154
+ continue;
3155
+ }
3156
+ throw e;
3157
+ }
2609
3158
  await applyMasksAndSample(driver, maskRules, writer, now);
2610
3159
  await driver.settle(settleCfg);
2611
3160
  const focus = "focus" in addressed.step ? addressed.step.focus : void 0;
@@ -2643,10 +3192,10 @@ async function capture(spec, opts) {
2643
3192
  const url = driver.currentUrl();
2644
3193
  const { writeFile: writeFile8 } = await import("fs/promises");
2645
3194
  await writeFile8(
2646
- join2(bundleDir, "failure.json"),
3195
+ join3(bundleDir, "failure.json"),
2647
3196
  JSON.stringify({ stepRef: failure.stepRef, message, url, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
2648
3197
  );
2649
- if (aria) await writeFile8(join2(bundleDir, "failure-aria.txt"), aria);
3198
+ if (aria) await writeFile8(join3(bundleDir, "failure-aria.txt"), aria);
2650
3199
  } catch {
2651
3200
  }
2652
3201
  throw new FlowError(
@@ -2661,7 +3210,7 @@ async function capture(spec, opts) {
2661
3210
  if (consoleLog.length > 0) {
2662
3211
  const { writeFile: writeFile8 } = await import("fs/promises");
2663
3212
  await writeFile8(
2664
- join2(bundleDir, "console.json"),
3213
+ join3(bundleDir, "console.json"),
2665
3214
  JSON.stringify(consoleLog.map((c) => ({ ...c, t: rel(c.t) })), null, 2)
2666
3215
  );
2667
3216
  }
@@ -2713,13 +3262,15 @@ async function fastForwardStep(driver, step) {
2713
3262
  break;
2714
3263
  case "click":
2715
3264
  case "dblclick": {
2716
- const t = await driver.resolveTarget(parseLocator(step.target), T);
2717
- await driver.clickAt(rectCenter(t.bbox), { double: step.action === "dblclick" });
3265
+ await driver.resolveTarget(parseLocator(step.target), T);
3266
+ await driver.actClick(parseLocator(step.target), { double: step.action === "dblclick", timeoutMs: T });
2718
3267
  break;
2719
3268
  }
2720
- case "hover":
3269
+ case "hover": {
3270
+ const t = await driver.resolveTarget(parseLocator(step.target), T);
3271
+ await driver.moveCursor(rectCenter(t.bbox), 60);
2721
3272
  break;
2722
- // no lasting state
3273
+ }
2723
3274
  case "type": {
2724
3275
  const t = await driver.resolveTarget(parseLocator(step.target), T);
2725
3276
  await driver.clickAt(rectCenter(t.bbox));
@@ -2737,8 +3288,23 @@ async function fastForwardStep(driver, step) {
2737
3288
  else if (step.by) await driver.scrollBy(step.by);
2738
3289
  await driver.waitForScrollSettle(1500);
2739
3290
  break;
3291
+ case "rightclick": {
3292
+ await driver.resolveTarget(parseLocator(step.target), T);
3293
+ await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: T });
3294
+ break;
3295
+ }
3296
+ case "upload":
3297
+ await driver.setInputFiles(parseLocator(step.target), step.file);
3298
+ break;
3299
+ case "drag":
3300
+ await driver.dragTo(parseLocator(step.target), parseLocator(step.to), T);
3301
+ break;
2740
3302
  case "expect":
2741
- await driver.expectState(parseLocator(step.target), { ...step.visible !== void 0 ? { visible: step.visible } : {} }, T).catch(() => {
3303
+ await driver.expectState(
3304
+ step.target ? parseLocator(step.target) : null,
3305
+ { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.url !== void 0 ? { url: step.url } : {} },
3306
+ T
3307
+ ).catch(() => {
2742
3308
  });
2743
3309
  break;
2744
3310
  case "wait":
@@ -2824,6 +3390,9 @@ async function executeStep(driver, addressed, clock, appUrl) {
2824
3390
  base.targetPre = pre;
2825
3391
  base.cursorPath = await moveToTarget(driver, pre, clock);
2826
3392
  await driver.actClick(loc, { timeoutMs: budgetMs });
3393
+ if (step.clear) {
3394
+ await driver.press("ControlOrMeta+A");
3395
+ }
2827
3396
  if (step.mask) {
2828
3397
  await driver.setTypingMask(loc);
2829
3398
  }
@@ -2853,6 +3422,42 @@ async function executeStep(driver, addressed, clock, appUrl) {
2853
3422
  base.tActionEnd = clock.now();
2854
3423
  return base;
2855
3424
  }
3425
+ case "rightclick": {
3426
+ base.locator = step.target;
3427
+ const loc = parseLocator(step.target);
3428
+ const pre = await driver.resolveTarget(loc, budgetMs);
3429
+ base.targetPre = pre;
3430
+ base.cursorPath = await moveToTarget(driver, pre, clock);
3431
+ const { tDown, tUp } = await driver.actClick(loc, { button: "right", timeoutMs: budgetMs });
3432
+ base.tAction = clock.rel(tDown);
3433
+ base.tActionEnd = clock.rel(tUp);
3434
+ return base;
3435
+ }
3436
+ case "upload": {
3437
+ base.locator = step.target;
3438
+ const loc = parseLocator(step.target);
3439
+ base.targetPre = await driver.tryMeasure(loc);
3440
+ if (base.targetPre) base.cursorPath = await moveToTarget(driver, base.targetPre, clock);
3441
+ base.tAction = clock.now();
3442
+ await driver.setInputFiles(loc, step.file);
3443
+ base.typedText = step.file.split(/[\\/]/).pop() ?? step.file;
3444
+ base.tActionEnd = clock.now();
3445
+ return base;
3446
+ }
3447
+ case "drag": {
3448
+ base.locator = step.target;
3449
+ const from = parseLocator(step.target);
3450
+ const to = parseLocator(step.to);
3451
+ const pre = await driver.resolveTarget(from, budgetMs);
3452
+ base.targetPre = pre;
3453
+ base.cursorPath = await moveToTarget(driver, pre, clock);
3454
+ const { tDown, tUp, path } = await driver.dragTo(from, to, budgetMs);
3455
+ base.cursorPath = [...base.cursorPath, ...path.map((w) => ({ ...w, t: clock.rel(w.t) }))];
3456
+ base.tAction = clock.rel(tDown);
3457
+ base.tActionEnd = clock.rel(tUp);
3458
+ base.targetPost = await driver.tryMeasure(to);
3459
+ return base;
3460
+ }
2856
3461
  case "scroll": {
2857
3462
  base.tAction = clock.now();
2858
3463
  if (step.target) {
@@ -2869,14 +3474,22 @@ async function executeStep(driver, addressed, clock, appUrl) {
2869
3474
  return base;
2870
3475
  }
2871
3476
  case "expect": {
2872
- base.locator = step.target;
2873
- const loc = parseLocator(step.target);
3477
+ base.locator = step.target ?? step.url ?? "";
3478
+ const loc = step.target ? parseLocator(step.target) : null;
2874
3479
  await driver.expectState(
2875
3480
  loc,
2876
- { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.text !== void 0 ? { text: step.text } : {}, ...step.count !== void 0 ? { count: step.count } : {} },
3481
+ {
3482
+ ...step.visible !== void 0 ? { visible: step.visible } : {},
3483
+ ...step.text !== void 0 ? { text: step.text } : {},
3484
+ ...step.count !== void 0 ? { count: step.count } : {},
3485
+ ...step.url !== void 0 ? { url: step.url } : {},
3486
+ ...step.value !== void 0 ? { value: step.value } : {},
3487
+ ...step.disabled !== void 0 ? { disabled: step.disabled } : {},
3488
+ ...step.checked !== void 0 ? { checked: step.checked } : {}
3489
+ },
2877
3490
  step.timeout ?? EXPECT_TIMEOUT_MS
2878
3491
  );
2879
- base.targetPre = await driver.tryMeasure(loc);
3492
+ if (loc) base.targetPre = await driver.tryMeasure(loc);
2880
3493
  base.tAction = clock.now();
2881
3494
  base.tActionEnd = base.tAction;
2882
3495
  return base;
@@ -2902,6 +3515,7 @@ async function moveToTarget(driver, target, clock) {
2902
3515
  const to = rectCenter(target.bbox);
2903
3516
  const travel = clamp(250 + dist(driver.cursorPos(), to) * 0.5, 300, 700);
2904
3517
  const waypoints = await driver.moveCursor(to, travel);
3518
+ await driver.captureNow();
2905
3519
  return waypoints.map((w) => ({ x: round12(w.x), y: round12(w.y), t: round12(clock.rel(w.t)) }));
2906
3520
  }
2907
3521
  async function ensurePostActionFrame(driver, tAction, rel) {
@@ -2948,13 +3562,13 @@ function round12(n) {
2948
3562
 
2949
3563
  // src/bundle/reader.ts
2950
3564
  import { readFile as readFile2 } from "fs/promises";
2951
- import { join as join3 } from "path";
3565
+ import { join as join4 } from "path";
2952
3566
  async function openBundle(dir) {
2953
3567
  const [manifestRaw, eventsRaw, framesRaw, masksRaw] = await Promise.all([
2954
- readFile2(join3(dir, "manifest.json"), "utf8"),
2955
- readFile2(join3(dir, "events.json"), "utf8"),
2956
- readFile2(join3(dir, "frames", "index.json"), "utf8"),
2957
- readFile2(join3(dir, "masks.json"), "utf8").catch(() => "[]")
3568
+ readFile2(join4(dir, "manifest.json"), "utf8"),
3569
+ readFile2(join4(dir, "events.json"), "utf8"),
3570
+ readFile2(join4(dir, "frames", "index.json"), "utf8"),
3571
+ readFile2(join4(dir, "masks.json"), "utf8").catch(() => "[]")
2958
3572
  ]);
2959
3573
  const manifest = JSON.parse(manifestRaw);
2960
3574
  if (manifest.schema !== "playhead/bundle@1") {
@@ -2979,17 +3593,17 @@ function frameIndexForTime(frames, tMs) {
2979
3593
  return lo;
2980
3594
  }
2981
3595
  function framePath(bundle, index) {
2982
- return join3(bundle.dir, "frames", bundle.frames[index].f);
3596
+ return join4(bundle.dir, "frames", bundle.frames[index].f);
2983
3597
  }
2984
3598
 
2985
3599
  // src/compose/index.ts
2986
3600
  init_schema();
2987
- import { join as join7 } from "path";
3601
+ import { join as join8 } from "path";
2988
3602
  import { writeFile as writeFile4 } from "fs/promises";
2989
3603
 
2990
3604
  // src/theme/index.ts
2991
- import { existsSync } from "fs";
2992
- import { join as join4, dirname as dirname2 } from "path";
3605
+ import { existsSync as existsSync2 } from "fs";
3606
+ import { join as join5, dirname as dirname3 } from "path";
2993
3607
  import { createRequire as createRequire2 } from "module";
2994
3608
  import { GlobalFonts } from "@napi-rs/canvas";
2995
3609
  var DEFAULT_THEME = {
@@ -3023,7 +3637,7 @@ function registerFonts() {
3023
3637
  const require5 = createRequire2(import.meta.url);
3024
3638
  let pkgDir;
3025
3639
  try {
3026
- pkgDir = dirname2(require5.resolve("@expo-google-fonts/inter/package.json"));
3640
+ pkgDir = dirname3(require5.resolve("@expo-google-fonts/inter/package.json"));
3027
3641
  } catch {
3028
3642
  throw new Error("Font package @expo-google-fonts/inter not found \u2014 run npm install");
3029
3643
  }
@@ -3034,8 +3648,8 @@ function registerFonts() {
3034
3648
  ["700Bold/Inter_700Bold.ttf", "Inter Bold"]
3035
3649
  ];
3036
3650
  for (const [rel, family] of faces) {
3037
- const p = join4(pkgDir, rel);
3038
- if (existsSync(p)) GlobalFonts.registerFromPath(p, family);
3651
+ const p = join5(pkgDir, rel);
3652
+ if (existsSync2(p)) GlobalFonts.registerFromPath(p, family);
3039
3653
  }
3040
3654
  }
3041
3655
 
@@ -3043,17 +3657,24 @@ function registerFonts() {
3043
3657
  function stageContent(profile) {
3044
3658
  return profile.stage?.content ?? { x: 0, y: 0, w: profile.width, h: profile.height };
3045
3659
  }
3046
- function withStage(profile, chrome) {
3660
+ function withStage(profile, chrome, viewportAspect) {
3047
3661
  const { width, height } = profile;
3662
+ const va = viewportAspect ?? width / height;
3663
+ const ui = Math.min(width, height) / 1080;
3048
3664
  const marginY = Math.round(height * 0.055);
3049
- const chromeH = chrome ? Math.round(44 * (Math.min(width, height) / 1080)) : 0;
3050
- const contentH = height - 2 * marginY - chromeH;
3051
- const contentW = Math.round(contentH * (width / height));
3665
+ const marginX = Math.round(width * 0.05);
3666
+ const chromeH = chrome ? Math.round(44 * ui) : 0;
3667
+ let contentH = height - 2 * marginY - chromeH;
3668
+ let contentW = Math.round(contentH * va);
3669
+ if (contentW > width - 2 * marginX) {
3670
+ contentW = width - 2 * marginX;
3671
+ contentH = Math.round(contentW / va);
3672
+ }
3052
3673
  const x = Math.round((width - contentW) / 2);
3053
- const y = marginY + chromeH;
3674
+ const y = Math.round((height - contentH - chromeH) / 2) + chromeH;
3054
3675
  return {
3055
3676
  ...profile,
3056
- stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * (Math.min(width, height) / 1080)) }
3677
+ stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * ui) }
3057
3678
  };
3058
3679
  }
3059
3680
  var PROFILE_16x9 = {
@@ -3063,7 +3684,8 @@ var PROFILE_16x9 = {
3063
3684
  safeArea: { top: 54, right: 96, bottom: 160, left: 96 },
3064
3685
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
3065
3686
  minFocusPx: 110,
3066
- captionMaxWidth: 1040
3687
+ captionMaxWidth: 1040,
3688
+ uiScale: 1
3067
3689
  };
3068
3690
  var PROFILE_9x16 = {
3069
3691
  width: 1080,
@@ -3072,7 +3694,8 @@ var PROFILE_9x16 = {
3072
3694
  safeArea: { top: 230, right: 56, bottom: 320, left: 56 },
3073
3695
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
3074
3696
  minFocusPx: 96,
3075
- captionMaxWidth: 980
3697
+ captionMaxWidth: 980,
3698
+ uiScale: 1
3076
3699
  };
3077
3700
  var PROFILE_1x1 = {
3078
3701
  width: 1080,
@@ -3081,7 +3704,8 @@ var PROFILE_1x1 = {
3081
3704
  safeArea: { top: 80, right: 72, bottom: 200, left: 72 },
3082
3705
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
3083
3706
  minFocusPx: 100,
3084
- captionMaxWidth: 940
3707
+ captionMaxWidth: 940,
3708
+ uiScale: 1
3085
3709
  };
3086
3710
  function profileForAspect(aspect, resolution, fps) {
3087
3711
  const base = aspect === "9:16" ? PROFILE_9x16 : aspect === "1:1" ? PROFILE_1x1 : PROFILE_16x9;
@@ -3098,7 +3722,8 @@ function profileForAspect(aspect, resolution, fps) {
3098
3722
  left: Math.round(base.safeArea.left * scaleX),
3099
3723
  right: Math.round(base.safeArea.right * scaleX)
3100
3724
  },
3101
- captionMaxWidth: Math.round(base.captionMaxWidth * scaleX)
3725
+ captionMaxWidth: Math.round(base.captionMaxWidth * scaleX),
3726
+ uiScale: Math.min(resolution.w, resolution.h) / 1080
3102
3727
  };
3103
3728
  }
3104
3729
  function actReactWindows(step) {
@@ -3186,6 +3811,9 @@ function pacingConfig(mode, kind) {
3186
3811
  type: 450 * factor,
3187
3812
  press: 450 * factor,
3188
3813
  select: 600 * factor,
3814
+ rightclick: 700 * factor,
3815
+ upload: 800 * factor,
3816
+ drag: 700 * factor,
3189
3817
  scroll: 350 * factor,
3190
3818
  expect: 800 * factor,
3191
3819
  wait: 150
@@ -3210,10 +3838,37 @@ function buildTimeline(log2, cfg, audioMs) {
3210
3838
  const srcEnd = Math.max(e.tSettled, e.tActionEnd, e.tStart + 1);
3211
3839
  const spanSrc = srcEnd - e.tStart;
3212
3840
  let spanOut = spanSrc;
3213
- if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) spanOut = cfg.maxTypeOutMs;
3214
- segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
3215
- const outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
3216
- out += spanOut;
3841
+ let outBeat;
3842
+ if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) {
3843
+ const HEAD_MS = 1200;
3844
+ const TAIL_MS = 700;
3845
+ const headSrc = Math.min(HEAD_MS, spanSrc * 0.4);
3846
+ const tailSrc = Math.min(TAIL_MS, spanSrc * 0.25);
3847
+ const midSrc = spanSrc - headSrc - tailSrc;
3848
+ const midOut = Math.max(250, cfg.maxTypeOutMs - headSrc - tailSrc);
3849
+ segments.push({ kind: "source", outStart: out, outEnd: out + headSrc, srcStart: e.tStart, srcEnd: e.tStart + headSrc });
3850
+ segments.push({
3851
+ kind: "source",
3852
+ outStart: out + headSrc,
3853
+ outEnd: out + headSrc + midOut,
3854
+ srcStart: e.tStart + headSrc,
3855
+ srcEnd: e.tStart + headSrc + midSrc
3856
+ });
3857
+ segments.push({
3858
+ kind: "source",
3859
+ outStart: out + headSrc + midOut,
3860
+ outEnd: out + headSrc + midOut + tailSrc,
3861
+ srcStart: srcEnd - tailSrc,
3862
+ srcEnd
3863
+ });
3864
+ spanOut = headSrc + midOut + tailSrc;
3865
+ outBeat = out + Math.min(e.tAction - e.tStart, headSrc);
3866
+ out += spanOut;
3867
+ } else {
3868
+ segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
3869
+ outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
3870
+ out += spanOut;
3871
+ }
3217
3872
  let hold = cfg.holds[e.kind];
3218
3873
  if (out + hold - stepOutStart < cfg.minStepMs) hold = cfg.minStepMs - (out - stepOutStart);
3219
3874
  const aud = audioMs?.get(`${e.sceneId}/${e.stepIndex}`);
@@ -3242,6 +3897,7 @@ function buildTimeline(log2, cfg, audioMs) {
3242
3897
  ...e.focus ? { focus: e.focus } : {},
3243
3898
  ...e.shot ? { shot: e.shot } : {},
3244
3899
  ...e.focusTarget ? { focusRectVp: e.focusTarget.bbox } : {},
3900
+ ...e.targetPre && !e.targetPost ? { targetGone: true } : {},
3245
3901
  outStart: stepOutStart,
3246
3902
  outBeat,
3247
3903
  outEnd: out,
@@ -3268,6 +3924,18 @@ function sampleSource(segments, tOut) {
3268
3924
  }
3269
3925
  }
3270
3926
  }
3927
+ function outTimeForSrc(segments, tSrc) {
3928
+ for (const seg of segments) {
3929
+ if (seg.kind === "source" && tSrc >= seg.srcStart && tSrc <= seg.srcEnd) {
3930
+ const u = (tSrc - seg.srcStart) / Math.max(1e-6, seg.srcEnd - seg.srcStart);
3931
+ return seg.outStart + u * (seg.outEnd - seg.outStart);
3932
+ }
3933
+ if (seg.kind === "freeze" && Math.abs(seg.srcAt - tSrc) < 400) {
3934
+ return seg.outStart + (seg.outEnd - seg.outStart) / 2;
3935
+ }
3936
+ }
3937
+ return null;
3938
+ }
3271
3939
  function segmentAt(segments, tOut) {
3272
3940
  let lo = 0;
3273
3941
  let hi = segments.length - 1;
@@ -3306,13 +3974,48 @@ function sampleCamera(keyframes, tOut) {
3306
3974
  }
3307
3975
  const a = keyframes[lo];
3308
3976
  const b = keyframes[hi];
3309
- const u = (tOut - a.tOut) / Math.max(1e-6, b.tOut - a.tOut);
3310
- const e = cubicBezier(b.ease, u);
3311
- return {
3312
- cx: a.state.cx + (b.state.cx - a.state.cx) * e,
3313
- cy: a.state.cy + (b.state.cy - a.state.cy) * e,
3314
- zoom: Math.exp(Math.log(a.state.zoom) + (Math.log(b.state.zoom) - Math.log(a.state.zoom)) * e)
3315
- };
3977
+ const dt = Math.max(1e-6, b.tOut - a.tOut);
3978
+ const u = (tOut - a.tOut) / dt;
3979
+ if (sameState(a.state, b.state)) return { ...a.state };
3980
+ const va = [a.state.cx, a.state.cy, Math.log(a.state.zoom)];
3981
+ const vb = [b.state.cx, b.state.cy, Math.log(b.state.zoom)];
3982
+ const TENSION = 0.5;
3983
+ const ma = tangentAt(keyframes, lo, TENSION);
3984
+ const mb = tangentAt(keyframes, hi, TENSION);
3985
+ const isolated = ma.every((m) => m === 0) && mb.every((m) => m === 0);
3986
+ if (isolated) {
3987
+ const e = cubicBezier(b.ease, u);
3988
+ return {
3989
+ cx: va[0] + (vb[0] - va[0]) * e,
3990
+ cy: va[1] + (vb[1] - va[1]) * e,
3991
+ zoom: Math.exp(va[2] + (vb[2] - va[2]) * e)
3992
+ };
3993
+ }
3994
+ const u2 = u * u;
3995
+ const u3 = u2 * u;
3996
+ const h00 = 2 * u3 - 3 * u2 + 1;
3997
+ const h10 = u3 - 2 * u2 + u;
3998
+ const h01 = -2 * u3 + 3 * u2;
3999
+ const h11 = u3 - u2;
4000
+ const out = [0, 0, 0];
4001
+ for (let c = 0; c < 3; c++) {
4002
+ out[c] = h00 * va[c] + h10 * dt * ma[c] + h01 * vb[c] + h11 * dt * mb[c];
4003
+ }
4004
+ return { cx: out[0], cy: out[1], zoom: Math.exp(out[2]) };
4005
+ }
4006
+ function tangentAt(keyframes, i, tension) {
4007
+ const cur = keyframes[i];
4008
+ const prev = i > 0 ? keyframes[i - 1] : null;
4009
+ const next = i < keyframes.length - 1 ? keyframes[i + 1] : null;
4010
+ if (!prev || !next) return [0, 0, 0];
4011
+ if (sameState(prev.state, cur.state) || sameState(cur.state, next.state)) return [0, 0, 0];
4012
+ const dt = Math.max(1e-6, next.tOut - prev.tOut);
4013
+ const vp = [prev.state.cx, prev.state.cy, Math.log(prev.state.zoom)];
4014
+ const vn = [next.state.cx, next.state.cy, Math.log(next.state.zoom)];
4015
+ return [0, 1, 2].map((c) => tension * (vn[c] - vp[c]) / dt);
4016
+ }
4017
+ function sameState(a, b) {
4018
+ return Math.abs(a.cx - b.cx) < 0.5 && Math.abs(a.cy - b.cy) < 0.5 && Math.abs(a.zoom - b.zoom) < 1e-3;
3316
4019
  }
3317
4020
 
3318
4021
  // src/compose/camera/planner.ts
@@ -3339,7 +4042,8 @@ function planCamera(steps, log2, profile, durationMs, opts) {
3339
4042
  if (quantums.length === 0) quantums.push(1);
3340
4043
  const navTimes = navigationEvents(log2).map((n) => n.t);
3341
4044
  const shots = groupShots(steps, navTimes, plane2, durationMs);
3342
- for (let attempt = 0; attempt < 4; attempt++) {
4045
+ const maxAttempts = Math.max(8, shots.length * (quantums.length + 1));
4046
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3343
4047
  for (const shot2 of shots) shot2.state = shotState(shot2, quantums, plane2, profile, emphasis);
3344
4048
  const keyframes = emitKeyframes(shots, plane2, durationMs);
3345
4049
  const violation = checkConstraints(keyframes, steps, plane2, profile);
@@ -3356,7 +4060,9 @@ function planCamera(steps, log2, profile, durationMs, opts) {
3356
4060
  fallbacks.push(`${violation.stepRef}: reduced shot zoom to satisfy framing constraints`);
3357
4061
  }
3358
4062
  }
4063
+ for (const shot of shots) shot.wide = true;
3359
4064
  for (const shot of shots) shot.state = shotState(shot, quantums, plane2, profile, emphasis);
4065
+ fallbacks.push("camera planner exhausted its attempt budget \u2014 all shots forced wide (check for conflicting focus hints)");
3360
4066
  return { keyframes: emitKeyframes(shots, plane2, durationMs), fallbacks };
3361
4067
  }
3362
4068
  function groupShots(steps, navTimes, plane2, durationMs) {
@@ -3431,6 +4137,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
3431
4137
  continue;
3432
4138
  }
3433
4139
  }
4140
+ const arriveBeat = step.kind === "goto" && rect ? Math.min(step.outEnd - 400, step.outBeat + Math.max(200, step.srcSettled - step.srcAction) + 150) : step.outBeat;
3434
4141
  current = {
3435
4142
  stepRefs: [step.stepRef],
3436
4143
  sceneId: step.sceneId,
@@ -3438,7 +4145,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
3438
4145
  minTargetDim: rect ? Math.min(rect.w, rect.h) : Math.min(plane2.vpW, plane2.vpH),
3439
4146
  wide,
3440
4147
  outStart: step.outStart,
3441
- firstBeat: step.outBeat,
4148
+ firstBeat: arriveBeat,
3442
4149
  outEnd: step.outEnd,
3443
4150
  zoomIndex: Number.MAX_SAFE_INTEGER
3444
4151
  // resolved in shotState
@@ -3671,6 +4378,12 @@ function writeCaption(event, texts) {
3671
4378
  return label ? `Enter the ${lowerFirst(label)}` : "Enter a value";
3672
4379
  case "press":
3673
4380
  return `Press ${event.locator ?? "the key"}`;
4381
+ case "rightclick":
4382
+ return label ? `Right-click \u201C${label}\u201D` : "Right-click the element";
4383
+ case "upload":
4384
+ return label ? `Upload a file to ${lowerFirst(label)}` : "Upload the file";
4385
+ case "drag":
4386
+ return label ? `Drag \u201C${label}\u201D into place` : "Drag the item into place";
3674
4387
  case "select":
3675
4388
  return label && event.typedText ? `Choose \u201C${event.typedText}\u201D under ${label}` : label ? `Choose an option under ${label}` : "Choose an option";
3676
4389
  case "goto":
@@ -3816,29 +4529,46 @@ function buildOverlays(steps, log2, theme, profile, title, subtitle, titleCardEn
3816
4529
  const text = kind.captionStyle === "factual" ? `Step ${number} \u2014 ${lowerFirst2(base)}` : base;
3817
4530
  const fontFor = (px) => `${px}px Inter Medium`;
3818
4531
  const showBadge = kind.numberedCaptions && kind.captionStyle !== "factual";
3819
- const badgeFontPx = 15;
4532
+ const ui = profile.uiScale;
4533
+ const padX = Math.round(CARD_PAD_X * ui);
4534
+ const padY = Math.round(CARD_PAD_Y * ui);
4535
+ const badgeFontPx = Math.max(11, Math.round(15 * ui));
3820
4536
  const badgeText = `${number}/${total}`;
3821
- const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + 20 : 0;
3822
- const badgeGap = showBadge ? BADGE_GAP : 0;
3823
- const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - CARD_PAD_X * 2 - badgeW - badgeGap;
3824
- const wrap = wrapText(text, fontFor, maxTextWidth, theme.captionFontPx, theme.captionMinFontPx, theme.captionMaxLines);
4537
+ const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + Math.round(20 * ui) : 0;
4538
+ const badgeGap = showBadge ? Math.round(BADGE_GAP * ui) : 0;
4539
+ const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - padX * 2 - badgeW - badgeGap;
4540
+ const wrap = wrapText(
4541
+ text,
4542
+ fontFor,
4543
+ maxTextWidth,
4544
+ Math.round(theme.captionFontPx * ui),
4545
+ Math.round(theme.captionMinFontPx * ui),
4546
+ theme.captionMaxLines
4547
+ );
3825
4548
  if (wrap.truncated) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "truncated" });
3826
4549
  else if (wrap.shrunk) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "shrunk" });
3827
4550
  const lineH = Math.round(wrap.fontPx * 1.35);
3828
- const cardW = CARD_PAD_X * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
3829
- const cardH = CARD_PAD_Y * 2 + wrap.lines.length * lineH;
4551
+ const cardW = padX * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
4552
+ const cardH = padY * 2 + wrap.lines.length * lineH;
3830
4553
  const x = (profile.width - cardW) / 2;
3831
- let y = theme.captionPosition === "bottom" ? profile.height - CARD_BOTTOM_MARGIN - cardH : profile.safeArea.top;
4554
+ let y = theme.captionPosition === "bottom" ? profile.height - Math.round(CARD_BOTTOM_MARGIN * ui) - cardH : profile.safeArea.top;
3832
4555
  if (camera && theme.captionPosition === "bottom") {
3833
- const rect = step.focusRectVp ?? step.targetRectVp;
3834
- if (rect) {
3835
- const content = stageContent(profile);
3836
- const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
3837
- const cam = sampleCamera(camera, Math.min(step.outBeat + 250, step.outEnd));
3838
- const proj = projectRect(rect, cam, capPlane);
4556
+ const content = stageContent(profile);
4557
+ const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
4558
+ const capBox = { x, y, w: cardW, h: cardH };
4559
+ const handoff = actReactWindows(step);
4560
+ const probes = handoff ? [
4561
+ { rect: handoff.act.rect, t: Math.min(step.outBeat + 250, handoff.act.to) },
4562
+ { rect: handoff.react.rect, t: handoff.react.from + 50 }
4563
+ ] : step.focusRectVp ?? step.targetRectVp ? [{ rect: step.focusRectVp ?? step.targetRectVp, t: Math.min(step.outBeat + 250, step.outEnd) }] : [];
4564
+ for (const probe of probes) {
4565
+ const cam = sampleCamera(camera, probe.t);
4566
+ const proj = projectRect(probe.rect, cam, capPlane);
3839
4567
  const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
3840
- const capBox = { x, y, w: cardW, h: cardH };
3841
- if (overlaps(projFrame, capBox)) y = profile.safeArea.top;
4568
+ if (overlaps(projFrame, capBox)) {
4569
+ y = profile.safeArea.top;
4570
+ break;
4571
+ }
3842
4572
  }
3843
4573
  }
3844
4574
  const next = captioned[i + 1];
@@ -3967,19 +4697,38 @@ function drawOverlays(ctx2, overlays, tOut, theme, profile) {
3967
4697
  if (bottomCaptions.length > 0) {
3968
4698
  const a = Math.max(...bottomCaptions.map((o) => captionAlpha(o, tOut)));
3969
4699
  if (a > 0) {
3970
- const g = ctx2.createLinearGradient(0, profile.height - SCRIM_H, 0, profile.height);
4700
+ const scrimH = Math.round(SCRIM_H * profile.uiScale);
4701
+ const g = ctx2.createLinearGradient(0, profile.height - scrimH, 0, profile.height);
3971
4702
  g.addColorStop(0, "rgba(8,10,16,0)");
3972
4703
  g.addColorStop(1, `rgba(8,10,16,${(0.55 * a).toFixed(3)})`);
3973
4704
  ctx2.fillStyle = g;
3974
- ctx2.fillRect(0, profile.height - SCRIM_H, profile.width, SCRIM_H);
4705
+ ctx2.fillRect(0, profile.height - scrimH, profile.width, scrimH);
3975
4706
  }
3976
4707
  }
4708
+ const activeCaps = overlays.filter(
4709
+ (o) => o.kind === "caption" && tOut >= o.tStart && tOut <= o.tEnd && captionAlpha(o, tOut) > 0
4710
+ );
4711
+ if (activeCaps.length === 2 && activeCaps[0].box.y === activeCaps[1].box.y) {
4712
+ const [a, b] = activeCaps[0].tStart <= activeCaps[1].tStart ? [activeCaps[0], activeCaps[1]] : [activeCaps[1], activeCaps[0]];
4713
+ const u = captionAlpha(b, tOut);
4714
+ const box = {
4715
+ x: a.box.x + (b.box.x - a.box.x) * u,
4716
+ y: a.box.y,
4717
+ w: a.box.w + (b.box.w - a.box.w) * u,
4718
+ h: Math.max(a.box.h, b.box.h)
4719
+ };
4720
+ drawCaptionCard(ctx2, box, 1, theme);
4721
+ drawCaptionContent(ctx2, a, box, 1 - u, theme);
4722
+ drawCaptionContent(ctx2, b, box, u, theme);
4723
+ } else {
4724
+ for (const o of activeCaps) drawCaption(ctx2, o, tOut, theme);
4725
+ }
3977
4726
  for (const o of overlays) {
3978
4727
  if (tOut < o.tStart || tOut > o.tEnd) continue;
3979
4728
  switch (o.kind) {
3980
4729
  case "caption":
3981
- drawCaption(ctx2, o, tOut, theme);
3982
4730
  break;
4731
+ // handled by the grouped pass above
3983
4732
  case "dip": {
3984
4733
  const u = (tOut - o.tStart) / Math.max(1, o.tEnd - o.tStart);
3985
4734
  ctx2.save();
@@ -4005,13 +4754,14 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
4005
4754
  const ms = Math.max(0, tOut - zeroAtMs);
4006
4755
  const s = Math.floor(ms / 1e3);
4007
4756
  const label = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}.${String(Math.floor(ms % 1e3 / 100))}`;
4008
- const font = "600 26px Inter SemiBold";
4757
+ const ui = profile.uiScale;
4758
+ const font = `600 ${Math.round(26 * ui)}px Inter SemiBold`;
4009
4759
  ctx2.save();
4010
4760
  ctx2.font = font;
4011
4761
  const textW = ctx2.measureText(label).width;
4012
- const padX = 16;
4013
- const w = textW + padX * 2 + 30;
4014
- const h = 44;
4762
+ const padX = 16 * ui;
4763
+ const w = textW + padX * 2 + 30 * ui;
4764
+ const h = 44 * ui;
4015
4765
  const x = profile.width - profile.safeArea.right - w;
4016
4766
  const y = profile.safeArea.top;
4017
4767
  ctx2.fillStyle = "rgba(18,22,31,0.82)";
@@ -4019,13 +4769,13 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
4019
4769
  ctx2.fill();
4020
4770
  ctx2.fillStyle = "#ef4444";
4021
4771
  ctx2.beginPath();
4022
- ctx2.arc(x + padX + 6, y + h / 2, 6, 0, Math.PI * 2);
4772
+ ctx2.arc(x + padX + 6 * ui, y + h / 2, 6 * ui, 0, Math.PI * 2);
4023
4773
  ctx2.fill();
4024
4774
  ctx2.fillStyle = "#ffffff";
4025
4775
  ctx2.textAlign = "left";
4026
4776
  ctx2.textBaseline = "middle";
4027
4777
  ctx2.font = font;
4028
- ctx2.fillText(label, x + padX + 24, y + h / 2 + 1);
4778
+ ctx2.fillText(label, x + padX + 24 * ui, y + h / 2 + 1);
4029
4779
  ctx2.restore();
4030
4780
  }
4031
4781
  function drawChapterLabel(ctx2, o, tOut, theme, profile) {
@@ -4033,23 +4783,24 @@ function drawChapterLabel(ctx2, o, tOut, theme, profile) {
4033
4783
  if (alpha <= 0) return;
4034
4784
  ctx2.save();
4035
4785
  ctx2.globalAlpha = alpha;
4036
- const font = "700 30px Inter Bold";
4786
+ const ui = profile.uiScale;
4787
+ const font = `700 ${Math.round(30 * ui)}px Inter Bold`;
4037
4788
  ctx2.font = font;
4038
4789
  const textW = ctx2.measureText(o.title).width;
4039
- const barW = textW + 96;
4040
- const barH = 56;
4790
+ const barW = textW + 96 * ui;
4791
+ const barH = 56 * ui;
4041
4792
  const x = (profile.width - barW) / 2;
4042
4793
  const y = profile.stage ? profile.stage.content.y + 14 : profile.safeArea.top + 8;
4043
4794
  ctx2.fillStyle = theme.accent;
4044
4795
  roundRect(ctx2, x, y, barW, barH, 12);
4045
4796
  ctx2.fill();
4046
4797
  ctx2.fillStyle = "#ffffff";
4047
- ctx2.font = "600 16px Inter SemiBold";
4798
+ ctx2.font = `600 ${Math.round(16 * ui)}px Inter SemiBold`;
4048
4799
  ctx2.textAlign = "left";
4049
4800
  ctx2.textBaseline = "middle";
4050
- ctx2.fillText(String(o.ordinal), x + 22, y + barH / 2 + 1);
4801
+ ctx2.fillText(String(o.ordinal), x + 22 * ui, y + barH / 2 + 1);
4051
4802
  ctx2.font = font;
4052
- ctx2.fillText(o.title, x + 48, y + barH / 2 + 1);
4803
+ ctx2.fillText(o.title, x + 48 * ui, y + barH / 2 + 1);
4053
4804
  ctx2.restore();
4054
4805
  }
4055
4806
  function captionAlpha(o, tOut) {
@@ -4058,23 +4809,36 @@ function captionAlpha(o, tOut) {
4058
4809
  function drawCaption(ctx2, o, tOut, theme) {
4059
4810
  const alpha = captionAlpha(o, tOut);
4060
4811
  if (alpha <= 0) return;
4812
+ ctx2.save();
4813
+ ctx2.globalAlpha = alpha;
4814
+ drawCaptionCard(ctx2, o.box, 1, theme);
4815
+ ctx2.restore();
4816
+ drawCaptionContent(ctx2, o, o.box, alpha, theme);
4817
+ }
4818
+ function drawCaptionCard(ctx2, box, alpha, theme) {
4061
4819
  ctx2.save();
4062
4820
  ctx2.globalAlpha = alpha;
4063
4821
  ctx2.shadowColor = "rgba(0,0,0,0.30)";
4064
4822
  ctx2.shadowBlur = 18;
4065
4823
  ctx2.shadowOffsetY = 4;
4066
4824
  ctx2.fillStyle = theme.surface;
4067
- roundRect(ctx2, o.box.x, o.box.y, o.box.w, o.box.h, theme.radius);
4825
+ roundRect(ctx2, box.x, box.y, box.w, box.h, theme.radius);
4068
4826
  ctx2.fill();
4069
- ctx2.shadowColor = "transparent";
4827
+ ctx2.restore();
4828
+ }
4829
+ function drawCaptionContent(ctx2, o, box, alpha, theme) {
4830
+ if (alpha <= 0) return;
4831
+ ctx2.save();
4832
+ ctx2.globalAlpha = alpha;
4070
4833
  let badgeW = 0;
4071
- const bx = o.box.x + 26;
4834
+ const bx = box.x + 26;
4072
4835
  if (o.showBadge) {
4073
4836
  const badgeText = `${o.ordinal}/${o.totalSteps}`;
4074
- const badgeFont = `600 15px Inter SemiBold`;
4075
- badgeW = Math.ceil(measureText(badgeText, badgeFont)) + 20;
4076
- const badgeH = 26;
4077
- const by = o.box.y + o.box.h / 2 - badgeH / 2;
4837
+ const bScale = o.fontPx / 36;
4838
+ const badgeFont = `600 ${Math.max(11, Math.round(15 * bScale))}px Inter SemiBold`;
4839
+ badgeW = Math.ceil(measureText(badgeText, badgeFont)) + Math.round(20 * bScale);
4840
+ const badgeH = Math.round(26 * bScale);
4841
+ const by = box.y + box.h / 2 - badgeH / 2;
4078
4842
  ctx2.fillStyle = theme.accent;
4079
4843
  roundRect(ctx2, bx, by, badgeW, badgeH, 13);
4080
4844
  ctx2.fill();
@@ -4086,7 +4850,7 @@ function drawCaption(ctx2, o, tOut, theme) {
4086
4850
  }
4087
4851
  const lineH = Math.round(o.fontPx * 1.35);
4088
4852
  const textX = o.showBadge ? bx + badgeW + 14 : bx;
4089
- const textTop = o.box.y + (o.box.h - o.lines.length * lineH) / 2;
4853
+ const textTop = box.y + (box.h - o.lines.length * lineH) / 2;
4090
4854
  ctx2.fillStyle = theme.onSurface;
4091
4855
  ctx2.font = `${o.fontPx}px Inter Medium`;
4092
4856
  ctx2.textAlign = "left";
@@ -4110,16 +4874,17 @@ function drawFailureCard(ctx2, card, tOut, theme, profile) {
4110
4874
  ctx2.fillStyle = "#ef4444";
4111
4875
  roundRect(ctx2, width / 2 - 32, height / 2 - 150, 64, 8, 4);
4112
4876
  ctx2.fill();
4877
+ const uiF = profile.uiScale;
4113
4878
  ctx2.fillStyle = "#ffffff";
4114
- ctx2.font = `52px Inter Bold`;
4879
+ ctx2.font = `${Math.round(52 * uiF)}px Inter Bold`;
4115
4880
  ctx2.textAlign = "center";
4116
4881
  ctx2.textBaseline = "middle";
4117
4882
  ctx2.fillText("Flow failed", width / 2, height / 2 - 70);
4118
4883
  ctx2.fillStyle = "#fca5a5";
4119
- ctx2.font = `600 30px Inter SemiBold`;
4884
+ ctx2.font = `600 ${Math.round(30 * uiF)}px Inter SemiBold`;
4120
4885
  ctx2.fillText(`at step ${card.stepRef}`, width / 2, height / 2 - 12);
4121
4886
  ctx2.fillStyle = "rgba(255,255,255,0.75)";
4122
- ctx2.font = `24px Inter`;
4887
+ ctx2.font = `${Math.round(24 * uiF)}px Inter`;
4123
4888
  const maxW = Math.min(1200, width - 200);
4124
4889
  const words = card.message.replace(/\s+/g, " ").split(" ");
4125
4890
  const lines = [];
@@ -4160,14 +4925,15 @@ function drawTitleCard(ctx2, card, tOut, theme, profile) {
4160
4925
  ctx2.fill();
4161
4926
  ctx2.globalAlpha = title.alpha;
4162
4927
  ctx2.fillStyle = "#ffffff";
4163
- ctx2.font = `56px Inter Bold`;
4928
+ const uiT = profile.uiScale;
4929
+ ctx2.font = `${Math.round(56 * uiT)}px Inter Bold`;
4164
4930
  ctx2.textAlign = "center";
4165
4931
  ctx2.textBaseline = "middle";
4166
4932
  ctx2.fillText(card.title, width / 2, height / 2 - 20 + title.rise);
4167
4933
  if (card.subtitle) {
4168
4934
  ctx2.globalAlpha = sub.alpha;
4169
4935
  ctx2.fillStyle = theme.onSurfaceDim;
4170
- ctx2.font = `26px Inter`;
4936
+ ctx2.font = `${Math.round(26 * uiT)}px Inter`;
4171
4937
  ctx2.fillText(card.subtitle, width / 2, height / 2 + 44 + sub.rise);
4172
4938
  }
4173
4939
  ctx2.restore();
@@ -4190,7 +4956,8 @@ var FrameStore = class {
4190
4956
  async frameForTime(tMs) {
4191
4957
  const idx = frameIndexForTime(this.bundle.frames, tMs);
4192
4958
  const canvas = await this.get(idx);
4193
- if (idx + 1 < this.bundle.frames.length) void this.get(idx + 1);
4959
+ if (idx + 1 < this.bundle.frames.length) this.get(idx + 1).catch(() => {
4960
+ });
4194
4961
  return canvas;
4195
4962
  }
4196
4963
  /**
@@ -4217,6 +4984,7 @@ var FrameStore = class {
4217
4984
  return hit;
4218
4985
  }
4219
4986
  const promise = this.decode(idx);
4987
+ promise.catch(() => this.cache.delete(idx));
4220
4988
  this.cache.set(idx, promise);
4221
4989
  while (this.cache.size > LRU_SIZE) {
4222
4990
  const oldest = this.cache.keys().next().value;
@@ -4342,12 +5110,19 @@ var Encoder = class {
4342
5110
  this.child.on("error", reject);
4343
5111
  this.child.on("close", (code) => resolve2(code ?? -1));
4344
5112
  });
5113
+ this.child.stdin.on("error", () => {
5114
+ });
4345
5115
  }
4346
5116
  async write(rgba) {
4347
5117
  const stdin = this.child.stdin;
4348
5118
  if (!stdin.writable) throw new Error(`ffmpeg closed early: ${this.stderrTail.join("")}`);
4349
5119
  if (!stdin.write(rgba)) {
4350
- await once(stdin, "drain");
5120
+ await Promise.race([
5121
+ once(stdin, "drain"),
5122
+ this.exit.then((code) => {
5123
+ throw new Error(`ffmpeg exited (code ${code}) while the encoder awaited drain: ${this.stderrTail.join("")}`);
5124
+ })
5125
+ ]);
4351
5126
  }
4352
5127
  }
4353
5128
  async finish() {
@@ -4364,6 +5139,7 @@ init_log();
4364
5139
  var SPOTLIGHT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
4365
5140
  var SPOTLIGHT_LEAD_MS = 200;
4366
5141
  var SPOTLIGHT_TAIL_MS = 650;
5142
+ var SPOTLIGHT_TAIL_GONE_MS = 160;
4367
5143
  var SPOTLIGHT_MAX_DIM = 0.34;
4368
5144
  async function renderVideo(bundle, manifest, outPath) {
4369
5145
  registerFonts();
@@ -4395,7 +5171,7 @@ async function renderVideo(bundle, manifest, outPath) {
4395
5171
  );
4396
5172
  const transitions = manifest.transitions ?? [];
4397
5173
  const spotlightSteps = manifest.steps.filter(
4398
- (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.focusRectVp ?? s.targetRectVp)
5174
+ (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.targetRectVp ?? s.focusRectVp)
4399
5175
  );
4400
5176
  const start = Date.now();
4401
5177
  for (let i = 0; i < manifest.totalFrames; i++) {
@@ -4436,7 +5212,8 @@ async function renderVideo(bundle, manifest, outPath) {
4436
5212
  ctx2.globalAlpha = 1;
4437
5213
  };
4438
5214
  const pair = await store.framePairForTime(srcAt);
4439
- if (segment.kind === "source" && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
5215
+ const rate = segment.kind === "source" ? (segment.srcEnd - segment.srcStart) / Math.max(1, segment.outEnd - segment.outStart) : Infinity;
5216
+ if (segment.kind === "source" && rate < 1.5 && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
4440
5217
  drawSrc(pair.a, 1);
4441
5218
  drawSrc(pair.b, pair.mix);
4442
5219
  } else {
@@ -4452,7 +5229,7 @@ async function renderVideo(bundle, manifest, outPath) {
4452
5229
  }
4453
5230
  const spot = activeSpotlight(spotlightSteps, tOut);
4454
5231
  if (spot) {
4455
- const rect = spot.step.focusRectVp ?? spot.step.targetRectVp;
5232
+ const rect = spot.step.targetRectVp ?? spot.step.focusRectVp;
4456
5233
  const proj = projectRect(rect, cam, plane2);
4457
5234
  drawSpotlight(spotCtx, content, proj, spot.alpha);
4458
5235
  ctx2.drawImage(spotCanvas, content.x, content.y);
@@ -4548,11 +5325,12 @@ function displayUrl(appUrl) {
4548
5325
  }
4549
5326
  function activeSpotlight(steps, tOut) {
4550
5327
  for (const step of steps) {
5328
+ const tail = step.targetGone ? SPOTLIGHT_TAIL_GONE_MS : SPOTLIGHT_TAIL_MS;
4551
5329
  const from = step.outBeat - SPOTLIGHT_LEAD_MS;
4552
- const to = step.outBeat + SPOTLIGHT_TAIL_MS;
5330
+ const to = step.outBeat + tail;
4553
5331
  if (tOut < from || tOut > to) continue;
4554
5332
  const rampIn = clamp((tOut - from) / 180, 0, 1);
4555
- const rampOut = clamp((to - tOut) / 250, 0, 1);
5333
+ const rampOut = clamp((to - tOut) / Math.min(250, tail), 0, 1);
4556
5334
  return { step, alpha: SPOTLIGHT_MAX_DIM * Math.min(rampIn, rampOut) };
4557
5335
  }
4558
5336
  return null;
@@ -4575,7 +5353,152 @@ function cursorGlyphAt(steps, tOut) {
4575
5353
  for (const s of steps) {
4576
5354
  if (s.kind === "type" && tOut >= s.outStart && tOut <= s.outEnd) return "ibeam";
4577
5355
  }
4578
- return "arrow";
5356
+ return "arrow";
5357
+ }
5358
+
5359
+ // src/verify/media.ts
5360
+ import sharp3 from "sharp";
5361
+ async function extractGrayFrames(videoPath, fps, w, h) {
5362
+ const res = await runBinaryRaw(ffmpegPath(), [
5363
+ "-hide_banner",
5364
+ "-loglevel",
5365
+ "error",
5366
+ "-i",
5367
+ videoPath,
5368
+ "-vf",
5369
+ `fps=${fps},scale=${w}:${h}`,
5370
+ "-f",
5371
+ "rawvideo",
5372
+ "-pix_fmt",
5373
+ "gray",
5374
+ "pipe:1"
5375
+ ]);
5376
+ if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
5377
+ const frameSize = w * h;
5378
+ const frames = [];
5379
+ for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
5380
+ frames.push(res.stdout.subarray(off, off + frameSize));
5381
+ }
5382
+ return { frames, intervalMs: 1e3 / fps };
5383
+ }
5384
+ async function extractFrameAt(videoPath, tMs) {
5385
+ const res = await runBinaryRaw(ffmpegPath(), [
5386
+ "-hide_banner",
5387
+ "-loglevel",
5388
+ "error",
5389
+ "-ss",
5390
+ (Math.max(0, tMs) / 1e3).toFixed(3),
5391
+ "-i",
5392
+ videoPath,
5393
+ "-frames:v",
5394
+ "1",
5395
+ "-f",
5396
+ "image2pipe",
5397
+ "-vcodec",
5398
+ "png",
5399
+ "pipe:1"
5400
+ ]);
5401
+ if (res.code !== 0 || res.stdout.length === 0) {
5402
+ throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
5403
+ }
5404
+ return res.stdout;
5405
+ }
5406
+ async function probeVideo(videoPath) {
5407
+ const res = await runBinary(ffprobePath(), [
5408
+ "-v",
5409
+ "error",
5410
+ "-print_format",
5411
+ "json",
5412
+ "-show_format",
5413
+ "-show_streams",
5414
+ videoPath
5415
+ ]);
5416
+ if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
5417
+ const json = JSON.parse(res.stdout);
5418
+ const v = json.streams?.find((s) => s.codec_type === "video");
5419
+ if (!v) throw new Error("no video stream found");
5420
+ const [num, den] = v.avg_frame_rate.split("/").map(Number);
5421
+ return {
5422
+ codec: v.codec_name,
5423
+ width: v.width,
5424
+ height: v.height,
5425
+ durationSec: Number(json.format?.duration ?? 0),
5426
+ fps: den ? num / den : 0
5427
+ };
5428
+ }
5429
+ function meanAbsDiff(a, b) {
5430
+ const n = Math.min(a.length, b.length);
5431
+ let sum = 0;
5432
+ for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
5433
+ return sum / n;
5434
+ }
5435
+ function variance(a) {
5436
+ let sum = 0;
5437
+ for (let i = 0; i < a.length; i++) sum += a[i];
5438
+ const mean = sum / a.length;
5439
+ let v = 0;
5440
+ for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
5441
+ return v / a.length;
5442
+ }
5443
+ async function sourceFrameGray(bundle, tMs, width) {
5444
+ const idx = frameIndexForTime(bundle.frames, tMs);
5445
+ const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
5446
+ return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
5447
+ }
5448
+ async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
5449
+ let idx = frameIndexForTime(bundle.frames, tMs);
5450
+ if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
5451
+ const meta = await sharp3(framePath(bundle, idx)).metadata();
5452
+ const fw = meta.width ?? 0;
5453
+ const fh = meta.height ?? 0;
5454
+ const x = Math.max(0, Math.round(region.x));
5455
+ const y = Math.max(0, Math.round(region.y));
5456
+ const w = Math.min(Math.round(region.w), fw - x);
5457
+ const h = Math.min(Math.round(region.h), fh - y);
5458
+ if (w < 4 || h < 4) return null;
5459
+ let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
5460
+ if (!color) img = img.grayscale();
5461
+ const { data } = await img.raw().toBuffer({ resolveWithObject: true });
5462
+ return data;
5463
+ }
5464
+ async function regionGray48(input, rect) {
5465
+ try {
5466
+ const img = sharp3(input);
5467
+ const meta = await img.metadata();
5468
+ const W = meta.width ?? 0;
5469
+ const H = meta.height ?? 0;
5470
+ const left = Math.max(0, Math.round(rect.x));
5471
+ const top = Math.max(0, Math.round(rect.y));
5472
+ const width = Math.min(W - left, Math.round(rect.w));
5473
+ const height = Math.min(H - top, Math.round(rect.h));
5474
+ if (width < 8 || height < 8) return null;
5475
+ return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
5476
+ } catch {
5477
+ return null;
5478
+ }
5479
+ }
5480
+ function normalizedCorrelation(a, b) {
5481
+ const n = Math.min(a.length, b.length);
5482
+ let ma = 0;
5483
+ let mb = 0;
5484
+ for (let i = 0; i < n; i++) {
5485
+ ma += a[i];
5486
+ mb += b[i];
5487
+ }
5488
+ ma /= n;
5489
+ mb /= n;
5490
+ let num = 0;
5491
+ let da = 0;
5492
+ let db = 0;
5493
+ for (let i = 0; i < n; i++) {
5494
+ const xa = a[i] - ma;
5495
+ const xb = b[i] - mb;
5496
+ num += xa * xb;
5497
+ da += xa * xa;
5498
+ db += xb * xb;
5499
+ }
5500
+ if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
5501
+ return num / Math.sqrt(da * db);
4579
5502
  }
4580
5503
 
4581
5504
  // src/compose/index.ts
@@ -4586,9 +5509,9 @@ init_schema();
4586
5509
  import { execFile } from "child_process";
4587
5510
  import { promisify } from "util";
4588
5511
  import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
4589
- import { existsSync as existsSync2 } from "fs";
4590
- import { join as join5 } from "path";
4591
- import { createHash as createHash2 } from "crypto";
5512
+ import { existsSync as existsSync3 } from "fs";
5513
+ import { join as join6 } from "path";
5514
+ import { createHash as createHash3 } from "crypto";
4592
5515
  import { createRequire as createRequire4 } from "module";
4593
5516
  var require3 = createRequire4(import.meta.url);
4594
5517
  var ffprobePath2 = require3("ffprobe-static").path;
@@ -4682,9 +5605,9 @@ async function synthesizeNarration(lines, opts, cacheDir) {
4682
5605
  const ext = provider.name === "kokoro" ? "wav" : "aiff";
4683
5606
  const out = /* @__PURE__ */ new Map();
4684
5607
  for (const { stepRef, text } of lines) {
4685
- const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
4686
- const file = join5(cacheDir, `${key}.${ext}`);
4687
- if (!existsSync2(file)) await provider.synth(text, file);
5608
+ const key = createHash3("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
5609
+ const file = join6(cacheDir, `${key}.${ext}`);
5610
+ if (!existsSync3(file)) await provider.synth(text, file);
4688
5611
  out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
4689
5612
  }
4690
5613
  return out;
@@ -4694,8 +5617,8 @@ async function synthesizeNarration(lines, opts, cacheDir) {
4694
5617
  import { execFile as execFile2 } from "child_process";
4695
5618
  import { promisify as promisify2 } from "util";
4696
5619
  import { rename, mkdir as mkdir3 } from "fs/promises";
4697
- import { existsSync as existsSync3 } from "fs";
4698
- import { join as join6 } from "path";
5620
+ import { existsSync as existsSync4 } from "fs";
5621
+ import { join as join7 } from "path";
4699
5622
  import { createRequire as createRequire5 } from "module";
4700
5623
  var require4 = createRequire5(import.meta.url);
4701
5624
  var ffmpegPath2 = require4("ffmpeg-static");
@@ -4703,8 +5626,9 @@ var exec2 = promisify2(execFile2);
4703
5626
  async function mixAudio(videoPath, steps, durationMs, opts) {
4704
5627
  const placed = (opts.narration ? steps : []).map((s) => ({ start: Math.round(s.outStart + AUDIO_LEAD_MS), clip: opts.narration.get(s.stepRef) })).filter((p) => p.clip !== void 0);
4705
5628
  const beats = opts.sfxBeats ?? [];
5629
+ const keyBeats = opts.keyBeats ?? [];
4706
5630
  const hasMusic = !!opts.music;
4707
- if (placed.length === 0 && beats.length === 0 && !hasMusic) return [];
5631
+ if (placed.length === 0 && beats.length === 0 && keyBeats.length === 0 && !hasMusic) return [];
4708
5632
  const durSec = (durationMs / 1e3).toFixed(3);
4709
5633
  const args = ["-y", "-loglevel", "error", "-i", videoPath];
4710
5634
  const inputs = [];
@@ -4742,6 +5666,21 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
4742
5666
  filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[sfx]`);
4743
5667
  inputs.push("[sfx]");
4744
5668
  }
5669
+ if (keyBeats.length > 0) {
5670
+ const key = await ensureKeySample(opts.cacheDir);
5671
+ args.push("-i", key);
5672
+ const idx = inputIdx++;
5673
+ const splits = keyBeats.map((_, i) => `[k${i}]`).join("");
5674
+ filters.push(`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,asplit=${keyBeats.length}${splits}`);
5675
+ const labels = [];
5676
+ keyBeats.forEach((t, i) => {
5677
+ const at = Math.max(0, Math.round(t));
5678
+ filters.push(`[k${i}]adelay=${at}|${at}[kk${i}]`);
5679
+ labels.push(`[kk${i}]`);
5680
+ });
5681
+ filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[keys]`);
5682
+ inputs.push("[keys]");
5683
+ }
4745
5684
  if (opts.music) {
4746
5685
  args.push("-stream_loop", "-1", "-i", opts.music.file);
4747
5686
  const idx = inputIdx++;
@@ -4785,10 +5724,28 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
4785
5724
  await rename(tmp, videoPath);
4786
5725
  return placed.map((p) => ({ stepRef: p.clip.stepRef, tStart: p.start, durMs: p.clip.durationMs }));
4787
5726
  }
5727
+ async function ensureKeySample(cacheDir) {
5728
+ await mkdir3(cacheDir, { recursive: true });
5729
+ const file = join7(cacheDir, "key.wav");
5730
+ if (existsSync4(file)) return file;
5731
+ await exec2(ffmpegPath2, [
5732
+ "-y",
5733
+ "-loglevel",
5734
+ "error",
5735
+ "-f",
5736
+ "lavfi",
5737
+ "-i",
5738
+ "sine=frequency=3400:duration=0.022",
5739
+ "-filter_complex",
5740
+ "[0]volume=0.12,afade=t=in:d=0.002,afade=t=out:st=0.008:d=0.014,aformat=sample_rates=44100:channel_layouts=stereo",
5741
+ file
5742
+ ]);
5743
+ return file;
5744
+ }
4788
5745
  async function ensureClickSample(cacheDir) {
4789
5746
  await mkdir3(cacheDir, { recursive: true });
4790
- const file = join6(cacheDir, "click.wav");
4791
- if (existsSync3(file)) return file;
5747
+ const file = join7(cacheDir, "click.wav");
5748
+ if (existsSync4(file)) return file;
4792
5749
  await exec2(ffmpegPath2, [
4793
5750
  "-y",
4794
5751
  "-loglevel",
@@ -4827,7 +5784,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4827
5784
  const resolvedTheme = theme ?? resolveTheme(spec);
4828
5785
  const kind = kindProfile(spec.output.kind);
4829
5786
  let prof = profile ?? profileForAspect(spec.output.aspect, resolveResolution(spec), spec.output.fps);
4830
- if (kind.stage && !prof.stage) prof = withStage(prof, true);
5787
+ if (kind.stage && !prof.stage) prof = withStage(prof, true, bundle.manifest.viewport.w / bundle.manifest.viewport.h);
4831
5788
  const diagnostics = { overflows: [], cameraFallbacks: [], clockWarnings: [] };
4832
5789
  const cfg = pacingConfig(spec.output.pacing ?? kind.pacing, kind);
4833
5790
  const dwell = spec.output.dwellScale;
@@ -4838,7 +5795,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4838
5795
  }
4839
5796
  const timeline = buildTimeline(bundle.events, cfg, audioMs);
4840
5797
  const END_CARD_MS = 2400;
4841
- if (spec.output.endCard) {
5798
+ if (spec.output.endCard && !bundle.manifest.partial) {
4842
5799
  const endStart = timeline.durationMs;
4843
5800
  timeline.segments.push({ kind: "card", outStart: endStart, outEnd: endStart + END_CARD_MS, cardId: "end" });
4844
5801
  timeline.durationMs += END_CARD_MS;
@@ -4865,8 +5822,9 @@ function plan(bundle, spec, theme, profile, audioMs) {
4865
5822
  const prev = timeline.steps[i - 1];
4866
5823
  const cur = timeline.steps[i];
4867
5824
  if (cur.sceneId !== prev.sceneId) {
4868
- const tStart = cur.outStart;
4869
- transitions.push({ tStart, tEnd: tStart + 280, srcFrom: Math.max(0, prev.srcSettled - 1) });
5825
+ const tStart = prev.outEnd;
5826
+ const tEnd = Math.min(cur.outStart + 120, tStart + 340);
5827
+ transitions.push({ tStart, tEnd: Math.max(tEnd, tStart + 200), srcFrom: Math.max(0, prev.srcSettled - 1) });
4870
5828
  }
4871
5829
  }
4872
5830
  const subtitle = spec.subtitle ?? safeHost(bundle.manifest.appUrl);
@@ -4929,46 +5887,109 @@ function plan(bundle, spec, theme, profile, audioMs) {
4929
5887
  bundleHashes: bundle.manifest.hashes
4930
5888
  };
4931
5889
  }
5890
+ var BLINK_DIFF_MIN = 25;
5891
+ async function excludeTransientPreActionFrames(bundle) {
5892
+ const notes = [];
5893
+ const dpr = bundle.manifest.dpr;
5894
+ const excluded = /* @__PURE__ */ new Set();
5895
+ for (const e of actionEvents(bundle.events)) {
5896
+ if (e.kind !== "click" && e.kind !== "dblclick") continue;
5897
+ if (!e.targetPre || !e.cursorPath || e.cursorPath.length === 0) continue;
5898
+ const tArrive = e.cursorPath[e.cursorPath.length - 1].t;
5899
+ if (!(tArrive < e.tAction)) continue;
5900
+ const iArrive = frameIndexForTime(bundle.frames, tArrive);
5901
+ const iBeat = frameIndexForTime(bundle.frames, e.tAction);
5902
+ if (iBeat <= iArrive) continue;
5903
+ const b = e.targetPre.bbox;
5904
+ const region = { x: b.x * dpr, y: b.y * dpr, w: b.w * dpr, h: b.h * dpr };
5905
+ const ref = await sourceRegionGray(bundle, bundle.frames[iArrive].t, region);
5906
+ if (!ref) continue;
5907
+ for (let i = iArrive + 1; i <= iBeat; i++) {
5908
+ const cand = await sourceRegionGray(bundle, bundle.frames[i].t, region);
5909
+ if (!cand || cand.length !== ref.length) continue;
5910
+ if (meanAbsDiff(ref, cand) > BLINK_DIFF_MIN) {
5911
+ excluded.add(i);
5912
+ notes.push(
5913
+ `${e.sceneId}/${e.stepIndex}: frame ${bundle.frames[i].f} (t=${Math.round(bundle.frames[i].t)}) is a pre-click transient (target region contradicts the arrival frame) \u2014 excluded from composition`
5914
+ );
5915
+ }
5916
+ }
5917
+ }
5918
+ if (excluded.size === 0) return { bundle, notes };
5919
+ return { bundle: { ...bundle, frames: bundle.frames.filter((_, i) => !excluded.has(i)) }, notes };
5920
+ }
5921
+ var JUMP_DIFF_MIN = 18;
5922
+ var JUMP_DISSOLVE_MS = 360;
5923
+ async function smoothJumpCuts(bundle, manifest) {
5924
+ for (const s of manifest.steps) {
5925
+ if (s.kind !== "click" && s.kind !== "dblclick" && s.kind !== "press" && s.kind !== "select") continue;
5926
+ const before = await sourceFrameGray(bundle, Math.max(0, s.srcAction - 30), 320);
5927
+ const after = await sourceFrameGray(bundle, Math.min(s.srcAction + 700, s.srcSettled), 320);
5928
+ if (after.frameT <= before.frameT || after.data.length !== before.data.length) continue;
5929
+ if (meanAbsDiff(before.data, after.data) < JUMP_DIFF_MIN) continue;
5930
+ const tStart = s.outBeat + 60;
5931
+ const tEnd = Math.min(tStart + JUMP_DISSOLVE_MS, s.outEnd);
5932
+ if ((manifest.transitions ?? []).some((w) => tStart <= w.tEnd && tEnd >= w.tStart)) continue;
5933
+ manifest.transitions = manifest.transitions ?? [];
5934
+ manifest.transitions.push({ tStart, tEnd, srcFrom: before.frameT });
5935
+ }
5936
+ }
4932
5937
  async function compose(bundle, spec, opts) {
4933
5938
  const audioCfg = spec.output.audio;
4934
5939
  let narration;
4935
5940
  let audioMs;
5941
+ let narrationDegraded;
5942
+ const voiceLabel = audioCfg?.voice ?? (audioCfg?.provider === "kokoro" ? "heart" : "Samantha");
4936
5943
  if (audioCfg && audioCfg.narration === "tts") {
4937
5944
  const texts = specStepTexts(spec);
4938
5945
  const lines = actionEvents(bundle.events).map((e) => {
4939
5946
  const ref = `${e.sceneId}/${e.stepIndex}`;
4940
5947
  return { stepRef: ref, text: writeNarration(e, texts.get(ref)) };
4941
5948
  }).filter((l) => typeof l.text === "string" && l.text.length > 0);
4942
- log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${audioCfg.voice ?? "Samantha"})`);
5949
+ log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${voiceLabel})`);
4943
5950
  try {
4944
- narration = await synthesizeNarration(lines, audioCfg, join7(opts.outDir, "narration"));
5951
+ narration = await synthesizeNarration(lines, audioCfg, join8(opts.outDir, "narration"));
4945
5952
  audioMs = new Map([...narration].map(([k, v]) => [k, v.durationMs]));
4946
5953
  } catch (e) {
4947
- log.warn(`narration unavailable on this host (${e.message.split("\n")[0]}) \u2014 rendering WITHOUT voice`);
5954
+ narrationDegraded = e.message.split("\n")[0] ?? "TTS unavailable";
5955
+ log.warn(`narration unavailable on this host (${narrationDegraded}) \u2014 rendering WITHOUT voice; the audio check will FAIL`);
4948
5956
  narration = void 0;
4949
5957
  audioMs = void 0;
4950
5958
  }
4951
5959
  }
5960
+ const transient = await excludeTransientPreActionFrames(bundle);
5961
+ bundle = transient.bundle;
4952
5962
  const manifest = plan(bundle, spec, opts.theme, opts.profile, audioMs);
5963
+ manifest.diagnostics.clockWarnings.push(...transient.notes);
5964
+ await smoothJumpCuts(bundle, manifest);
4953
5965
  for (const w of manifest.diagnostics.clockWarnings) log.warn(w);
4954
5966
  for (const f of manifest.diagnostics.cameraFallbacks) log.warn(f);
4955
- const videoPath = join7(opts.outDir, opts.fileName ?? "out.mp4");
5967
+ const videoPath = join8(opts.outDir, opts.fileName ?? "out.mp4");
4956
5968
  log.info(
4957
5969
  `composing ${(manifest.durationMs / 1e3).toFixed(1)}s (${manifest.totalFrames} frames @ ${manifest.profile.fps}fps, ${manifest.profile.width}x${manifest.profile.height})`
4958
5970
  );
4959
5971
  await renderVideo(bundle, manifest, videoPath);
4960
5972
  if (audioCfg && (narration || audioCfg.sfx || audioCfg.music)) {
5973
+ const keyBeats = [];
5974
+ if (audioCfg.sfx) {
5975
+ for (const s of manifest.steps) {
5976
+ if (s.kind !== "type") continue;
5977
+ const headEnd = Math.min(s.outEnd, s.outBeat + 1150);
5978
+ for (let t = s.outBeat + 40; t < headEnd; t += 78 + (t | 0) % 29) keyBeats.push(t);
5979
+ }
5980
+ }
4961
5981
  const clips = await mixAudio(videoPath, manifest.steps, manifest.durationMs, {
4962
5982
  ...narration ? { narration } : {},
4963
5983
  ...audioCfg.sfx ? { sfxBeats: manifest.ripples } : {},
5984
+ ...keyBeats.length > 0 ? { keyBeats } : {},
4964
5985
  ...audioCfg.music ? { music: audioCfg.music } : {},
4965
- cacheDir: join7(opts.outDir, "narration")
5986
+ cacheDir: join8(opts.outDir, "narration")
4966
5987
  });
4967
5988
  if (narration) {
4968
5989
  manifest.audio = {
4969
5990
  narrated: true,
4970
5991
  provider: audioCfg.provider,
4971
- voice: audioCfg.voice ?? "Samantha",
5992
+ voice: voiceLabel,
4972
5993
  lines: clips.length,
4973
5994
  clips
4974
5995
  };
@@ -4977,8 +5998,19 @@ async function compose(bundle, spec, opts) {
4977
5998
  `\u2713 audio: ${clips.length} narration line(s)${audioCfg.sfx ? `, ${manifest.ripples.length} click(s)` : ""}${audioCfg.music ? ", music bed" : ""} \u2192 ${videoPath}`
4978
5999
  );
4979
6000
  }
6001
+ if (narrationDegraded && !manifest.audio) {
6002
+ manifest.audio = {
6003
+ narrated: false,
6004
+ provider: audioCfg?.provider ?? "say",
6005
+ voice: voiceLabel,
6006
+ lines: 0,
6007
+ degraded: narrationDegraded
6008
+ };
6009
+ } else if (narrationDegraded && manifest.audio) {
6010
+ manifest.audio.degraded = narrationDegraded;
6011
+ }
4980
6012
  manifest.videoSha256 = await sha256File(videoPath);
4981
- const manifestPath = join7(opts.outDir, "compose-manifest.json");
6013
+ const manifestPath = join8(opts.outDir, "compose-manifest.json");
4982
6014
  await writeFile4(manifestPath, JSON.stringify(manifest, null, 2));
4983
6015
  return { videoPath, manifestPath, manifest };
4984
6016
  }
@@ -4991,158 +6023,11 @@ function safeHost(url) {
4991
6023
  }
4992
6024
 
4993
6025
  // src/verify/runner.ts
4994
- import { join as join8 } from "path";
6026
+ import { join as join9 } from "path";
4995
6027
  import { mkdir as mkdir4, writeFile as writeFile6, readFile as readFile4 } from "fs/promises";
4996
6028
 
4997
6029
  // src/verify/checks.ts
4998
6030
  init_geometry();
4999
-
5000
- // src/verify/media.ts
5001
- import sharp3 from "sharp";
5002
- async function extractGrayFrames(videoPath, fps, w, h) {
5003
- const res = await runBinaryRaw(ffmpegPath(), [
5004
- "-hide_banner",
5005
- "-loglevel",
5006
- "error",
5007
- "-i",
5008
- videoPath,
5009
- "-vf",
5010
- `fps=${fps},scale=${w}:${h}`,
5011
- "-f",
5012
- "rawvideo",
5013
- "-pix_fmt",
5014
- "gray",
5015
- "pipe:1"
5016
- ]);
5017
- if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
5018
- const frameSize = w * h;
5019
- const frames = [];
5020
- for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
5021
- frames.push(res.stdout.subarray(off, off + frameSize));
5022
- }
5023
- return { frames, intervalMs: 1e3 / fps };
5024
- }
5025
- async function extractFrameAt(videoPath, tMs) {
5026
- const res = await runBinaryRaw(ffmpegPath(), [
5027
- "-hide_banner",
5028
- "-loglevel",
5029
- "error",
5030
- "-ss",
5031
- (Math.max(0, tMs) / 1e3).toFixed(3),
5032
- "-i",
5033
- videoPath,
5034
- "-frames:v",
5035
- "1",
5036
- "-f",
5037
- "image2pipe",
5038
- "-vcodec",
5039
- "png",
5040
- "pipe:1"
5041
- ]);
5042
- if (res.code !== 0 || res.stdout.length === 0) {
5043
- throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
5044
- }
5045
- return res.stdout;
5046
- }
5047
- async function probeVideo(videoPath) {
5048
- const res = await runBinary(ffprobePath(), [
5049
- "-v",
5050
- "error",
5051
- "-print_format",
5052
- "json",
5053
- "-show_format",
5054
- "-show_streams",
5055
- videoPath
5056
- ]);
5057
- if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
5058
- const json = JSON.parse(res.stdout);
5059
- const v = json.streams?.find((s) => s.codec_type === "video");
5060
- if (!v) throw new Error("no video stream found");
5061
- const [num, den] = v.avg_frame_rate.split("/").map(Number);
5062
- return {
5063
- codec: v.codec_name,
5064
- width: v.width,
5065
- height: v.height,
5066
- durationSec: Number(json.format?.duration ?? 0),
5067
- fps: den ? num / den : 0
5068
- };
5069
- }
5070
- function meanAbsDiff(a, b) {
5071
- const n = Math.min(a.length, b.length);
5072
- let sum = 0;
5073
- for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
5074
- return sum / n;
5075
- }
5076
- function variance(a) {
5077
- let sum = 0;
5078
- for (let i = 0; i < a.length; i++) sum += a[i];
5079
- const mean = sum / a.length;
5080
- let v = 0;
5081
- for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
5082
- return v / a.length;
5083
- }
5084
- async function sourceFrameGray(bundle, tMs, width) {
5085
- const idx = frameIndexForTime(bundle.frames, tMs);
5086
- const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
5087
- return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
5088
- }
5089
- async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
5090
- let idx = frameIndexForTime(bundle.frames, tMs);
5091
- if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
5092
- const meta = await sharp3(framePath(bundle, idx)).metadata();
5093
- const fw = meta.width ?? 0;
5094
- const fh = meta.height ?? 0;
5095
- const x = Math.max(0, Math.round(region.x));
5096
- const y = Math.max(0, Math.round(region.y));
5097
- const w = Math.min(Math.round(region.w), fw - x);
5098
- const h = Math.min(Math.round(region.h), fh - y);
5099
- if (w < 4 || h < 4) return null;
5100
- let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
5101
- if (!color) img = img.grayscale();
5102
- const { data } = await img.raw().toBuffer({ resolveWithObject: true });
5103
- return data;
5104
- }
5105
- async function regionGray48(input, rect) {
5106
- try {
5107
- const img = sharp3(input);
5108
- const meta = await img.metadata();
5109
- const W = meta.width ?? 0;
5110
- const H = meta.height ?? 0;
5111
- const left = Math.max(0, Math.round(rect.x));
5112
- const top = Math.max(0, Math.round(rect.y));
5113
- const width = Math.min(W - left, Math.round(rect.w));
5114
- const height = Math.min(H - top, Math.round(rect.h));
5115
- if (width < 8 || height < 8) return null;
5116
- return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
5117
- } catch {
5118
- return null;
5119
- }
5120
- }
5121
- function normalizedCorrelation(a, b) {
5122
- const n = Math.min(a.length, b.length);
5123
- let ma = 0;
5124
- let mb = 0;
5125
- for (let i = 0; i < n; i++) {
5126
- ma += a[i];
5127
- mb += b[i];
5128
- }
5129
- ma /= n;
5130
- mb /= n;
5131
- let num = 0;
5132
- let da = 0;
5133
- let db = 0;
5134
- for (let i = 0; i < n; i++) {
5135
- const xa = a[i] - ma;
5136
- const xb = b[i] - mb;
5137
- num += xa * xb;
5138
- da += xa * xa;
5139
- db += xb * xb;
5140
- }
5141
- if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
5142
- return num / Math.sqrt(da * db);
5143
- }
5144
-
5145
- // src/verify/checks.ts
5146
6031
  var EFFECT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "type", "select", "goto"]);
5147
6032
  var CLICK_KINDS2 = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
5148
6033
  function plane(ctx2) {
@@ -5221,7 +6106,47 @@ async function checkCursorOnTarget(ctx2) {
5221
6106
  }
5222
6107
  }
5223
6108
  if (checked === 0) return { id: "cursor-on-target", status: "skip", details: "no click steps" };
5224
- return evidence.length > 0 ? { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence } : { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target` };
6109
+ if (evidence.length > 0)
6110
+ return { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence };
6111
+ const p = plane(ctx2);
6112
+ const content = stageContent(ctx2.manifest.profile);
6113
+ const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
6114
+ const clickSteps = ctx2.manifest.steps.filter((s) => CLICK_KINDS2.has(s.kind) && s.targetRectVp);
6115
+ let sampled = 0;
6116
+ let present = 0;
6117
+ for (const step of clickSteps.slice(0, 2)) {
6118
+ const t = step.outBeat;
6119
+ const pos = sampleCursor(cursorPlan, t);
6120
+ const boxVp = { x: pos.x - 16, y: pos.y - 16, w: 32, h: 32 };
6121
+ const cam = sampleCamera(ctx2.manifest.camera, t);
6122
+ const proj = projectRect(boxVp, cam, p);
6123
+ const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
6124
+ const { srcAt } = sampleSource(ctx2.manifest.segments, t);
6125
+ if (srcAt === null) continue;
6126
+ const outPng = await extractFrameAt(ctx2.videoPath, t).catch(() => null);
6127
+ if (!outPng) continue;
6128
+ const [outRegion, srcRegion] = await Promise.all([
6129
+ regionGray48(outPng, projFrame),
6130
+ regionGray48(framePath(ctx2.bundle, frameIndexForTime(ctx2.bundle.frames, srcAt)), {
6131
+ x: boxVp.x * frameScale,
6132
+ y: boxVp.y * frameScale,
6133
+ w: boxVp.w * frameScale,
6134
+ h: boxVp.h * frameScale
6135
+ })
6136
+ ]);
6137
+ if (!outRegion || !srcRegion) continue;
6138
+ sampled += 1;
6139
+ if (meanAbsDiff(outRegion, srcRegion) > 6) present += 1;
6140
+ }
6141
+ if (sampled > 0 && present === 0) {
6142
+ return {
6143
+ id: "cursor-on-target",
6144
+ status: "fail",
6145
+ details: `cursor sprite NOT FOUND in the output pixels at ${sampled} sampled click beat(s) \u2014 the plan says it's there, the film disagrees`
6146
+ };
6147
+ }
6148
+ const renderedNote = sampled > 0 ? `; sprite verified on film at ${present}/${sampled} sampled beat(s)` : "";
6149
+ return { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target${renderedNote}` };
5225
6150
  }
5226
6151
  async function checkActionEffect(ctx2) {
5227
6152
  const evidence = [];
@@ -5279,12 +6204,14 @@ async function checkActionEffect(ctx2) {
5279
6204
  }
5280
6205
  const maskedNote = skippedMasked > 0 ? ` (${skippedMasked} masked skipped)` : "";
5281
6206
  const inconcNote = inconclusive.length > 0 ? `, ${inconclusive.length} inconclusive (capture gap)` : "";
6207
+ const coverage = { verified: checked - evidence.length - inconclusive.length, total: checked + skippedMasked };
5282
6208
  if (evidence.length > 0) {
5283
6209
  return {
5284
6210
  id: "action-effect",
5285
6211
  status: "fail",
5286
6212
  details: `${evidence.length}/${checked} actions show no visible effect${maskedNote}${inconcNote}`,
5287
- evidence: [...evidence, ...inconclusive]
6213
+ evidence: [...evidence, ...inconclusive],
6214
+ coverage
5288
6215
  };
5289
6216
  }
5290
6217
  if (inconclusive.length > 0) {
@@ -5292,10 +6219,11 @@ async function checkActionEffect(ctx2) {
5292
6219
  id: "action-effect",
5293
6220
  status: "warn",
5294
6221
  details: `${checked - inconclusive.length}/${checked} actions visibly effective; ${inconclusive.length} unverified (capture gap)${maskedNote}`,
5295
- evidence: inconclusive
6222
+ evidence: inconclusive,
6223
+ coverage
5296
6224
  };
5297
6225
  }
5298
- return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}` };
6226
+ return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}`, coverage };
5299
6227
  }
5300
6228
  async function checkFrozenBlank(ctx2) {
5301
6229
  const { frames, intervalMs } = await extractGrayFrames(ctx2.videoPath, 2, 320, 180);
@@ -5303,11 +6231,23 @@ async function checkFrozenBlank(ctx2) {
5303
6231
  const evidence = [];
5304
6232
  const dips = ctx2.manifest.overlays.filter((o) => o.kind === "dip");
5305
6233
  const inTransition = (t) => segmentAt(ctx2.manifest.segments, t).kind === "card" || dips.some((d) => t >= d.tStart - 60 && t <= d.tEnd + 60);
6234
+ const centerBlank = [];
6235
+ const W = 320;
6236
+ const H = 180;
5306
6237
  for (let i = 0; i < frames.length; i++) {
5307
6238
  const t = i * intervalMs;
5308
6239
  if (inTransition(t)) continue;
5309
6240
  if (variance(frames[i]) < 2) {
5310
6241
  evidence.push({ t, note: `blank frame at ${(t / 1e3).toFixed(1)}s` });
6242
+ continue;
6243
+ }
6244
+ const f = frames[i];
6245
+ const center = [];
6246
+ for (let y = Math.floor(H * 0.3); y < Math.floor(H * 0.7); y++) {
6247
+ for (let x = Math.floor(W * 0.25); x < Math.floor(W * 0.75); x++) center.push(f[y * W + x]);
6248
+ }
6249
+ if (variance(Buffer.from(center)) < 3) {
6250
+ centerBlank.push({ t, note: `content region blank at ${(t / 1e3).toFixed(1)}s \u2014 still loading? Add a wait: { for: \u2026 } step so hydration never gets filmed` });
5311
6251
  }
5312
6252
  }
5313
6253
  const maskedWindows = ctx2.manifest.steps.filter((s) => s.masked).map((s) => ({ start: s.outStart, end: s.outEnd }));
@@ -5342,7 +6282,18 @@ async function checkFrozenBlank(ctx2) {
5342
6282
  runStart = i;
5343
6283
  }
5344
6284
  }
5345
- return evidence.length > 0 ? { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence } : { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
6285
+ if (evidence.length > 0) {
6286
+ return { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence: [...evidence, ...centerBlank] };
6287
+ }
6288
+ if (centerBlank.length > 0) {
6289
+ return {
6290
+ id: "frozen-blank",
6291
+ status: "warn",
6292
+ details: `frames live, but ${centerBlank.length} sampled frame(s) have a blank content region (mid-hydration footage?)`,
6293
+ evidence: centerBlank
6294
+ };
6295
+ }
6296
+ return { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
5346
6297
  }
5347
6298
  async function checkCaptions(ctx2) {
5348
6299
  const evidence = [];
@@ -5353,7 +6304,7 @@ async function checkCaptions(ctx2) {
5353
6304
  if (o.kind !== "caption") continue;
5354
6305
  count += 1;
5355
6306
  if (o.truncated) evidence.push({ stepRef: o.stepRef, note: `caption truncated: "${o.text}"` });
5356
- if (o.fontPx < ctx2.manifest.theme.captionMinFontPx)
6307
+ if (o.fontPx < Math.round(ctx2.manifest.theme.captionMinFontPx * ctx2.manifest.profile.uiScale))
5357
6308
  evidence.push({ stepRef: o.stepRef, note: `caption font ${o.fontPx}px below minimum` });
5358
6309
  if (!rectContains(out, o.box, 24)) evidence.push({ stepRef: o.stepRef, note: "caption box outside safe bounds" });
5359
6310
  if (o.shrunk && !o.truncated) warned += 1;
@@ -5361,7 +6312,35 @@ async function checkCaptions(ctx2) {
5361
6312
  if (count === 0) return { id: "captions", status: "skip", details: "no captions" };
5362
6313
  if (evidence.length > 0)
5363
6314
  return { id: "captions", status: "fail", details: `${evidence.length} caption problem(s)`, evidence };
5364
- return warned > 0 ? { id: "captions", status: "warn", details: `${count} captions ok, ${warned} auto-shrunk to fit` } : { id: "captions", status: "pass", details: `${count} captions fit at full size` };
6315
+ const caps = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
6316
+ const stride = Math.max(1, Math.floor(caps.length / 3));
6317
+ let sampled = 0;
6318
+ let drawn = 0;
6319
+ for (let i = 0; i < caps.length && sampled < 3; i += stride) {
6320
+ const c = caps[i];
6321
+ const tOn = (c.tStart + c.tEnd) / 2;
6322
+ const tOff = c.tEnd + 500;
6323
+ const clashes = caps.some((o) => o !== c && tOff >= o.tStart - 100 && tOff <= o.tEnd + 100);
6324
+ if (clashes || tOff >= ctx2.manifest.durationMs - 200) continue;
6325
+ const [onPng, offPng] = await Promise.all([
6326
+ extractFrameAt(ctx2.videoPath, tOn).catch(() => null),
6327
+ extractFrameAt(ctx2.videoPath, tOff).catch(() => null)
6328
+ ]);
6329
+ if (!onPng || !offPng) continue;
6330
+ const [ra, rb] = await Promise.all([regionGray48(onPng, c.box), regionGray48(offPng, c.box)]);
6331
+ if (!ra || !rb) continue;
6332
+ sampled += 1;
6333
+ if (meanAbsDiff(ra, rb) > 4) drawn += 1;
6334
+ }
6335
+ if (sampled > 0 && drawn === 0) {
6336
+ return {
6337
+ id: "captions",
6338
+ status: "fail",
6339
+ details: `captions planned but NOT FOUND in the output pixels (${sampled} sampled boxes identical with and without caption)`
6340
+ };
6341
+ }
6342
+ const renderedNote = sampled > 0 ? `; ${drawn}/${sampled} sampled on film` : "";
6343
+ return warned > 0 ? { id: "captions", status: "warn", details: `${count} captions ok, ${warned} auto-shrunk to fit${renderedNote}` } : { id: "captions", status: "pass", details: `${count} captions fit at full size${renderedNote}` };
5365
6344
  }
5366
6345
  async function checkPacing(ctx2) {
5367
6346
  const evidence = [];
@@ -5377,7 +6356,20 @@ async function checkPacing(ctx2) {
5377
6356
  return evidence.length > 0 ? { id: "pacing", status: "fail", details: `${evidence.length} pacing violation(s)`, evidence } : { id: "pacing", status: "pass", details: `${ctx2.manifest.steps.length} steps paced \u22651.2s, total ${(total / 1e3).toFixed(1)}s` };
5378
6357
  }
5379
6358
  async function checkAudio(ctx2) {
5380
- if (!ctx2.manifest.audio?.narrated) return { id: "audio", status: "skip", details: "no narration" };
6359
+ if (ctx2.manifest.audio?.degraded) {
6360
+ return {
6361
+ id: "audio",
6362
+ status: "fail",
6363
+ details: `narration was requested but degraded to silence: ${ctx2.manifest.audio.degraded}`
6364
+ };
6365
+ }
6366
+ if (!ctx2.manifest.audio?.narrated) {
6367
+ return {
6368
+ id: "audio",
6369
+ status: "skip",
6370
+ details: "no narration \u2014 for a voice-over, set output.audio: { narration: tts } and re-compose"
6371
+ };
6372
+ }
5381
6373
  const evidence = [];
5382
6374
  const probe = await runBinary(ffprobePath(), [
5383
6375
  "-v",
@@ -5512,9 +6504,37 @@ async function checkMasks(ctx2) {
5512
6504
  if (evidence.length > 0) {
5513
6505
  return { id: "masks", status: "fail", details: `${evidence.length} mask violation(s)`, evidence };
5514
6506
  }
6507
+ const p = plane(ctx2);
6508
+ const content = stageContent(ctx2.manifest.profile);
6509
+ const solidSamples = ctx2.bundle.maskSamples.flatMap((s) => s.rects.filter((r) => r.style === "solid").map((r) => ({ t: s.t, r }))).filter((_, i, arr) => i % Math.max(1, Math.floor(arr.length / 3)) === 0).slice(0, 3);
6510
+ let outSampled = 0;
6511
+ let outCovered = 0;
6512
+ for (const s of solidSamples) {
6513
+ const tOut = outTimeForSrc(ctx2.manifest.segments, s.t);
6514
+ if (tOut === null || tOut >= ctx2.manifest.durationMs - 100) continue;
6515
+ const cam = sampleCamera(ctx2.manifest.camera, tOut);
6516
+ const rectVp = { x: s.r.x / frameScale, y: s.r.y / frameScale, w: s.r.w / frameScale, h: s.r.h / frameScale };
6517
+ const proj = projectRect(rectVp, cam, p);
6518
+ const projFrame = { x: proj.x + content.x + 4, y: proj.y + content.y + 4, w: proj.w - 8, h: proj.h - 8 };
6519
+ if (projFrame.w < 12 || projFrame.h < 12) continue;
6520
+ const png = await extractFrameAt(ctx2.videoPath, tOut).catch(() => null);
6521
+ if (!png) continue;
6522
+ const region = await regionGray48(png, projFrame);
6523
+ if (!region) continue;
6524
+ outSampled += 1;
6525
+ if (Math.sqrt(variance(region)) < 16) outCovered += 1;
6526
+ }
6527
+ if (outSampled > 0 && outCovered === 0) {
6528
+ return {
6529
+ id: "masks",
6530
+ status: "fail",
6531
+ details: `masked regions verified in SOURCE frames but NOT covered in the delivered video (${outSampled} output sample(s) show content)`
6532
+ };
6533
+ }
5515
6534
  const parts = [];
5516
6535
  if (buckets.size > 0) parts.push(`${buckets.size} solid region(s) verified opaque`);
5517
6536
  if (blurKeys.size > 0) parts.push(`${blurKeys.size} blurred region(s) applied`);
6537
+ if (outSampled > 0) parts.push(`${outCovered}/${outSampled} re-verified in the output video`);
5518
6538
  return { id: "masks", status: "pass", details: `${parts.join(", ")}; log clean` };
5519
6539
  }
5520
6540
  function scaleRect(r, s) {
@@ -5572,7 +6592,7 @@ async function checkOutputPixels(ctx2) {
5572
6592
  }
5573
6593
  }
5574
6594
  if (checked === 0) return { id: "output-pixels", status: "skip", details: "no comparable samples (captions overlapped or frames unavailable)" };
5575
- return evidence.length > 0 ? { id: "output-pixels", status: "fail", details: `${evidence.length}/${checked} sampled regions diverge from the plan`, evidence } : { id: "output-pixels", status: "pass", details: `${checked} sampled output regions match their planned source` };
6595
+ return evidence.length > 0 ? { id: "output-pixels", status: "fail", details: `${evidence.length}/${checked} sampled regions diverge from the plan`, evidence, coverage: { verified: checked - evidence.length, total: checked } } : { id: "output-pixels", status: "pass", details: `${checked} sampled output regions match their planned source`, coverage: { verified: checked, total: checked } };
5576
6596
  }
5577
6597
 
5578
6598
  // src/verify/contact-sheet.ts
@@ -5721,13 +6741,17 @@ function extractJson(text) {
5721
6741
  }
5722
6742
 
5723
6743
  // src/verify/report.ts
5724
- import { createHmac } from "crypto";
6744
+ import { createHmac, createHash as createHash4, sign as edSign, verify as edVerify, createPrivateKey, createPublicKey } from "crypto";
6745
+ import { readFileSync as readFileSync2 } from "fs";
5725
6746
  function signablePayload(v) {
5726
6747
  return JSON.stringify({
5727
6748
  schema: v.schema,
5728
6749
  verdict: v.verdict,
5729
6750
  videoSha256: v.video.sha256,
6751
+ manifestHash: v.manifestHash,
6752
+ contactSheetSha256: v.contactSheetSha256,
5730
6753
  bundleHashes: { events: v.bundleHashes.events, framesIndex: v.bundleHashes.framesIndex },
6754
+ coverage: v.coverage,
5731
6755
  provenance: {
5732
6756
  playheadVersion: v.provenance.playheadVersion,
5733
6757
  specHash: v.provenance.specHash,
@@ -5736,23 +6760,54 @@ function signablePayload(v) {
5736
6760
  verifiedAt: v.provenance.verifiedAt,
5737
6761
  checkSuiteVersion: v.provenance.checkSuiteVersion,
5738
6762
  host: v.provenance.host,
5739
- partial: v.provenance.partial ?? false
6763
+ partial: v.provenance.partial ?? false,
6764
+ failure: v.provenance.failure ?? null
5740
6765
  },
5741
- checks: v.checks.map((c) => ({ id: c.id, status: c.status }))
6766
+ checks: v.checks.map((c) => ({
6767
+ id: c.id,
6768
+ status: c.status,
6769
+ details: c.details,
6770
+ evidence: (c.evidence ?? []).map((e) => ({ stepRef: e.stepRef ?? null, t: e.t ?? null, note: e.note }))
6771
+ })),
6772
+ vision: v.vision
5742
6773
  });
5743
6774
  }
5744
- function signVerdict(v, key = process.env.PLAYHEAD_SIGNING_KEY) {
5745
- if (!key) return null;
5746
- return createHmac("sha256", key).update(signablePayload(v)).digest("hex");
6775
+ function signVerdict(v, env = {
6776
+ ...process.env.PLAYHEAD_SIGNING_KEY ? { hmacKey: process.env.PLAYHEAD_SIGNING_KEY } : {},
6777
+ ...process.env.PLAYHEAD_SIGNING_KEY_FILE ? { keyFile: process.env.PLAYHEAD_SIGNING_KEY_FILE } : {}
6778
+ }) {
6779
+ const payload = Buffer.from(signablePayload(v));
6780
+ if (env.keyFile) {
6781
+ const privateKey = createPrivateKey(readFileSync2(env.keyFile, "utf8"));
6782
+ const publicKey = createPublicKey(privateKey);
6783
+ const spki = publicKey.export({ type: "spki", format: "der" });
6784
+ return {
6785
+ alg: "Ed25519",
6786
+ keyId: keyIdFor(spki),
6787
+ publicKey: spki.toString("base64"),
6788
+ value: edSign(null, payload, privateKey).toString("base64")
6789
+ };
6790
+ }
6791
+ if (env.hmacKey) {
6792
+ return {
6793
+ alg: "HS256",
6794
+ keyId: keyIdFor(Buffer.from(env.hmacKey)),
6795
+ value: createHmac("sha256", env.hmacKey).update(payload).digest("hex")
6796
+ };
6797
+ }
6798
+ return null;
6799
+ }
6800
+ function keyIdFor(material) {
6801
+ return createHash4("sha256").update(material).digest("hex").slice(0, 16);
5747
6802
  }
5748
6803
 
5749
6804
  // src/verify/runner.ts
5750
6805
  init_log();
5751
6806
  import { platform, arch } from "os";
5752
6807
  import pc2 from "picocolors";
5753
- var CHECK_SUITE_VERSION = "playhead/checks@2";
6808
+ var CHECK_SUITE_VERSION = "playhead/checks@3";
5754
6809
  async function verify(bundle, manifest, videoPath, opts) {
5755
- const verifyDir = join8(opts.outDir, "verify");
6810
+ const verifyDir = join9(opts.outDir, "verify");
5756
6811
  await mkdir4(verifyDir, { recursive: true });
5757
6812
  const ctx2 = { bundle, manifest, videoPath, outDir: verifyDir };
5758
6813
  log.info("verifying output");
@@ -5776,7 +6831,7 @@ async function verify(bundle, manifest, videoPath, opts) {
5776
6831
  checks.push(result);
5777
6832
  report(result);
5778
6833
  }
5779
- const contactSheet = join8(verifyDir, "contact-sheet.png");
6834
+ const contactSheet = join9(verifyDir, "contact-sheet.png");
5780
6835
  await renderContactSheet(manifest, videoPath, contactSheet);
5781
6836
  log.ok(`contact sheet \u2192 ${contactSheet}`);
5782
6837
  let vision = null;
@@ -5787,11 +6842,28 @@ async function verify(bundle, manifest, videoPath, opts) {
5787
6842
  vision = review;
5788
6843
  }
5789
6844
  const failed = checks.some((c) => c.status === "fail");
6845
+ const effectCoverage = checks.find((c) => c.id === "action-effect")?.coverage;
6846
+ const pixelCoverage = checks.find((c) => c.id === "output-pixels")?.coverage;
6847
+ const coverage = {
6848
+ actionsVerified: effectCoverage?.verified ?? 0,
6849
+ actionsTotal: effectCoverage?.total ?? 0,
6850
+ outputSamplesVerified: pixelCoverage?.verified ?? 0,
6851
+ checksSkipped: checks.filter((c) => c.status === "skip").length,
6852
+ checksWarned: checks.filter((c) => c.status === "warn").length
6853
+ };
5790
6854
  const unsigned = {
5791
- schema: "playhead/verdict@2",
6855
+ schema: "playhead/verdict@3",
5792
6856
  verdict: failed ? "not-publishable" : "publishable",
5793
- video: { path: videoPath, sha256: manifest.videoSha256 ?? await sha256File(videoPath) },
6857
+ // ALWAYS hash the file the checks actually ran against — never trust the manifest's claim
6858
+ // (an unauthenticated JSON from disk). Round-2 audit: a doctored manifest could bind the
6859
+ // signed verdict to a video that was never verified.
6860
+ video: { path: videoPath, sha256: await sha256File(videoPath) },
5794
6861
  bundleHashes: manifest.bundleHashes,
6862
+ // The compose manifest is the oracle for the plan-side checks — hash it into the verdict
6863
+ // so a doctored manifest can no longer mint passes undetected.
6864
+ manifestHash: sha256Json(manifest),
6865
+ contactSheetSha256: await sha256File(contactSheet),
6866
+ coverage,
5795
6867
  provenance: {
5796
6868
  playheadVersion: PLAYHEAD_VERSION,
5797
6869
  specHash: bundle.manifest.specHash,
@@ -5809,9 +6881,10 @@ async function verify(bundle, manifest, videoPath, opts) {
5809
6881
  };
5810
6882
  const verdict = { ...unsigned, signature: signVerdict(unsigned) };
5811
6883
  if (!verdict.signature) log.debug("verdict unsigned \u2014 set PLAYHEAD_SIGNING_KEY to sign");
5812
- await writeFile6(join8(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
5813
- if (failed) log.error(`verdict: NOT PUBLISHABLE`);
5814
- else log.ok(`verdict: publishable`);
6884
+ await writeFile6(join9(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
6885
+ const cov = coverage.actionsTotal > 0 ? ` (coverage: ${coverage.actionsVerified}/${coverage.actionsTotal} actions pixel-verified, ${coverage.outputSamplesVerified} output samples)` : " (coverage: no effectful actions)";
6886
+ if (failed) log.error(`verdict: NOT PUBLISHABLE${cov}`);
6887
+ else log.ok(`verdict: publishable${cov}`);
5815
6888
  return verdict;
5816
6889
  }
5817
6890
  function report(c) {
@@ -5842,11 +6915,16 @@ function buildServer() {
5842
6915
  description: "Launch the running web app and return a grounded catalog of addressable elements \u2014 each line is a Playhead locator validated to resolve uniquely against the real DOM. Use these locators verbatim when writing a spec. Only shows the CURRENT screen; call again after a navigation to see later screens.",
5843
6916
  inputSchema: {
5844
6917
  url: z2.string().describe("the running app URL, e.g. http://localhost:3000"),
5845
- viewport: dims.describe("viewport WxH")
6918
+ viewport: dims.describe("viewport WxH"),
6919
+ storageState: z2.string().optional().describe("session file from `playhead login` \u2014 REQUIRED for authenticated apps")
5846
6920
  }
5847
6921
  },
5848
- async ({ url, viewport }) => {
5849
- const snap = await exploreUrl(url, { viewport: parseDims(viewport), headless: true });
6922
+ async ({ url, viewport, storageState }) => {
6923
+ const snap = await exploreUrl(url, {
6924
+ viewport: parseDims(viewport),
6925
+ headless: true,
6926
+ ...storageState ? { storageStatePath: resolve(storageState) } : {}
6927
+ });
5850
6928
  return { content: [{ type: "text", text: formatCatalog(snap) }] };
5851
6929
  }
5852
6930
  );
@@ -5859,10 +6937,11 @@ function buildServer() {
5859
6937
  url: z2.string(),
5860
6938
  goal: z2.string().describe('what flow to demo, e.g. "create a new deal for Globex"'),
5861
6939
  outPath: z2.string().default("playhead.yaml").describe("where to write the spec"),
5862
- viewport: dims
6940
+ viewport: dims,
6941
+ storageState: z2.string().optional().describe("session file from `playhead login` for authenticated apps")
5863
6942
  }
5864
6943
  },
5865
- async ({ url, goal, outPath, viewport }) => {
6944
+ async ({ url, goal, outPath, viewport, storageState }) => {
5866
6945
  const path = isAbsolute(outPath) ? outPath : resolve(outPath);
5867
6946
  const { steps } = await authorSpec({
5868
6947
  url,
@@ -5870,7 +6949,8 @@ function buildServer() {
5870
6949
  outPath: path,
5871
6950
  viewport: parseDims(viewport),
5872
6951
  maxSteps: 40,
5873
- headless: true
6952
+ headless: true,
6953
+ ...storageState ? { storageStatePath: resolve(storageState) } : {}
5874
6954
  });
5875
6955
  const yaml = await readFile6(path, "utf8");
5876
6956
  return { content: [{ type: "text", text: `Wrote ${steps.length}-step spec to ${path}
@@ -5926,21 +7006,48 @@ ${formatValidateResult2(res)}` }],
5926
7006
  await mkdir5(out, { recursive: true });
5927
7007
  let parsedSpec;
5928
7008
  if (spec) {
5929
- const tmp = await mkdtemp(join10(tmpdir(), "playhead-spec-"));
5930
- const p = join10(tmp, "spec.yaml");
7009
+ const tmp = await mkdtemp(join11(tmpdir(), "playhead-spec-"));
7010
+ const p = join11(tmp, "spec.yaml");
5931
7011
  await writeFile7(p, spec);
5932
7012
  parsedSpec = parseSpec(spec);
5933
7013
  } else {
5934
7014
  parsedSpec = await loadSpec(resolve(specPath));
5935
7015
  }
5936
- const { bundleDir } = await capture(parsedSpec, { outDir: out, headless: true });
5937
- const bundle = await openBundle(bundleDir);
5938
- const composed = await compose(bundle, parsedSpec, { outDir: out });
5939
- const verdict = await verify(bundle, composed.manifest, composed.videoPath, { outDir: out, vision });
5940
- return {
5941
- content: [{ type: "text", text: renderVerdictReport(verdict, composed.videoPath, composed.manifestPath) }],
5942
- isError: verdict.verdict !== "publishable"
5943
- };
7016
+ try {
7017
+ const { bundleDir } = await capture(parsedSpec, { outDir: out, headless: true });
7018
+ const bundle = await openBundle(bundleDir);
7019
+ const composed = await compose(bundle, parsedSpec, { outDir: out });
7020
+ const verdict = await verify(bundle, composed.manifest, composed.videoPath, { outDir: out, vision });
7021
+ return {
7022
+ content: [{ type: "text", text: renderVerdictReport(verdict, composed.videoPath, composed.manifestPath) }],
7023
+ isError: verdict.verdict !== "publishable"
7024
+ };
7025
+ } catch (e) {
7026
+ const { FlowError: FlowError2 } = await Promise.resolve().then(() => (init_exit(), exit_exports));
7027
+ if (e instanceof FlowError2) {
7028
+ let clipLine = "";
7029
+ try {
7030
+ const bundle = await openBundle(join11(out, "capture"));
7031
+ const clip = await compose(bundle, parsedSpec, { outDir: out, fileName: "failure.mp4" });
7032
+ clipLine = `
7033
+ failure clip: ${clip.videoPath} (watch the flow up to the break)`;
7034
+ } catch {
7035
+ }
7036
+ const failure = await readFile6(join11(out, "capture", "failure.json"), "utf8").catch(() => null);
7037
+ return {
7038
+ content: [
7039
+ {
7040
+ type: "text",
7041
+ text: `FLOW FAILED \u2014 the app broke the scripted flow.
7042
+ ${failure ?? e.message}${clipLine}
7043
+ Fix the spec (or the app) and render again.`
7044
+ }
7045
+ ],
7046
+ isError: true
7047
+ };
7048
+ }
7049
+ throw e;
7050
+ }
5944
7051
  }
5945
7052
  );
5946
7053
  server.registerTool(