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/index.js CHANGED
@@ -9,10 +9,33 @@ import { z } from "zod";
9
9
  // src/spec/locators.ts
10
10
  var LocatorParseError = class extends Error {
11
11
  };
12
+ function splitSegments(raw) {
13
+ const parts = [];
14
+ let cur = "";
15
+ let quote = null;
16
+ for (let i = 0; i < raw.length; i++) {
17
+ const ch = raw[i];
18
+ if (quote) {
19
+ cur += ch;
20
+ if (ch === quote) quote = null;
21
+ } else if (ch === '"' || ch === "'") {
22
+ quote = ch;
23
+ cur += ch;
24
+ } else if (ch === ">" && raw[i + 1] === ">") {
25
+ parts.push(cur.trim());
26
+ cur = "";
27
+ i += 1;
28
+ } else {
29
+ cur += ch;
30
+ }
31
+ }
32
+ parts.push(cur.trim());
33
+ return parts;
34
+ }
12
35
  function parseLocator(input) {
13
36
  const raw = input.trim();
14
37
  let nth;
15
- const parts = raw.split(">>").map((p) => p.trim());
38
+ const parts = splitSegments(raw);
16
39
  const frames = [];
17
40
  while (parts.length > 0 && parts[0].startsWith("frame=")) {
18
41
  const sel = unquote(parts.shift().slice("frame=".length).trim());
@@ -122,7 +145,7 @@ var locatorString = z.string().min(1).superRefine((val, ctx2) => {
122
145
  }
123
146
  });
124
147
  var focusHint = z.union([z.literal("target"), z.literal("wide"), locatorString]);
125
- var EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout"];
148
+ var EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout", "optional"];
126
149
  var stepExtras = {
127
150
  caption: z.string().optional(),
128
151
  /** The SPOKEN line for TTS narration — unconstrained by the caption card's size. Resolution:
@@ -134,7 +157,10 @@ var stepExtras = {
134
157
  * scene structure (scenes stay narrative). */
135
158
  shot: z.enum(["cut", "continue"]).optional(),
136
159
  /** Per-step budget override (ms) for finding the target / meeting the expectation. */
137
- timeout: z.number().int().positive().optional()
160
+ timeout: z.number().int().positive().optional(),
161
+ /** A failing optional step is SKIPPED (warned, unfilmed beat) instead of killing the whole
162
+ * capture — for cookie banners, A/B'd tooltips, and other environment noise. */
163
+ optional: z.boolean().optional()
138
164
  };
139
165
  var targetOrShorthand = z.union([
140
166
  locatorString,
@@ -151,9 +177,15 @@ var typeStep = z.object({
151
177
  target: locatorString,
152
178
  text: z.string(),
153
179
  mask: z.boolean().default(false),
180
+ /** Select-all + overwrite instead of appending — editing a pre-filled field without this
181
+ * produces "Janenew value". */
182
+ clear: z.boolean().default(false),
154
183
  ...stepExtras
155
184
  }).strict();
156
185
  var pressStep = z.object({ action: z.literal("press"), keys: z.string(), ...stepExtras }).strict();
186
+ var rightclickStep = z.object({ action: z.literal("rightclick"), target: locatorString, ...stepExtras }).strict();
187
+ var uploadStep = z.object({ action: z.literal("upload"), target: locatorString, file: z.string(), ...stepExtras }).strict();
188
+ var dragStep = z.object({ action: z.literal("drag"), target: locatorString, to: locatorString, ...stepExtras }).strict();
157
189
  var selectStep = z.object({
158
190
  action: z.literal("select"),
159
191
  target: locatorString,
@@ -168,13 +200,24 @@ var scrollStep = z.object({
168
200
  }).strict();
169
201
  var expectStep = z.object({
170
202
  action: z.literal("expect"),
171
- target: locatorString,
203
+ target: locatorString.optional(),
172
204
  visible: z.boolean().optional(),
173
205
  text: z.string().optional(),
174
206
  /** Exact number of matching elements (e.g. rows in a filtered table). */
175
207
  count: z.number().int().min(0).optional(),
208
+ /** Current page URL must CONTAIN this substring (or match when wrapped /like this/). */
209
+ url: z.string().optional(),
210
+ /** Form control's current value. */
211
+ value: z.string().optional(),
212
+ /** Element enabled/disabled and checked state. */
213
+ disabled: z.boolean().optional(),
214
+ checked: z.boolean().optional(),
176
215
  ...stepExtras
177
- }).strict();
216
+ }).strict().refine((s) => s.target !== void 0 || s.url !== void 0, {
217
+ message: "expect needs a target locator (element assertions) and/or a url"
218
+ }).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, {
219
+ message: "element assertions (visible/text/count/value/disabled/checked) need a target"
220
+ });
178
221
  var waitStep = z.object({
179
222
  action: z.literal("wait"),
180
223
  ms: z.number().int().positive().optional(),
@@ -189,12 +232,15 @@ var stepSchema = z.discriminatedUnion("action", [
189
232
  pointerStep,
190
233
  typeStep,
191
234
  pressStep,
235
+ rightclickStep,
236
+ uploadStep,
237
+ dragStep,
192
238
  selectStep,
193
239
  scrollStep,
194
240
  expectStep,
195
241
  waitStep
196
242
  ]);
197
- var ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "select", "scroll", "expect", "wait"];
243
+ var ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "rightclick", "upload", "drag", "select", "scroll", "expect", "wait"];
198
244
  var authoredStep = z.record(z.string(), z.unknown()).superRefine((obj, ctx2) => {
199
245
  const actions = ACTION_KEYS.filter((k) => k in obj);
200
246
  if (actions.length !== 1) {
@@ -343,7 +389,10 @@ var specSchema = z.object({
343
389
  }).strict().refine(
344
390
  (a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
345
391
  { message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
346
- ).optional(),
392
+ ).refine((a) => a.provider !== "kokoro" || a.rate === void 0 || a.rate >= 80 && a.rate <= 140, {
393
+ 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",
394
+ path: ["rate"]
395
+ }).optional(),
347
396
  /** Closing card. When set, the video ends on a title-card-styled end card. */
348
397
  endCard: z.object({
349
398
  title: z.string().min(1),
@@ -362,13 +411,24 @@ var specSchema = z.object({
362
411
  style: z.enum(["solid", "blur"]).default("solid")
363
412
  }).strict()
364
413
  ).default([]),
414
+ /** Steps that run BEFORE recording starts and never appear on film — dismiss a cookie-consent
415
+ * banner, close a first-run tour, prime app state. Same step grammar as scenes. */
416
+ setup: z.array(authoredStep.pipe(stepSchema)).default([]),
365
417
  scenes: z.array(
366
418
  z.object({
367
419
  id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scene ids are lowercase kebab-case"),
368
420
  title: z.string().optional(),
369
421
  steps: z.array(authoredStep.pipe(stepSchema)).min(1)
370
422
  }).strict()
371
- ).min(1)
423
+ ).min(1).superRefine((scenes, ctx2) => {
424
+ const seen = /* @__PURE__ */ new Set();
425
+ scenes.forEach((s, i) => {
426
+ if (seen.has(s.id)) {
427
+ ctx2.addIssue({ code: "custom", path: [i, "id"], message: `duplicate scene id "${s.id}" \u2014 ids must be unique (check the extended base spec too)` });
428
+ }
429
+ seen.add(s.id);
430
+ });
431
+ })
372
432
  }).strict();
373
433
  var ASPECTS = {
374
434
  "16:9": { viewport: { w: 1280, h: 720 }, resolution: { w: 1920, h: 1080 } },
@@ -412,6 +472,7 @@ var KNOWN_KEYS = [
412
472
  "scenes",
413
473
  "vars",
414
474
  "extends",
475
+ "setup",
415
476
  "url",
416
477
  "viewport",
417
478
  "storageState",
@@ -482,7 +543,15 @@ var KNOWN_KEYS = [
482
543
  "for",
483
544
  "state",
484
545
  "x",
485
- "y"
546
+ "y",
547
+ "optional",
548
+ "clear",
549
+ "to",
550
+ "disabled",
551
+ "checked",
552
+ "rightclick",
553
+ "upload",
554
+ "drag"
486
555
  ];
487
556
  function suggest(key) {
488
557
  let best = null;
@@ -514,7 +583,13 @@ async function loadSpec(path) {
514
583
  const text = await readFile(resolvePath(path), "utf8");
515
584
  const lineCounter = new LineCounter();
516
585
  const doc = parseDocument(text, { lineCounter });
517
- return validateResolved(interpolate(merged, path), path, doc, lineCounter);
586
+ const leaf = doc.toJS() ?? {};
587
+ const arrLen = (v) => Array.isArray(v) ? v.length : 0;
588
+ const offsets = {
589
+ scenes: arrLen(merged.scenes) - arrLen(leaf.scenes),
590
+ masking: arrLen(merged.masking) - arrLen(leaf.masking)
591
+ };
592
+ return validateResolved(interpolate(merged, path), path, doc, lineCounter, offsets);
518
593
  }
519
594
  async function loadRaw(path, depth) {
520
595
  if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
@@ -553,6 +628,28 @@ function interpolate(raw, sourcePath) {
553
628
  for (const [name, value] of Object.entries(varsIn)) {
554
629
  vars.set(name, resolveEnv(String(value), sourcePath));
555
630
  }
631
+ for (let pass = 0; pass < 6; pass++) {
632
+ let changed = false;
633
+ for (const [name, value] of vars) {
634
+ const next = value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (m, ref) => {
635
+ if (ref === name) throw new SpecError(`${sourcePath}: variable {{${name}}} references itself`);
636
+ const v = vars.get(ref);
637
+ return v !== void 0 && !v.includes(`{{${name}}}`) ? v : m;
638
+ });
639
+ if (next !== value) {
640
+ vars.set(name, next);
641
+ changed = true;
642
+ }
643
+ }
644
+ if (!changed) break;
645
+ if (pass === 5) throw new SpecError(`${sourcePath}: variable references did not resolve after 6 passes \u2014 circular vars?`);
646
+ }
647
+ for (const [name, value] of vars) {
648
+ const m = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(value);
649
+ if (m && vars.has(m[1])) {
650
+ throw new SpecError(`${sourcePath}: circular variable reference \u2014 {{${name}}} and {{${m[1]}}} depend on each other`);
651
+ }
652
+ }
556
653
  const doc = { ...raw };
557
654
  delete doc.vars;
558
655
  const seen = (s) => {
@@ -584,18 +681,18 @@ function resolveEnv(s, sourcePath) {
584
681
  throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
585
682
  });
586
683
  }
587
- function validateResolved(resolved, sourcePath, doc, lineCounter) {
684
+ function validateResolved(resolved, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
588
685
  const result = specSchema.safeParse(resolved);
589
686
  if (!result.success) {
590
- const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter));
687
+ const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter, offsets));
591
688
  throw new SpecError(`${sourcePath}: invalid spec
592
689
  - ${issues.join("\n - ")}`, issues);
593
690
  }
594
691
  return result.data;
595
692
  }
596
- function formatIssue(iss, sourcePath, doc, lineCounter) {
693
+ function formatIssue(iss, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
597
694
  const where = iss.path.length ? humanPath(iss.path) : "spec";
598
- const pos = positionOf(iss.path, doc, lineCounter);
695
+ const pos = positionOf(iss.path, doc, lineCounter, offsets);
599
696
  const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
600
697
  if (iss.code === "unrecognized_keys") {
601
698
  const keys = iss.keys;
@@ -604,7 +701,15 @@ function formatIssue(iss, sourcePath, doc, lineCounter) {
604
701
  }
605
702
  return `${at}${where}: ${iss.message}`;
606
703
  }
607
- function positionOf(path, doc, lineCounter) {
704
+ function positionOf(path, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
705
+ if ((path[0] === "scenes" || path[0] === "masking") && typeof path[1] === "number") {
706
+ const off = offsets[path[0]];
707
+ if (off > 0) {
708
+ const adjusted = path[1] - off;
709
+ if (adjusted < 0) return null;
710
+ path = [path[0], adjusted, ...path.slice(2)];
711
+ }
712
+ }
608
713
  for (let depth = path.length; depth > 0; depth--) {
609
714
  try {
610
715
  const node = doc.getIn(path.slice(0, depth), true);
@@ -622,7 +727,7 @@ function humanPath(path) {
622
727
  }
623
728
 
624
729
  // src/capture/executor.ts
625
- import { join as join2 } from "path";
730
+ import { join as join3 } from "path";
626
731
 
627
732
  // src/capture/playwright-driver.ts
628
733
  import { chromium } from "playwright";
@@ -682,7 +787,23 @@ var log = {
682
787
  }
683
788
  };
684
789
 
790
+ // src/shared/exit.ts
791
+ var EXIT = {
792
+ OK: 0,
793
+ FLOW: 1,
794
+ QUALITY: 2,
795
+ INFRA: 3,
796
+ USAGE: 4
797
+ };
798
+ var FlowError = class extends Error {
799
+ exitCode = EXIT.FLOW;
800
+ };
801
+ var InfraError = class extends Error {
802
+ exitCode = EXIT.INFRA;
803
+ };
804
+
685
805
  // src/capture/playwright-driver.ts
806
+ import { access } from "fs/promises";
686
807
  var INPUT_TIMEOUT_MS = 5e3;
687
808
  var MASK_INIT_SCRIPT = `
688
809
  (() => {
@@ -725,11 +846,24 @@ var MASK_INIT_SCRIPT = `
725
846
  // is tagged the frame it appears \u2014 not seconds later at the next step boundary. Node-side
726
847
  // tagging remains the backstop for rules needing Playwright semantics (role/label/text).
727
848
  let scanTick = 0;
849
+ // querySelectorAll does NOT pierce shadow roots \u2014 but Playwright's tagging does, so a masked
850
+ // element inside a web component (any design system) would be tagged yet never overlaid,
851
+ // filming the secret while masks.json claims coverage (round-2 audit). Walk shadow roots too.
852
+ const deepQuery = (sel) => {
853
+ const out = [];
854
+ const walk = (root) => {
855
+ try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch (e) {}
856
+ const all = root.querySelectorAll('*');
857
+ for (const el of all) if (el.shadowRoot) walk(el.shadowRoot);
858
+ };
859
+ walk(document);
860
+ return out;
861
+ };
728
862
  const scanRules = () => {
729
863
  const rules = window.__playheadMaskCssRules || [];
730
864
  for (const r of rules) {
731
865
  try {
732
- for (const el of document.querySelectorAll(r.css)) {
866
+ for (const el of deepQuery(r.css)) {
733
867
  if (!el.hasAttribute('data-playhead-mask')) el.setAttribute('data-playhead-mask', r.style);
734
868
  }
735
869
  } catch (e) {}
@@ -738,7 +872,7 @@ var MASK_INIT_SCRIPT = `
738
872
  const tick = () => {
739
873
  try {
740
874
  if (scanTick++ % 3 === 0) scanRules(); // every ~3 frames \u2014 cheap, and a 1-frame leak beats a 1-step leak
741
- const tagged = new Set(document.querySelectorAll('[data-playhead-mask]'));
875
+ const tagged = new Set(deepQuery('[data-playhead-mask]'));
742
876
  for (const [el, box] of boxes) {
743
877
  if (!tagged.has(el) || !el.isConnected) { box.remove(); boxes.delete(el); }
744
878
  }
@@ -888,9 +1022,23 @@ var PlaywrightDriver = class _PlaywrightDriver {
888
1022
  tagCounter = 0;
889
1023
  typingMaskLoc = null;
890
1024
  async launch(opts) {
1025
+ try {
1026
+ await access(chromium.executablePath());
1027
+ } catch {
1028
+ throw new InfraError(
1029
+ "Chromium is not installed (one-time setup) \u2014 run: npx playwright install chromium"
1030
+ );
1031
+ }
891
1032
  const env = opts.environment;
892
- this.browser = await chromium.launch({ headless: opts.headless ?? true });
1033
+ this.forcedDsf = opts.dpr > 1 && process.env.PLAYHEAD_CAPTURE !== "screenshot" ? opts.dpr : 1;
1034
+ this.browser = await chromium.launch({
1035
+ headless: opts.headless ?? true,
1036
+ ...this.forcedDsf > 1 ? { args: [`--force-device-scale-factor=${this.forcedDsf}`] } : {}
1037
+ });
1038
+ const chromeMajor = this.browser.version().split(".")[0] ?? this.browser.version();
1039
+ 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";
893
1040
  this.context = await this.browser.newContext({
1041
+ userAgent: `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`,
894
1042
  viewport: { width: opts.viewport.w, height: opts.viewport.h },
895
1043
  deviceScaleFactor: opts.dpr,
896
1044
  colorScheme: env?.colorScheme ?? "light",
@@ -929,7 +1077,10 @@ var PlaywrightDriver = class _PlaywrightDriver {
929
1077
  });
930
1078
  this.page.on("console", (msg) => this.pushConsole(msg.type(), msg.text()));
931
1079
  this.page.on("pageerror", (err) => this.pushConsole("pageerror", err.message));
932
- this.page.on("crash", () => this.pushConsole("crash", "page crashed"));
1080
+ this.page.on("crash", () => {
1081
+ this.pushConsole("crash", "page crashed");
1082
+ this.crashed = true;
1083
+ });
933
1084
  const dialogPolicy = opts.dialogs ?? "accept";
934
1085
  this.page.on("dialog", (dialog) => {
935
1086
  this.pushConsole("dialog", `${dialog.type()}("${dialog.message()}") \u2192 ${dialogPolicy}`);
@@ -946,6 +1097,12 @@ var PlaywrightDriver = class _PlaywrightDriver {
946
1097
  await this.page.mouse.move(this.cursor.x, this.cursor.y);
947
1098
  }
948
1099
  consoleBuf = [];
1100
+ /** Browser-level forced device scale (--force-device-scale-factor); 1 = not forced. */
1101
+ forcedDsf = 1;
1102
+ crashed = false;
1103
+ assertAlive() {
1104
+ if (this.crashed) throw new Error("page crashed \u2014 the browser renderer died (see console.json)");
1105
+ }
949
1106
  pushConsole(type, text) {
950
1107
  this.consoleBuf.push({ t: Date.now(), type, text: text.slice(0, 500) });
951
1108
  if (this.consoleBuf.length > 500) this.consoleBuf.shift();
@@ -1063,6 +1220,7 @@ var PlaywrightDriver = class _PlaywrightDriver {
1063
1220
  return l;
1064
1221
  }
1065
1222
  async resolveTarget(loc, timeoutMs) {
1223
+ this.assertAlive();
1066
1224
  const locator = this.toLocator(loc);
1067
1225
  try {
1068
1226
  await locator.waitFor({ state: "visible", timeout: timeoutMs });
@@ -1126,6 +1284,8 @@ var PlaywrightDriver = class _PlaywrightDriver {
1126
1284
  if (loc.frames && loc.frames.length > 0) {
1127
1285
  const box = await withTimeout(locator.boundingBox(), 3e3).catch(() => null);
1128
1286
  if (box) geom.bbox = { x: box.x, y: box.y, w: box.width, h: box.height };
1287
+ const main = await withTimeout(this.page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })), 2e3).catch(() => null);
1288
+ if (main) geom.scroll = main;
1129
1289
  }
1130
1290
  let role = null;
1131
1291
  let name = null;
@@ -1217,12 +1377,69 @@ var PlaywrightDriver = class _PlaywrightDriver {
1217
1377
  * through the checks a raw coordinate click bypasses (a toast/sticky header drifting under
1218
1378
  * the point between measure and click silently redirects a coordinate click).
1219
1379
  */
1380
+ async setInputFiles(loc, filePath) {
1381
+ await this.toLocator(loc).setInputFiles(filePath, { timeout: INPUT_TIMEOUT_MS * 2 });
1382
+ }
1383
+ /** Real drag: hover the source, press, glide to the destination in eased steps (recording
1384
+ * pressed-cursor waypoints for the film), release over the target's current position. */
1385
+ async dragTo(from, to, timeoutMs) {
1386
+ const src = this.toLocator(from);
1387
+ const dst = this.toLocator(to);
1388
+ await src.hover({ timeout: timeoutMs });
1389
+ const a = await src.boundingBox();
1390
+ if (!a) throw new Error(`drag source vanished: ${from.raw}`);
1391
+ const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 };
1392
+ const tDown = Date.now();
1393
+ await withTimeout(this.page.mouse.down(), INPUT_TIMEOUT_MS);
1394
+ const path = [{ x: start.x, y: start.y, t: tDown }];
1395
+ const b = await dst.boundingBox();
1396
+ if (!b) {
1397
+ await this.page.mouse.up().catch(() => {
1398
+ });
1399
+ throw new Error(`drag destination not found: ${to.raw}`);
1400
+ }
1401
+ const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
1402
+ const STEPS = 24;
1403
+ for (let i = 1; i <= STEPS; i++) {
1404
+ const u = minJerk(i / STEPS);
1405
+ const p = { x: start.x + (end.x - start.x) * u, y: start.y + (end.y - start.y) * u };
1406
+ await withTimeout(this.page.mouse.move(p.x, p.y), INPUT_TIMEOUT_MS);
1407
+ path.push({ x: p.x, y: p.y, t: Date.now() });
1408
+ await sleep(18);
1409
+ }
1410
+ const tUp = Date.now();
1411
+ await withTimeout(this.page.mouse.up(), INPUT_TIMEOUT_MS);
1412
+ this.cursor = end;
1413
+ return { tDown, tUp, path };
1414
+ }
1220
1415
  async actClick(loc, opts) {
1221
1416
  const locator = this.toLocator(loc);
1222
- const tDown = Date.now();
1417
+ await locator.hover({ timeout: opts?.timeoutMs ?? 1e4 }).catch(() => {
1418
+ });
1419
+ await withTimeout(
1420
+ this.page.evaluate(() => new Promise(requestAnimationFrame).then(() => new Promise(requestAnimationFrame))),
1421
+ 1200
1422
+ ).catch(() => {
1423
+ });
1424
+ await this.captureNow();
1425
+ const before = Date.now();
1426
+ await locator.evaluate((el) => {
1427
+ const w = el.ownerDocument.defaultView;
1428
+ if (w) {
1429
+ w.__phTDown = null;
1430
+ el.addEventListener("pointerdown", () => w.__phTDown = Date.now(), { once: true, capture: true });
1431
+ }
1432
+ }).catch(() => {
1433
+ });
1223
1434
  if (opts?.double) await locator.dblclick({ delay: 60, timeout: opts?.timeoutMs ?? 1e4 });
1224
- else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4 });
1225
- return { tDown, tUp: Date.now() };
1435
+ else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4, ...opts?.button ? { button: opts.button } : {} });
1436
+ const tUp = Date.now();
1437
+ const browserTDown = await withTimeout(
1438
+ locator.evaluate((el) => el.ownerDocument.defaultView?.__phTDown ?? null),
1439
+ 1500
1440
+ ).catch(() => null);
1441
+ const tDown = typeof browserTDown === "number" ? browserTDown - this.clockOffset : Math.max(before, tUp - 90);
1442
+ return { tDown, tUp };
1226
1443
  }
1227
1444
  async selectOption(loc, value) {
1228
1445
  const locator = this.toLocator(loc);
@@ -1231,8 +1448,6 @@ var PlaywrightDriver = class _PlaywrightDriver {
1231
1448
  } catch {
1232
1449
  await locator.selectOption(value);
1233
1450
  }
1234
- await withTimeout(this.page.keyboard.press("Escape"), INPUT_TIMEOUT_MS).catch(() => {
1235
- });
1236
1451
  await withTimeout(
1237
1452
  locator.evaluate((el) => el.blur?.()),
1238
1453
  2e3
@@ -1268,6 +1483,22 @@ var PlaywrightDriver = class _PlaywrightDriver {
1268
1483
  }
1269
1484
  }
1270
1485
  async expectState(loc, opts, timeoutMs) {
1486
+ if (opts.url !== void 0) {
1487
+ const want = opts.url;
1488
+ const matches = (u) => {
1489
+ const m = /^\/(.+)\/([a-z]*)$/.exec(want);
1490
+ return m ? new RegExp(m[1], m[2]).test(u) : u.includes(want);
1491
+ };
1492
+ const deadlineUrl = Date.now() + timeoutMs;
1493
+ while (!matches(this.page.url())) {
1494
+ if (Date.now() > deadlineUrl) {
1495
+ throw new Error(`Expectation not met for URL: expected ${want}, got ${this.page.url()}`);
1496
+ }
1497
+ await sleep(100);
1498
+ }
1499
+ if (!loc) return;
1500
+ }
1501
+ if (!loc) return;
1271
1502
  const locator = this.toLocator(loc);
1272
1503
  const deadline = Date.now() + timeoutMs;
1273
1504
  let lastErr = "condition not met";
@@ -1283,6 +1514,18 @@ var PlaywrightDriver = class _PlaywrightDriver {
1283
1514
  const visible = await locator.first().isVisible();
1284
1515
  if (opts.visible !== void 0 && visible !== opts.visible) {
1285
1516
  lastErr = `expected visible=${opts.visible}, got ${visible}`;
1517
+ } else if (opts.value !== void 0) {
1518
+ const v = await locator.inputValue({ timeout: 1e3 }).catch(() => null);
1519
+ if (v === opts.value) return;
1520
+ lastErr = `expected value ${JSON.stringify(opts.value)}, got ${JSON.stringify(v)}`;
1521
+ } else if (opts.disabled !== void 0) {
1522
+ const d = await locator.isDisabled({ timeout: 1e3 }).catch(() => null);
1523
+ if (d === opts.disabled) return;
1524
+ lastErr = `expected disabled=${opts.disabled}, got ${d}`;
1525
+ } else if (opts.checked !== void 0) {
1526
+ const c = await locator.isChecked({ timeout: 1e3 }).catch(() => null);
1527
+ if (c === opts.checked) return;
1528
+ lastErr = `expected checked=${opts.checked}, got ${c}`;
1286
1529
  } else if (opts.text !== void 0) {
1287
1530
  const content = visible ? await locator.first().innerText() : "";
1288
1531
  if (!content.includes(opts.text)) {
@@ -1324,6 +1567,7 @@ var PlaywrightDriver = class _PlaywrightDriver {
1324
1567
  }
1325
1568
  static LONG_REQUEST_MS = 2e3;
1326
1569
  async settle(opts) {
1570
+ this.assertAlive();
1327
1571
  this.installNetTracking();
1328
1572
  const start = Date.now();
1329
1573
  let capped = true;
@@ -1366,7 +1610,20 @@ var PlaywrightDriver = class _PlaywrightDriver {
1366
1610
  })
1367
1611
  ),
1368
1612
  1800
1369
- ).catch(() => {
1613
+ ).catch(async (e) => {
1614
+ if (/context.*destroyed|navigat/i.test(e.message ?? "")) {
1615
+ await this.page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
1616
+ });
1617
+ await withTimeout(
1618
+ this.page.evaluate(
1619
+ () => new Promise((resolve) => {
1620
+ setTimeout(resolve, 400);
1621
+ })
1622
+ ),
1623
+ 1e3
1624
+ ).catch(() => {
1625
+ });
1626
+ }
1370
1627
  });
1371
1628
  await this.nextFrame();
1372
1629
  }
@@ -1423,10 +1680,56 @@ var PlaywrightDriver = class _PlaywrightDriver {
1423
1680
  // Headless Chromium's CDP screencast is hard-locked to CSS-pixel resolution and ignores
1424
1681
  // deviceScaleFactor, so a paced Page.captureScreenshot loop with clip.scale is what actually
1425
1682
  // yields 2x frames (the zoom headroom the camera planner needs).
1683
+ screencastActive = false;
1426
1684
  async startCapture(onFrame, opts) {
1427
1685
  this.captureOnFrame = onFrame;
1428
1686
  this.captureOpts = opts;
1429
1687
  this.captureActive = true;
1688
+ if (process.env.PLAYHEAD_CAPTURE !== "screenshot") {
1689
+ try {
1690
+ const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
1691
+ const expectedW = Math.round(vp.width * opts.scale);
1692
+ let sizeChecked = false;
1693
+ this.captureCdp.on("Page.screencastFrame", (ev) => {
1694
+ void this.captureCdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
1695
+ });
1696
+ if (!this.captureActive || !this.captureOnFrame || !this.screencastActive) return;
1697
+ const data = Buffer.from(ev.data, "base64");
1698
+ if (!sizeChecked) {
1699
+ sizeChecked = true;
1700
+ const w = imageWidth(data, opts.format);
1701
+ if (w !== null && w < expectedW * 0.9) {
1702
+ log.warn(
1703
+ `screencast emits ${w}px-wide frames (need ${expectedW} for zoom headroom) \u2014 reverting to the 2x screenshot loop`
1704
+ );
1705
+ this.screencastActive = false;
1706
+ void withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1707
+ });
1708
+ this.startScreenshotLoop(opts);
1709
+ return;
1710
+ }
1711
+ }
1712
+ const tNodeMs = ev.metadata?.timestamp ? ev.metadata.timestamp * 1e3 - this.clockOffset : Date.now();
1713
+ this.lastFrameNodeMs = tNodeMs;
1714
+ this.captureOnFrame({ data, tNodeMs });
1715
+ });
1716
+ await this.captureCdp.send("Page.startScreencast", {
1717
+ format: opts.format,
1718
+ quality: opts.quality,
1719
+ maxWidth: expectedW,
1720
+ maxHeight: Math.round(vp.height * opts.scale),
1721
+ // Compositor paints at up to ~60; halve toward the requested rate.
1722
+ everyNthFrame: Math.max(1, Math.round(60 / Math.max(15, opts.fps * 1.25)))
1723
+ });
1724
+ this.screencastActive = true;
1725
+ return;
1726
+ } catch (e) {
1727
+ log.warn(`screencast unavailable (${e.message.split("\n")[0]}) \u2014 falling back to the paced screenshot loop`);
1728
+ }
1729
+ }
1730
+ this.startScreenshotLoop(opts);
1731
+ }
1732
+ startScreenshotLoop(opts) {
1430
1733
  const intervalMs = 1e3 / opts.fps;
1431
1734
  this.captureLoop = (async () => {
1432
1735
  while (this.captureActive) {
@@ -1437,7 +1740,8 @@ var PlaywrightDriver = class _PlaywrightDriver {
1437
1740
  }
1438
1741
  })();
1439
1742
  }
1440
- /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one). */
1743
+ /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one).
1744
+ * Resolves true iff a frame was actually stored. */
1441
1745
  grabFrame() {
1442
1746
  if (this.inflightGrab) return this.inflightGrab;
1443
1747
  this.inflightGrab = this.doGrab().finally(() => {
@@ -1447,7 +1751,7 @@ var PlaywrightDriver = class _PlaywrightDriver {
1447
1751
  }
1448
1752
  grabFailures = 0;
1449
1753
  async doGrab() {
1450
- if (!this.captureOpts || !this.captureOnFrame) return;
1754
+ if (!this.captureOpts || !this.captureOnFrame) return false;
1451
1755
  try {
1452
1756
  const opts = this.captureOpts;
1453
1757
  const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
@@ -1456,7 +1760,9 @@ var PlaywrightDriver = class _PlaywrightDriver {
1456
1760
  this.captureCdp.send("Page.captureScreenshot", {
1457
1761
  format: opts.format,
1458
1762
  quality: opts.quality,
1459
- clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale },
1763
+ // Under --force-device-scale-factor the surface is already scaled divide it out
1764
+ // or screenshots come back double-scaled (5120-wide).
1765
+ clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale / this.forcedDsf },
1460
1766
  captureBeyondViewport: false
1461
1767
  }),
1462
1768
  1500
@@ -1465,7 +1771,9 @@ var PlaywrightDriver = class _PlaywrightDriver {
1465
1771
  this.lastFrameNodeMs = tNodeMs;
1466
1772
  this.grabFailures = 0;
1467
1773
  this.captureOnFrame({ data: Buffer.from(shot.data, "base64"), tNodeMs });
1468
- } catch {
1774
+ return true;
1775
+ } catch (err) {
1776
+ log.debug(`frame grab failed: ${err.message.split("\n")[0]}`);
1469
1777
  this.grabFailures += 1;
1470
1778
  if (this.grabFailures >= 2) {
1471
1779
  try {
@@ -1477,6 +1785,7 @@ var PlaywrightDriver = class _PlaywrightDriver {
1477
1785
  } catch {
1478
1786
  }
1479
1787
  }
1788
+ return false;
1480
1789
  }
1481
1790
  }
1482
1791
  async readScroll() {
@@ -1490,6 +1799,11 @@ var PlaywrightDriver = class _PlaywrightDriver {
1490
1799
  }
1491
1800
  async stopCapture() {
1492
1801
  this.captureActive = false;
1802
+ if (this.screencastActive) {
1803
+ this.screencastActive = false;
1804
+ await withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1805
+ });
1806
+ }
1493
1807
  await this.captureLoop?.catch(() => {
1494
1808
  });
1495
1809
  this.captureLoop = null;
@@ -1498,9 +1812,12 @@ var PlaywrightDriver = class _PlaywrightDriver {
1498
1812
  return this.lastFrameNodeMs;
1499
1813
  }
1500
1814
  async captureNow() {
1501
- if (this.inflightGrab) await this.inflightGrab.catch(() => {
1502
- });
1503
- await this.grabFrame();
1815
+ if (this.inflightGrab) await this.inflightGrab.catch(() => false);
1816
+ for (let i = 0; i < 3; i++) {
1817
+ if (await this.grabFrame()) return;
1818
+ await sleep(120);
1819
+ }
1820
+ log.warn("captureNow: no frame stored after 3 attempts \u2014 footage may hold a stale state here");
1504
1821
  }
1505
1822
  };
1506
1823
  function toPw(m) {
@@ -1539,10 +1856,29 @@ function withTimeout(p, ms) {
1539
1856
  );
1540
1857
  });
1541
1858
  }
1859
+ function imageWidth(data, format) {
1860
+ try {
1861
+ if (format === "png") {
1862
+ return data.length >= 24 ? data.readUInt32BE(16) : null;
1863
+ }
1864
+ let i = 2;
1865
+ while (i + 9 < data.length) {
1866
+ if (data[i] !== 255) return null;
1867
+ const marker = data[i + 1];
1868
+ if (marker >= 192 && marker <= 195) return data.readUInt16BE(i + 7);
1869
+ const len = data.readUInt16BE(i + 2);
1870
+ i += 2 + len;
1871
+ }
1872
+ return null;
1873
+ } catch {
1874
+ return null;
1875
+ }
1876
+ }
1542
1877
 
1543
1878
  // src/bundle/writer.ts
1544
1879
  import { mkdir, writeFile } from "fs/promises";
1545
- import { join } from "path";
1880
+ import { createHash as createHash2 } from "crypto";
1881
+ import { join as join2 } from "path";
1546
1882
  import sharp from "sharp";
1547
1883
 
1548
1884
  // src/bundle/hash.ts
@@ -1560,14 +1896,35 @@ function sha256File(path) {
1560
1896
 
1561
1897
  // src/shared/version.ts
1562
1898
  import { createRequire } from "module";
1563
- var PLAYHEAD_VERSION = createRequire(import.meta.url)("../../package.json").version;
1899
+ import { fileURLToPath } from "url";
1900
+ import { dirname as dirname2, join } from "path";
1901
+ import { existsSync, readFileSync } from "fs";
1902
+ function resolveOwnPackageJson() {
1903
+ let dir = dirname2(fileURLToPath(import.meta.url));
1904
+ for (let i = 0; i < 6; i++) {
1905
+ const p = join(dir, "package.json");
1906
+ if (existsSync(p)) {
1907
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
1908
+ if (pkg.name === "playhead-cli" || pkg.name === "playhead") return { version: pkg.version ?? "0.0.0" };
1909
+ }
1910
+ const parent = dirname2(dir);
1911
+ if (parent === dir) break;
1912
+ dir = parent;
1913
+ }
1914
+ try {
1915
+ return createRequire(import.meta.url)("../../package.json");
1916
+ } catch {
1917
+ return { version: "0.0.0" };
1918
+ }
1919
+ }
1920
+ var PLAYHEAD_VERSION = resolveOwnPackageJson().version;
1564
1921
 
1565
1922
  // src/bundle/writer.ts
1566
1923
  var BundleWriter = class {
1567
1924
  constructor(dir, captureFormat) {
1568
1925
  this.dir = dir;
1569
1926
  this.captureFormat = captureFormat;
1570
- this.ready = mkdir(join(dir, "frames"), { recursive: true }).then(() => {
1927
+ this.ready = mkdir(join2(dir, "frames"), { recursive: true }).then(() => {
1571
1928
  });
1572
1929
  }
1573
1930
  dir;
@@ -1576,14 +1933,22 @@ var BundleWriter = class {
1576
1933
  pendingWrites = [];
1577
1934
  maskSamples = [];
1578
1935
  counter = 0;
1936
+ failedFrames = /* @__PURE__ */ new Set();
1937
+ writeError;
1579
1938
  ready;
1580
- /** Queue a frame write. `t` is capture-relative ms. */
1939
+ /** Queue a frame write. `t` is capture-relative ms. Each frame's BYTES are hashed at write
1940
+ * time — the index entry carries the digest, so the framesIndex hash transitively covers
1941
+ * every pixel in the bundle. (Round-2 audit: filename+timestamp hashing left frame images
1942
+ * freely swappable under a passing attest.) */
1581
1943
  addFrame(data, t) {
1582
1944
  this.counter += 1;
1583
1945
  const name = `${String(this.counter).padStart(6, "0")}.${this.captureFormat === "jpeg" ? "jpg" : "png"}`;
1584
- this.frames.push({ f: name, t: round1(t) });
1946
+ this.frames.push({ f: name, t: round1(t), h: createHash2("sha256").update(data).digest("hex") });
1585
1947
  this.pendingWrites.push(
1586
- this.ready.then(() => writeFile(join(this.dir, "frames", name), data))
1948
+ this.ready.then(() => writeFile(join2(this.dir, "frames", name), data)).catch((e) => {
1949
+ this.failedFrames.add(name);
1950
+ this.writeError ??= e;
1951
+ })
1587
1952
  );
1588
1953
  }
1589
1954
  addMaskSample(sample) {
@@ -1595,21 +1960,28 @@ var BundleWriter = class {
1595
1960
  async finish(meta) {
1596
1961
  await this.ready;
1597
1962
  await Promise.all(this.pendingWrites);
1963
+ if (this.failedFrames.size > 0) {
1964
+ this.frames = this.frames.filter((f) => !this.failedFrames.has(f.f));
1965
+ log.warn(`${this.failedFrames.size} frame write(s) failed (${this.writeError?.message?.split("\n")[0]}) \u2014 those frames dropped from the index`);
1966
+ }
1598
1967
  this.frames.sort((a, b) => a.t - b.t);
1599
1968
  let frameW = meta.viewport.w * meta.dpr;
1600
1969
  let frameH = meta.viewport.h * meta.dpr;
1601
1970
  const first = this.frames[0];
1602
1971
  if (first) {
1603
- const info = await sharp(join(this.dir, "frames", first.f)).metadata();
1972
+ const info = await sharp(join2(this.dir, "frames", first.f)).metadata();
1604
1973
  if (info.width && info.height) {
1605
1974
  frameW = info.width;
1606
1975
  frameH = info.height;
1607
1976
  }
1608
1977
  }
1978
+ meta.events.meta.frameW = frameW;
1979
+ meta.events.meta.frameH = frameH;
1980
+ const spec = redactMaskedText(meta.spec);
1609
1981
  const manifest = {
1610
1982
  schema: "playhead/bundle@1",
1611
1983
  playheadVersion: PLAYHEAD_VERSION,
1612
- specHash: sha256Json(meta.spec),
1984
+ specHash: sha256Json(spec),
1613
1985
  appUrl: meta.appUrl,
1614
1986
  viewport: meta.viewport,
1615
1987
  dpr: meta.dpr,
@@ -1628,15 +2000,24 @@ var BundleWriter = class {
1628
2000
  ...meta.failure ? { failure: meta.failure } : {}
1629
2001
  };
1630
2002
  await Promise.all([
1631
- writeFile(join(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
1632
- writeFile(join(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
1633
- writeFile(join(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
1634
- writeFile(join(this.dir, "spec.resolved.json"), JSON.stringify(meta.spec, null, 2)),
1635
- writeFile(join(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
2003
+ writeFile(join2(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
2004
+ writeFile(join2(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
2005
+ writeFile(join2(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
2006
+ writeFile(join2(this.dir, "spec.resolved.json"), JSON.stringify(spec, null, 2)),
2007
+ writeFile(join2(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
1636
2008
  ]);
1637
2009
  return manifest;
1638
2010
  }
1639
2011
  };
2012
+ function redactMaskedText(spec) {
2013
+ const copy = JSON.parse(JSON.stringify(spec));
2014
+ for (const scene of copy.scenes) {
2015
+ for (const step of scene.steps) {
2016
+ if (step.action === "type" && step.mask && step.text) step.text = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
2017
+ }
2018
+ }
2019
+ return copy;
2020
+ }
1640
2021
  function round1(n) {
1641
2022
  return Math.round(n * 10) / 10;
1642
2023
  }
@@ -1696,18 +2077,6 @@ function projectPoint(p, cam, plane2) {
1696
2077
  return { x: (p.x - vis.x) * s, y: (p.y - vis.y) * s };
1697
2078
  }
1698
2079
 
1699
- // src/shared/exit.ts
1700
- var EXIT = {
1701
- OK: 0,
1702
- FLOW: 1,
1703
- QUALITY: 2,
1704
- INFRA: 3,
1705
- USAGE: 4
1706
- };
1707
- var FlowError = class extends Error {
1708
- exitCode = EXIT.FLOW;
1709
- };
1710
-
1711
2080
  // src/capture/executor.ts
1712
2081
  var SETTLE = { idleMs: 300, capMs: 5e3 };
1713
2082
  var TARGET_TIMEOUT_MS = 1e4;
@@ -1716,7 +2085,7 @@ var TYPE_DELAY_MS = 45;
1716
2085
  var STEP_BUDGET_MS = 45e3;
1717
2086
  var CAPTURE_DEADLINE_MS = 10 * 6e4;
1718
2087
  async function capture(spec, opts) {
1719
- const bundleDir = join2(opts.outDir, "capture");
2088
+ const bundleDir = join3(opts.outDir, "capture");
1720
2089
  const driver = opts.driver ?? new PlaywrightDriver();
1721
2090
  const dpr = 2;
1722
2091
  const format = opts.captureFormat ?? "jpeg";
@@ -1750,18 +2119,20 @@ async function capture(spec, opts) {
1750
2119
  try {
1751
2120
  clockOffsetMs = await driver.measureClockOffset();
1752
2121
  t0 = Date.now();
2122
+ let recordEvents = !opts.fromScene;
1753
2123
  driver.onNavigation((url, tNode) => {
1754
- events.push({ type: "navigation", url, t: rel(tNode) });
2124
+ if (recordEvents) events.push({ type: "navigation", url, t: rel(tNode) });
1755
2125
  });
1756
2126
  const startFilming = () => driver.startCapture((frame) => writer.addFrame(frame.data, rel(frame.tNodeMs)), {
1757
2127
  format,
1758
2128
  quality: opts.quality ?? 82,
1759
2129
  scale: dpr,
1760
- // 24 requested 20-22 achieved with the screenshot loop's ~25ms capture cost; frame
1761
- // blending at compose smooths the rest. (True 30+ needs the screencast source — planned.)
1762
- fps: 24
2130
+ // The screencast source delivers paint-driven frames up to ~30fps during motion; the
2131
+ // paced screenshot fallback (PLAYHEAD_CAPTURE=screenshot) tops out ~20.
2132
+ fps: 30
1763
2133
  });
1764
- if (!opts.fromScene) await startFilming();
2134
+ const preRoll = spec.setup.length > 0 || Boolean(opts.fromScene);
2135
+ if (!preRoll) await startFilming();
1765
2136
  await driver.installMaskRules(maskRules);
1766
2137
  await driver.goto(spec.app.url);
1767
2138
  await applyMasksAndSample(driver, maskRules, writer, now);
@@ -1773,7 +2144,15 @@ async function capture(spec, opts) {
1773
2144
  );
1774
2145
  });
1775
2146
  }
1776
- if (!opts.fromScene) await driver.captureNow();
2147
+ if (!preRoll) await driver.captureNow();
2148
+ if (spec.setup.length > 0) {
2149
+ log.info(`running ${spec.setup.length} setup step(s) (state only, not filmed)`);
2150
+ for (const [i, s] of spec.setup.entries()) {
2151
+ currentStepRef = `setup/${i}`;
2152
+ await fastForwardStep(driver, s);
2153
+ }
2154
+ currentStepRef = "";
2155
+ }
1777
2156
  let steps = flattenSteps(spec);
1778
2157
  const totalSteps = steps.length;
1779
2158
  if (opts.fromScene) {
@@ -1786,7 +2165,10 @@ async function capture(spec, opts) {
1786
2165
  }
1787
2166
  currentStepRef = "";
1788
2167
  steps = steps.slice(idx);
2168
+ }
2169
+ if (preRoll) {
1789
2170
  await driver.settle(settleCfg);
2171
+ recordEvents = true;
1790
2172
  await startFilming();
1791
2173
  await driver.captureNow();
1792
2174
  }
@@ -1796,11 +2178,20 @@ async function capture(spec, opts) {
1796
2178
  throw new Error(`capture watchdog: exceeded ${CAPTURE_DEADLINE_MS / 6e4} minutes at step ${currentStepRef}`);
1797
2179
  }
1798
2180
  log.step(`${addressed.ordinal}/${totalSteps} ${describeStep(addressed)}`);
1799
- const event = await withStepBudget(
1800
- executeStep(driver, addressed, { rel, now }, spec.app.url),
1801
- STEP_BUDGET_MS,
1802
- currentStepRef
1803
- );
2181
+ let event;
2182
+ try {
2183
+ event = await withStepBudget(
2184
+ executeStep(driver, addressed, { rel, now }, spec.app.url),
2185
+ STEP_BUDGET_MS,
2186
+ currentStepRef
2187
+ );
2188
+ } catch (e) {
2189
+ if (addressed.step.optional) {
2190
+ log.warn(`optional step ${currentStepRef} skipped: ${e.message.split("\n")[0]}`);
2191
+ continue;
2192
+ }
2193
+ throw e;
2194
+ }
1804
2195
  await applyMasksAndSample(driver, maskRules, writer, now);
1805
2196
  await driver.settle(settleCfg);
1806
2197
  const focus = "focus" in addressed.step ? addressed.step.focus : void 0;
@@ -1838,10 +2229,10 @@ async function capture(spec, opts) {
1838
2229
  const url = driver.currentUrl();
1839
2230
  const { writeFile: writeFile6 } = await import("fs/promises");
1840
2231
  await writeFile6(
1841
- join2(bundleDir, "failure.json"),
2232
+ join3(bundleDir, "failure.json"),
1842
2233
  JSON.stringify({ stepRef: failure.stepRef, message, url, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
1843
2234
  );
1844
- if (aria) await writeFile6(join2(bundleDir, "failure-aria.txt"), aria);
2235
+ if (aria) await writeFile6(join3(bundleDir, "failure-aria.txt"), aria);
1845
2236
  } catch {
1846
2237
  }
1847
2238
  throw new FlowError(
@@ -1856,7 +2247,7 @@ async function capture(spec, opts) {
1856
2247
  if (consoleLog.length > 0) {
1857
2248
  const { writeFile: writeFile6 } = await import("fs/promises");
1858
2249
  await writeFile6(
1859
- join2(bundleDir, "console.json"),
2250
+ join3(bundleDir, "console.json"),
1860
2251
  JSON.stringify(consoleLog.map((c) => ({ ...c, t: rel(c.t) })), null, 2)
1861
2252
  );
1862
2253
  }
@@ -1908,13 +2299,15 @@ async function fastForwardStep(driver, step) {
1908
2299
  break;
1909
2300
  case "click":
1910
2301
  case "dblclick": {
1911
- const t = await driver.resolveTarget(parseLocator(step.target), T);
1912
- await driver.clickAt(rectCenter(t.bbox), { double: step.action === "dblclick" });
2302
+ await driver.resolveTarget(parseLocator(step.target), T);
2303
+ await driver.actClick(parseLocator(step.target), { double: step.action === "dblclick", timeoutMs: T });
1913
2304
  break;
1914
2305
  }
1915
- case "hover":
2306
+ case "hover": {
2307
+ const t = await driver.resolveTarget(parseLocator(step.target), T);
2308
+ await driver.moveCursor(rectCenter(t.bbox), 60);
1916
2309
  break;
1917
- // no lasting state
2310
+ }
1918
2311
  case "type": {
1919
2312
  const t = await driver.resolveTarget(parseLocator(step.target), T);
1920
2313
  await driver.clickAt(rectCenter(t.bbox));
@@ -1932,8 +2325,23 @@ async function fastForwardStep(driver, step) {
1932
2325
  else if (step.by) await driver.scrollBy(step.by);
1933
2326
  await driver.waitForScrollSettle(1500);
1934
2327
  break;
2328
+ case "rightclick": {
2329
+ await driver.resolveTarget(parseLocator(step.target), T);
2330
+ await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: T });
2331
+ break;
2332
+ }
2333
+ case "upload":
2334
+ await driver.setInputFiles(parseLocator(step.target), step.file);
2335
+ break;
2336
+ case "drag":
2337
+ await driver.dragTo(parseLocator(step.target), parseLocator(step.to), T);
2338
+ break;
1935
2339
  case "expect":
1936
- await driver.expectState(parseLocator(step.target), { ...step.visible !== void 0 ? { visible: step.visible } : {} }, T).catch(() => {
2340
+ await driver.expectState(
2341
+ step.target ? parseLocator(step.target) : null,
2342
+ { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.url !== void 0 ? { url: step.url } : {} },
2343
+ T
2344
+ ).catch(() => {
1937
2345
  });
1938
2346
  break;
1939
2347
  case "wait":
@@ -2019,6 +2427,9 @@ async function executeStep(driver, addressed, clock, appUrl) {
2019
2427
  base.targetPre = pre;
2020
2428
  base.cursorPath = await moveToTarget(driver, pre, clock);
2021
2429
  await driver.actClick(loc, { timeoutMs: budgetMs });
2430
+ if (step.clear) {
2431
+ await driver.press("ControlOrMeta+A");
2432
+ }
2022
2433
  if (step.mask) {
2023
2434
  await driver.setTypingMask(loc);
2024
2435
  }
@@ -2048,6 +2459,42 @@ async function executeStep(driver, addressed, clock, appUrl) {
2048
2459
  base.tActionEnd = clock.now();
2049
2460
  return base;
2050
2461
  }
2462
+ case "rightclick": {
2463
+ base.locator = step.target;
2464
+ const loc = parseLocator(step.target);
2465
+ const pre = await driver.resolveTarget(loc, budgetMs);
2466
+ base.targetPre = pre;
2467
+ base.cursorPath = await moveToTarget(driver, pre, clock);
2468
+ const { tDown, tUp } = await driver.actClick(loc, { button: "right", timeoutMs: budgetMs });
2469
+ base.tAction = clock.rel(tDown);
2470
+ base.tActionEnd = clock.rel(tUp);
2471
+ return base;
2472
+ }
2473
+ case "upload": {
2474
+ base.locator = step.target;
2475
+ const loc = parseLocator(step.target);
2476
+ base.targetPre = await driver.tryMeasure(loc);
2477
+ if (base.targetPre) base.cursorPath = await moveToTarget(driver, base.targetPre, clock);
2478
+ base.tAction = clock.now();
2479
+ await driver.setInputFiles(loc, step.file);
2480
+ base.typedText = step.file.split(/[\\/]/).pop() ?? step.file;
2481
+ base.tActionEnd = clock.now();
2482
+ return base;
2483
+ }
2484
+ case "drag": {
2485
+ base.locator = step.target;
2486
+ const from = parseLocator(step.target);
2487
+ const to = parseLocator(step.to);
2488
+ const pre = await driver.resolveTarget(from, budgetMs);
2489
+ base.targetPre = pre;
2490
+ base.cursorPath = await moveToTarget(driver, pre, clock);
2491
+ const { tDown, tUp, path } = await driver.dragTo(from, to, budgetMs);
2492
+ base.cursorPath = [...base.cursorPath, ...path.map((w) => ({ ...w, t: clock.rel(w.t) }))];
2493
+ base.tAction = clock.rel(tDown);
2494
+ base.tActionEnd = clock.rel(tUp);
2495
+ base.targetPost = await driver.tryMeasure(to);
2496
+ return base;
2497
+ }
2051
2498
  case "scroll": {
2052
2499
  base.tAction = clock.now();
2053
2500
  if (step.target) {
@@ -2064,14 +2511,22 @@ async function executeStep(driver, addressed, clock, appUrl) {
2064
2511
  return base;
2065
2512
  }
2066
2513
  case "expect": {
2067
- base.locator = step.target;
2068
- const loc = parseLocator(step.target);
2514
+ base.locator = step.target ?? step.url ?? "";
2515
+ const loc = step.target ? parseLocator(step.target) : null;
2069
2516
  await driver.expectState(
2070
2517
  loc,
2071
- { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.text !== void 0 ? { text: step.text } : {}, ...step.count !== void 0 ? { count: step.count } : {} },
2518
+ {
2519
+ ...step.visible !== void 0 ? { visible: step.visible } : {},
2520
+ ...step.text !== void 0 ? { text: step.text } : {},
2521
+ ...step.count !== void 0 ? { count: step.count } : {},
2522
+ ...step.url !== void 0 ? { url: step.url } : {},
2523
+ ...step.value !== void 0 ? { value: step.value } : {},
2524
+ ...step.disabled !== void 0 ? { disabled: step.disabled } : {},
2525
+ ...step.checked !== void 0 ? { checked: step.checked } : {}
2526
+ },
2072
2527
  step.timeout ?? EXPECT_TIMEOUT_MS
2073
2528
  );
2074
- base.targetPre = await driver.tryMeasure(loc);
2529
+ if (loc) base.targetPre = await driver.tryMeasure(loc);
2075
2530
  base.tAction = clock.now();
2076
2531
  base.tActionEnd = base.tAction;
2077
2532
  return base;
@@ -2097,6 +2552,7 @@ async function moveToTarget(driver, target, clock) {
2097
2552
  const to = rectCenter(target.bbox);
2098
2553
  const travel = clamp(250 + dist(driver.cursorPos(), to) * 0.5, 300, 700);
2099
2554
  const waypoints = await driver.moveCursor(to, travel);
2555
+ await driver.captureNow();
2100
2556
  return waypoints.map((w) => ({ x: round12(w.x), y: round12(w.y), t: round12(clock.rel(w.t)) }));
2101
2557
  }
2102
2558
  async function ensurePostActionFrame(driver, tAction, rel) {
@@ -2143,13 +2599,13 @@ function round12(n) {
2143
2599
 
2144
2600
  // src/bundle/reader.ts
2145
2601
  import { readFile as readFile2 } from "fs/promises";
2146
- import { join as join3 } from "path";
2602
+ import { join as join4 } from "path";
2147
2603
  async function openBundle(dir) {
2148
2604
  const [manifestRaw, eventsRaw, framesRaw, masksRaw] = await Promise.all([
2149
- readFile2(join3(dir, "manifest.json"), "utf8"),
2150
- readFile2(join3(dir, "events.json"), "utf8"),
2151
- readFile2(join3(dir, "frames", "index.json"), "utf8"),
2152
- readFile2(join3(dir, "masks.json"), "utf8").catch(() => "[]")
2605
+ readFile2(join4(dir, "manifest.json"), "utf8"),
2606
+ readFile2(join4(dir, "events.json"), "utf8"),
2607
+ readFile2(join4(dir, "frames", "index.json"), "utf8"),
2608
+ readFile2(join4(dir, "masks.json"), "utf8").catch(() => "[]")
2153
2609
  ]);
2154
2610
  const manifest = JSON.parse(manifestRaw);
2155
2611
  if (manifest.schema !== "playhead/bundle@1") {
@@ -2174,16 +2630,16 @@ function frameIndexForTime(frames, tMs) {
2174
2630
  return lo;
2175
2631
  }
2176
2632
  function framePath(bundle, index) {
2177
- return join3(bundle.dir, "frames", bundle.frames[index].f);
2633
+ return join4(bundle.dir, "frames", bundle.frames[index].f);
2178
2634
  }
2179
2635
 
2180
2636
  // src/compose/index.ts
2181
- import { join as join7 } from "path";
2637
+ import { join as join8 } from "path";
2182
2638
  import { writeFile as writeFile3 } from "fs/promises";
2183
2639
 
2184
2640
  // src/theme/index.ts
2185
- import { existsSync } from "fs";
2186
- import { join as join4, dirname as dirname2 } from "path";
2641
+ import { existsSync as existsSync2 } from "fs";
2642
+ import { join as join5, dirname as dirname3 } from "path";
2187
2643
  import { createRequire as createRequire2 } from "module";
2188
2644
  import { GlobalFonts } from "@napi-rs/canvas";
2189
2645
  var DEFAULT_THEME = {
@@ -2217,7 +2673,7 @@ function registerFonts() {
2217
2673
  const require5 = createRequire2(import.meta.url);
2218
2674
  let pkgDir;
2219
2675
  try {
2220
- pkgDir = dirname2(require5.resolve("@expo-google-fonts/inter/package.json"));
2676
+ pkgDir = dirname3(require5.resolve("@expo-google-fonts/inter/package.json"));
2221
2677
  } catch {
2222
2678
  throw new Error("Font package @expo-google-fonts/inter not found \u2014 run npm install");
2223
2679
  }
@@ -2228,8 +2684,8 @@ function registerFonts() {
2228
2684
  ["700Bold/Inter_700Bold.ttf", "Inter Bold"]
2229
2685
  ];
2230
2686
  for (const [rel, family] of faces) {
2231
- const p = join4(pkgDir, rel);
2232
- if (existsSync(p)) GlobalFonts.registerFromPath(p, family);
2687
+ const p = join5(pkgDir, rel);
2688
+ if (existsSync2(p)) GlobalFonts.registerFromPath(p, family);
2233
2689
  }
2234
2690
  }
2235
2691
 
@@ -2237,17 +2693,24 @@ function registerFonts() {
2237
2693
  function stageContent(profile) {
2238
2694
  return profile.stage?.content ?? { x: 0, y: 0, w: profile.width, h: profile.height };
2239
2695
  }
2240
- function withStage(profile, chrome) {
2696
+ function withStage(profile, chrome, viewportAspect) {
2241
2697
  const { width, height } = profile;
2698
+ const va = viewportAspect ?? width / height;
2699
+ const ui = Math.min(width, height) / 1080;
2242
2700
  const marginY = Math.round(height * 0.055);
2243
- const chromeH = chrome ? Math.round(44 * (Math.min(width, height) / 1080)) : 0;
2244
- const contentH = height - 2 * marginY - chromeH;
2245
- const contentW = Math.round(contentH * (width / height));
2701
+ const marginX = Math.round(width * 0.05);
2702
+ const chromeH = chrome ? Math.round(44 * ui) : 0;
2703
+ let contentH = height - 2 * marginY - chromeH;
2704
+ let contentW = Math.round(contentH * va);
2705
+ if (contentW > width - 2 * marginX) {
2706
+ contentW = width - 2 * marginX;
2707
+ contentH = Math.round(contentW / va);
2708
+ }
2246
2709
  const x = Math.round((width - contentW) / 2);
2247
- const y = marginY + chromeH;
2710
+ const y = Math.round((height - contentH - chromeH) / 2) + chromeH;
2248
2711
  return {
2249
2712
  ...profile,
2250
- stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * (Math.min(width, height) / 1080)) }
2713
+ stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * ui) }
2251
2714
  };
2252
2715
  }
2253
2716
  var PROFILE_16x9 = {
@@ -2257,7 +2720,8 @@ var PROFILE_16x9 = {
2257
2720
  safeArea: { top: 54, right: 96, bottom: 160, left: 96 },
2258
2721
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2259
2722
  minFocusPx: 110,
2260
- captionMaxWidth: 1040
2723
+ captionMaxWidth: 1040,
2724
+ uiScale: 1
2261
2725
  };
2262
2726
  var PROFILE_9x16 = {
2263
2727
  width: 1080,
@@ -2266,7 +2730,8 @@ var PROFILE_9x16 = {
2266
2730
  safeArea: { top: 230, right: 56, bottom: 320, left: 56 },
2267
2731
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2268
2732
  minFocusPx: 96,
2269
- captionMaxWidth: 980
2733
+ captionMaxWidth: 980,
2734
+ uiScale: 1
2270
2735
  };
2271
2736
  var PROFILE_1x1 = {
2272
2737
  width: 1080,
@@ -2275,7 +2740,8 @@ var PROFILE_1x1 = {
2275
2740
  safeArea: { top: 80, right: 72, bottom: 200, left: 72 },
2276
2741
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2277
2742
  minFocusPx: 100,
2278
- captionMaxWidth: 940
2743
+ captionMaxWidth: 940,
2744
+ uiScale: 1
2279
2745
  };
2280
2746
  function profileForAspect(aspect, resolution, fps) {
2281
2747
  const base = aspect === "9:16" ? PROFILE_9x16 : aspect === "1:1" ? PROFILE_1x1 : PROFILE_16x9;
@@ -2292,7 +2758,8 @@ function profileForAspect(aspect, resolution, fps) {
2292
2758
  left: Math.round(base.safeArea.left * scaleX),
2293
2759
  right: Math.round(base.safeArea.right * scaleX)
2294
2760
  },
2295
- captionMaxWidth: Math.round(base.captionMaxWidth * scaleX)
2761
+ captionMaxWidth: Math.round(base.captionMaxWidth * scaleX),
2762
+ uiScale: Math.min(resolution.w, resolution.h) / 1080
2296
2763
  };
2297
2764
  }
2298
2765
  function actReactWindows(step) {
@@ -2380,6 +2847,9 @@ function pacingConfig(mode, kind) {
2380
2847
  type: 450 * factor,
2381
2848
  press: 450 * factor,
2382
2849
  select: 600 * factor,
2850
+ rightclick: 700 * factor,
2851
+ upload: 800 * factor,
2852
+ drag: 700 * factor,
2383
2853
  scroll: 350 * factor,
2384
2854
  expect: 800 * factor,
2385
2855
  wait: 150
@@ -2404,10 +2874,37 @@ function buildTimeline(log2, cfg, audioMs) {
2404
2874
  const srcEnd = Math.max(e.tSettled, e.tActionEnd, e.tStart + 1);
2405
2875
  const spanSrc = srcEnd - e.tStart;
2406
2876
  let spanOut = spanSrc;
2407
- if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) spanOut = cfg.maxTypeOutMs;
2408
- segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
2409
- const outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
2410
- out += spanOut;
2877
+ let outBeat;
2878
+ if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) {
2879
+ const HEAD_MS = 1200;
2880
+ const TAIL_MS = 700;
2881
+ const headSrc = Math.min(HEAD_MS, spanSrc * 0.4);
2882
+ const tailSrc = Math.min(TAIL_MS, spanSrc * 0.25);
2883
+ const midSrc = spanSrc - headSrc - tailSrc;
2884
+ const midOut = Math.max(250, cfg.maxTypeOutMs - headSrc - tailSrc);
2885
+ segments.push({ kind: "source", outStart: out, outEnd: out + headSrc, srcStart: e.tStart, srcEnd: e.tStart + headSrc });
2886
+ segments.push({
2887
+ kind: "source",
2888
+ outStart: out + headSrc,
2889
+ outEnd: out + headSrc + midOut,
2890
+ srcStart: e.tStart + headSrc,
2891
+ srcEnd: e.tStart + headSrc + midSrc
2892
+ });
2893
+ segments.push({
2894
+ kind: "source",
2895
+ outStart: out + headSrc + midOut,
2896
+ outEnd: out + headSrc + midOut + tailSrc,
2897
+ srcStart: srcEnd - tailSrc,
2898
+ srcEnd
2899
+ });
2900
+ spanOut = headSrc + midOut + tailSrc;
2901
+ outBeat = out + Math.min(e.tAction - e.tStart, headSrc);
2902
+ out += spanOut;
2903
+ } else {
2904
+ segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
2905
+ outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
2906
+ out += spanOut;
2907
+ }
2411
2908
  let hold = cfg.holds[e.kind];
2412
2909
  if (out + hold - stepOutStart < cfg.minStepMs) hold = cfg.minStepMs - (out - stepOutStart);
2413
2910
  const aud = audioMs?.get(`${e.sceneId}/${e.stepIndex}`);
@@ -2436,6 +2933,7 @@ function buildTimeline(log2, cfg, audioMs) {
2436
2933
  ...e.focus ? { focus: e.focus } : {},
2437
2934
  ...e.shot ? { shot: e.shot } : {},
2438
2935
  ...e.focusTarget ? { focusRectVp: e.focusTarget.bbox } : {},
2936
+ ...e.targetPre && !e.targetPost ? { targetGone: true } : {},
2439
2937
  outStart: stepOutStart,
2440
2938
  outBeat,
2441
2939
  outEnd: out,
@@ -2462,6 +2960,18 @@ function sampleSource(segments, tOut) {
2462
2960
  }
2463
2961
  }
2464
2962
  }
2963
+ function outTimeForSrc(segments, tSrc) {
2964
+ for (const seg of segments) {
2965
+ if (seg.kind === "source" && tSrc >= seg.srcStart && tSrc <= seg.srcEnd) {
2966
+ const u = (tSrc - seg.srcStart) / Math.max(1e-6, seg.srcEnd - seg.srcStart);
2967
+ return seg.outStart + u * (seg.outEnd - seg.outStart);
2968
+ }
2969
+ if (seg.kind === "freeze" && Math.abs(seg.srcAt - tSrc) < 400) {
2970
+ return seg.outStart + (seg.outEnd - seg.outStart) / 2;
2971
+ }
2972
+ }
2973
+ return null;
2974
+ }
2465
2975
  function segmentAt(segments, tOut) {
2466
2976
  let lo = 0;
2467
2977
  let hi = segments.length - 1;
@@ -2495,13 +3005,48 @@ function sampleCamera(keyframes, tOut) {
2495
3005
  }
2496
3006
  const a = keyframes[lo];
2497
3007
  const b = keyframes[hi];
2498
- const u = (tOut - a.tOut) / Math.max(1e-6, b.tOut - a.tOut);
2499
- const e = cubicBezier(b.ease, u);
2500
- return {
2501
- cx: a.state.cx + (b.state.cx - a.state.cx) * e,
2502
- cy: a.state.cy + (b.state.cy - a.state.cy) * e,
2503
- zoom: Math.exp(Math.log(a.state.zoom) + (Math.log(b.state.zoom) - Math.log(a.state.zoom)) * e)
2504
- };
3008
+ const dt = Math.max(1e-6, b.tOut - a.tOut);
3009
+ const u = (tOut - a.tOut) / dt;
3010
+ if (sameState(a.state, b.state)) return { ...a.state };
3011
+ const va = [a.state.cx, a.state.cy, Math.log(a.state.zoom)];
3012
+ const vb = [b.state.cx, b.state.cy, Math.log(b.state.zoom)];
3013
+ const TENSION = 0.5;
3014
+ const ma = tangentAt(keyframes, lo, TENSION);
3015
+ const mb = tangentAt(keyframes, hi, TENSION);
3016
+ const isolated = ma.every((m) => m === 0) && mb.every((m) => m === 0);
3017
+ if (isolated) {
3018
+ const e = cubicBezier(b.ease, u);
3019
+ return {
3020
+ cx: va[0] + (vb[0] - va[0]) * e,
3021
+ cy: va[1] + (vb[1] - va[1]) * e,
3022
+ zoom: Math.exp(va[2] + (vb[2] - va[2]) * e)
3023
+ };
3024
+ }
3025
+ const u2 = u * u;
3026
+ const u3 = u2 * u;
3027
+ const h00 = 2 * u3 - 3 * u2 + 1;
3028
+ const h10 = u3 - 2 * u2 + u;
3029
+ const h01 = -2 * u3 + 3 * u2;
3030
+ const h11 = u3 - u2;
3031
+ const out = [0, 0, 0];
3032
+ for (let c = 0; c < 3; c++) {
3033
+ out[c] = h00 * va[c] + h10 * dt * ma[c] + h01 * vb[c] + h11 * dt * mb[c];
3034
+ }
3035
+ return { cx: out[0], cy: out[1], zoom: Math.exp(out[2]) };
3036
+ }
3037
+ function tangentAt(keyframes, i, tension) {
3038
+ const cur = keyframes[i];
3039
+ const prev = i > 0 ? keyframes[i - 1] : null;
3040
+ const next = i < keyframes.length - 1 ? keyframes[i + 1] : null;
3041
+ if (!prev || !next) return [0, 0, 0];
3042
+ if (sameState(prev.state, cur.state) || sameState(cur.state, next.state)) return [0, 0, 0];
3043
+ const dt = Math.max(1e-6, next.tOut - prev.tOut);
3044
+ const vp = [prev.state.cx, prev.state.cy, Math.log(prev.state.zoom)];
3045
+ const vn = [next.state.cx, next.state.cy, Math.log(next.state.zoom)];
3046
+ return [0, 1, 2].map((c) => tension * (vn[c] - vp[c]) / dt);
3047
+ }
3048
+ function sameState(a, b) {
3049
+ 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;
2505
3050
  }
2506
3051
 
2507
3052
  // src/compose/camera/planner.ts
@@ -2528,7 +3073,8 @@ function planCamera(steps, log2, profile, durationMs, opts) {
2528
3073
  if (quantums.length === 0) quantums.push(1);
2529
3074
  const navTimes = navigationEvents(log2).map((n) => n.t);
2530
3075
  const shots = groupShots(steps, navTimes, plane2, durationMs);
2531
- for (let attempt = 0; attempt < 4; attempt++) {
3076
+ const maxAttempts = Math.max(8, shots.length * (quantums.length + 1));
3077
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2532
3078
  for (const shot2 of shots) shot2.state = shotState(shot2, quantums, plane2, profile, emphasis);
2533
3079
  const keyframes = emitKeyframes(shots, plane2, durationMs);
2534
3080
  const violation = checkConstraints(keyframes, steps, plane2, profile);
@@ -2545,7 +3091,9 @@ function planCamera(steps, log2, profile, durationMs, opts) {
2545
3091
  fallbacks.push(`${violation.stepRef}: reduced shot zoom to satisfy framing constraints`);
2546
3092
  }
2547
3093
  }
3094
+ for (const shot of shots) shot.wide = true;
2548
3095
  for (const shot of shots) shot.state = shotState(shot, quantums, plane2, profile, emphasis);
3096
+ fallbacks.push("camera planner exhausted its attempt budget \u2014 all shots forced wide (check for conflicting focus hints)");
2549
3097
  return { keyframes: emitKeyframes(shots, plane2, durationMs), fallbacks };
2550
3098
  }
2551
3099
  function groupShots(steps, navTimes, plane2, durationMs) {
@@ -2620,6 +3168,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
2620
3168
  continue;
2621
3169
  }
2622
3170
  }
3171
+ const arriveBeat = step.kind === "goto" && rect ? Math.min(step.outEnd - 400, step.outBeat + Math.max(200, step.srcSettled - step.srcAction) + 150) : step.outBeat;
2623
3172
  current = {
2624
3173
  stepRefs: [step.stepRef],
2625
3174
  sceneId: step.sceneId,
@@ -2627,7 +3176,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
2627
3176
  minTargetDim: rect ? Math.min(rect.w, rect.h) : Math.min(plane2.vpW, plane2.vpH),
2628
3177
  wide,
2629
3178
  outStart: step.outStart,
2630
- firstBeat: step.outBeat,
3179
+ firstBeat: arriveBeat,
2631
3180
  outEnd: step.outEnd,
2632
3181
  zoomIndex: Number.MAX_SAFE_INTEGER
2633
3182
  // resolved in shotState
@@ -2857,6 +3406,12 @@ function writeCaption(event, texts) {
2857
3406
  return label ? `Enter the ${lowerFirst(label)}` : "Enter a value";
2858
3407
  case "press":
2859
3408
  return `Press ${event.locator ?? "the key"}`;
3409
+ case "rightclick":
3410
+ return label ? `Right-click \u201C${label}\u201D` : "Right-click the element";
3411
+ case "upload":
3412
+ return label ? `Upload a file to ${lowerFirst(label)}` : "Upload the file";
3413
+ case "drag":
3414
+ return label ? `Drag \u201C${label}\u201D into place` : "Drag the item into place";
2860
3415
  case "select":
2861
3416
  return label && event.typedText ? `Choose \u201C${event.typedText}\u201D under ${label}` : label ? `Choose an option under ${label}` : "Choose an option";
2862
3417
  case "goto":
@@ -3001,29 +3556,46 @@ function buildOverlays(steps, log2, theme, profile, title, subtitle, titleCardEn
3001
3556
  const text = kind.captionStyle === "factual" ? `Step ${number} \u2014 ${lowerFirst2(base)}` : base;
3002
3557
  const fontFor = (px) => `${px}px Inter Medium`;
3003
3558
  const showBadge = kind.numberedCaptions && kind.captionStyle !== "factual";
3004
- const badgeFontPx = 15;
3559
+ const ui = profile.uiScale;
3560
+ const padX = Math.round(CARD_PAD_X * ui);
3561
+ const padY = Math.round(CARD_PAD_Y * ui);
3562
+ const badgeFontPx = Math.max(11, Math.round(15 * ui));
3005
3563
  const badgeText = `${number}/${total}`;
3006
- const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + 20 : 0;
3007
- const badgeGap = showBadge ? BADGE_GAP : 0;
3008
- const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - CARD_PAD_X * 2 - badgeW - badgeGap;
3009
- const wrap = wrapText(text, fontFor, maxTextWidth, theme.captionFontPx, theme.captionMinFontPx, theme.captionMaxLines);
3564
+ const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + Math.round(20 * ui) : 0;
3565
+ const badgeGap = showBadge ? Math.round(BADGE_GAP * ui) : 0;
3566
+ const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - padX * 2 - badgeW - badgeGap;
3567
+ const wrap = wrapText(
3568
+ text,
3569
+ fontFor,
3570
+ maxTextWidth,
3571
+ Math.round(theme.captionFontPx * ui),
3572
+ Math.round(theme.captionMinFontPx * ui),
3573
+ theme.captionMaxLines
3574
+ );
3010
3575
  if (wrap.truncated) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "truncated" });
3011
3576
  else if (wrap.shrunk) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "shrunk" });
3012
3577
  const lineH = Math.round(wrap.fontPx * 1.35);
3013
- const cardW = CARD_PAD_X * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
3014
- const cardH = CARD_PAD_Y * 2 + wrap.lines.length * lineH;
3578
+ const cardW = padX * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
3579
+ const cardH = padY * 2 + wrap.lines.length * lineH;
3015
3580
  const x = (profile.width - cardW) / 2;
3016
- let y = theme.captionPosition === "bottom" ? profile.height - CARD_BOTTOM_MARGIN - cardH : profile.safeArea.top;
3581
+ let y = theme.captionPosition === "bottom" ? profile.height - Math.round(CARD_BOTTOM_MARGIN * ui) - cardH : profile.safeArea.top;
3017
3582
  if (camera && theme.captionPosition === "bottom") {
3018
- const rect = step.focusRectVp ?? step.targetRectVp;
3019
- if (rect) {
3020
- const content = stageContent(profile);
3021
- const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
3022
- const cam = sampleCamera(camera, Math.min(step.outBeat + 250, step.outEnd));
3023
- const proj = projectRect(rect, cam, capPlane);
3583
+ const content = stageContent(profile);
3584
+ const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
3585
+ const capBox = { x, y, w: cardW, h: cardH };
3586
+ const handoff = actReactWindows(step);
3587
+ const probes = handoff ? [
3588
+ { rect: handoff.act.rect, t: Math.min(step.outBeat + 250, handoff.act.to) },
3589
+ { rect: handoff.react.rect, t: handoff.react.from + 50 }
3590
+ ] : step.focusRectVp ?? step.targetRectVp ? [{ rect: step.focusRectVp ?? step.targetRectVp, t: Math.min(step.outBeat + 250, step.outEnd) }] : [];
3591
+ for (const probe of probes) {
3592
+ const cam = sampleCamera(camera, probe.t);
3593
+ const proj = projectRect(probe.rect, cam, capPlane);
3024
3594
  const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
3025
- const capBox = { x, y, w: cardW, h: cardH };
3026
- if (overlaps(projFrame, capBox)) y = profile.safeArea.top;
3595
+ if (overlaps(projFrame, capBox)) {
3596
+ y = profile.safeArea.top;
3597
+ break;
3598
+ }
3027
3599
  }
3028
3600
  }
3029
3601
  const next = captioned[i + 1];
@@ -3151,19 +3723,38 @@ function drawOverlays(ctx2, overlays, tOut, theme, profile) {
3151
3723
  if (bottomCaptions.length > 0) {
3152
3724
  const a = Math.max(...bottomCaptions.map((o) => captionAlpha(o, tOut)));
3153
3725
  if (a > 0) {
3154
- const g = ctx2.createLinearGradient(0, profile.height - SCRIM_H, 0, profile.height);
3726
+ const scrimH = Math.round(SCRIM_H * profile.uiScale);
3727
+ const g = ctx2.createLinearGradient(0, profile.height - scrimH, 0, profile.height);
3155
3728
  g.addColorStop(0, "rgba(8,10,16,0)");
3156
3729
  g.addColorStop(1, `rgba(8,10,16,${(0.55 * a).toFixed(3)})`);
3157
3730
  ctx2.fillStyle = g;
3158
- ctx2.fillRect(0, profile.height - SCRIM_H, profile.width, SCRIM_H);
3731
+ ctx2.fillRect(0, profile.height - scrimH, profile.width, scrimH);
3159
3732
  }
3160
3733
  }
3734
+ const activeCaps = overlays.filter(
3735
+ (o) => o.kind === "caption" && tOut >= o.tStart && tOut <= o.tEnd && captionAlpha(o, tOut) > 0
3736
+ );
3737
+ if (activeCaps.length === 2 && activeCaps[0].box.y === activeCaps[1].box.y) {
3738
+ const [a, b] = activeCaps[0].tStart <= activeCaps[1].tStart ? [activeCaps[0], activeCaps[1]] : [activeCaps[1], activeCaps[0]];
3739
+ const u = captionAlpha(b, tOut);
3740
+ const box = {
3741
+ x: a.box.x + (b.box.x - a.box.x) * u,
3742
+ y: a.box.y,
3743
+ w: a.box.w + (b.box.w - a.box.w) * u,
3744
+ h: Math.max(a.box.h, b.box.h)
3745
+ };
3746
+ drawCaptionCard(ctx2, box, 1, theme);
3747
+ drawCaptionContent(ctx2, a, box, 1 - u, theme);
3748
+ drawCaptionContent(ctx2, b, box, u, theme);
3749
+ } else {
3750
+ for (const o of activeCaps) drawCaption(ctx2, o, tOut, theme);
3751
+ }
3161
3752
  for (const o of overlays) {
3162
3753
  if (tOut < o.tStart || tOut > o.tEnd) continue;
3163
3754
  switch (o.kind) {
3164
3755
  case "caption":
3165
- drawCaption(ctx2, o, tOut, theme);
3166
3756
  break;
3757
+ // handled by the grouped pass above
3167
3758
  case "dip": {
3168
3759
  const u = (tOut - o.tStart) / Math.max(1, o.tEnd - o.tStart);
3169
3760
  ctx2.save();
@@ -3189,13 +3780,14 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
3189
3780
  const ms = Math.max(0, tOut - zeroAtMs);
3190
3781
  const s = Math.floor(ms / 1e3);
3191
3782
  const label = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}.${String(Math.floor(ms % 1e3 / 100))}`;
3192
- const font = "600 26px Inter SemiBold";
3783
+ const ui = profile.uiScale;
3784
+ const font = `600 ${Math.round(26 * ui)}px Inter SemiBold`;
3193
3785
  ctx2.save();
3194
3786
  ctx2.font = font;
3195
3787
  const textW = ctx2.measureText(label).width;
3196
- const padX = 16;
3197
- const w = textW + padX * 2 + 30;
3198
- const h = 44;
3788
+ const padX = 16 * ui;
3789
+ const w = textW + padX * 2 + 30 * ui;
3790
+ const h = 44 * ui;
3199
3791
  const x = profile.width - profile.safeArea.right - w;
3200
3792
  const y = profile.safeArea.top;
3201
3793
  ctx2.fillStyle = "rgba(18,22,31,0.82)";
@@ -3203,13 +3795,13 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
3203
3795
  ctx2.fill();
3204
3796
  ctx2.fillStyle = "#ef4444";
3205
3797
  ctx2.beginPath();
3206
- ctx2.arc(x + padX + 6, y + h / 2, 6, 0, Math.PI * 2);
3798
+ ctx2.arc(x + padX + 6 * ui, y + h / 2, 6 * ui, 0, Math.PI * 2);
3207
3799
  ctx2.fill();
3208
3800
  ctx2.fillStyle = "#ffffff";
3209
3801
  ctx2.textAlign = "left";
3210
3802
  ctx2.textBaseline = "middle";
3211
3803
  ctx2.font = font;
3212
- ctx2.fillText(label, x + padX + 24, y + h / 2 + 1);
3804
+ ctx2.fillText(label, x + padX + 24 * ui, y + h / 2 + 1);
3213
3805
  ctx2.restore();
3214
3806
  }
3215
3807
  function drawChapterLabel(ctx2, o, tOut, theme, profile) {
@@ -3217,23 +3809,24 @@ function drawChapterLabel(ctx2, o, tOut, theme, profile) {
3217
3809
  if (alpha <= 0) return;
3218
3810
  ctx2.save();
3219
3811
  ctx2.globalAlpha = alpha;
3220
- const font = "700 30px Inter Bold";
3812
+ const ui = profile.uiScale;
3813
+ const font = `700 ${Math.round(30 * ui)}px Inter Bold`;
3221
3814
  ctx2.font = font;
3222
3815
  const textW = ctx2.measureText(o.title).width;
3223
- const barW = textW + 96;
3224
- const barH = 56;
3816
+ const barW = textW + 96 * ui;
3817
+ const barH = 56 * ui;
3225
3818
  const x = (profile.width - barW) / 2;
3226
3819
  const y = profile.stage ? profile.stage.content.y + 14 : profile.safeArea.top + 8;
3227
3820
  ctx2.fillStyle = theme.accent;
3228
3821
  roundRect(ctx2, x, y, barW, barH, 12);
3229
3822
  ctx2.fill();
3230
3823
  ctx2.fillStyle = "#ffffff";
3231
- ctx2.font = "600 16px Inter SemiBold";
3824
+ ctx2.font = `600 ${Math.round(16 * ui)}px Inter SemiBold`;
3232
3825
  ctx2.textAlign = "left";
3233
3826
  ctx2.textBaseline = "middle";
3234
- ctx2.fillText(String(o.ordinal), x + 22, y + barH / 2 + 1);
3827
+ ctx2.fillText(String(o.ordinal), x + 22 * ui, y + barH / 2 + 1);
3235
3828
  ctx2.font = font;
3236
- ctx2.fillText(o.title, x + 48, y + barH / 2 + 1);
3829
+ ctx2.fillText(o.title, x + 48 * ui, y + barH / 2 + 1);
3237
3830
  ctx2.restore();
3238
3831
  }
3239
3832
  function captionAlpha(o, tOut) {
@@ -3242,23 +3835,36 @@ function captionAlpha(o, tOut) {
3242
3835
  function drawCaption(ctx2, o, tOut, theme) {
3243
3836
  const alpha = captionAlpha(o, tOut);
3244
3837
  if (alpha <= 0) return;
3838
+ ctx2.save();
3839
+ ctx2.globalAlpha = alpha;
3840
+ drawCaptionCard(ctx2, o.box, 1, theme);
3841
+ ctx2.restore();
3842
+ drawCaptionContent(ctx2, o, o.box, alpha, theme);
3843
+ }
3844
+ function drawCaptionCard(ctx2, box, alpha, theme) {
3245
3845
  ctx2.save();
3246
3846
  ctx2.globalAlpha = alpha;
3247
3847
  ctx2.shadowColor = "rgba(0,0,0,0.30)";
3248
3848
  ctx2.shadowBlur = 18;
3249
3849
  ctx2.shadowOffsetY = 4;
3250
3850
  ctx2.fillStyle = theme.surface;
3251
- roundRect(ctx2, o.box.x, o.box.y, o.box.w, o.box.h, theme.radius);
3851
+ roundRect(ctx2, box.x, box.y, box.w, box.h, theme.radius);
3252
3852
  ctx2.fill();
3253
- ctx2.shadowColor = "transparent";
3853
+ ctx2.restore();
3854
+ }
3855
+ function drawCaptionContent(ctx2, o, box, alpha, theme) {
3856
+ if (alpha <= 0) return;
3857
+ ctx2.save();
3858
+ ctx2.globalAlpha = alpha;
3254
3859
  let badgeW = 0;
3255
- const bx = o.box.x + 26;
3860
+ const bx = box.x + 26;
3256
3861
  if (o.showBadge) {
3257
3862
  const badgeText = `${o.ordinal}/${o.totalSteps}`;
3258
- const badgeFont = `600 15px Inter SemiBold`;
3259
- badgeW = Math.ceil(measureText(badgeText, badgeFont)) + 20;
3260
- const badgeH = 26;
3261
- const by = o.box.y + o.box.h / 2 - badgeH / 2;
3863
+ const bScale = o.fontPx / 36;
3864
+ const badgeFont = `600 ${Math.max(11, Math.round(15 * bScale))}px Inter SemiBold`;
3865
+ badgeW = Math.ceil(measureText(badgeText, badgeFont)) + Math.round(20 * bScale);
3866
+ const badgeH = Math.round(26 * bScale);
3867
+ const by = box.y + box.h / 2 - badgeH / 2;
3262
3868
  ctx2.fillStyle = theme.accent;
3263
3869
  roundRect(ctx2, bx, by, badgeW, badgeH, 13);
3264
3870
  ctx2.fill();
@@ -3270,7 +3876,7 @@ function drawCaption(ctx2, o, tOut, theme) {
3270
3876
  }
3271
3877
  const lineH = Math.round(o.fontPx * 1.35);
3272
3878
  const textX = o.showBadge ? bx + badgeW + 14 : bx;
3273
- const textTop = o.box.y + (o.box.h - o.lines.length * lineH) / 2;
3879
+ const textTop = box.y + (box.h - o.lines.length * lineH) / 2;
3274
3880
  ctx2.fillStyle = theme.onSurface;
3275
3881
  ctx2.font = `${o.fontPx}px Inter Medium`;
3276
3882
  ctx2.textAlign = "left";
@@ -3294,16 +3900,17 @@ function drawFailureCard(ctx2, card, tOut, theme, profile) {
3294
3900
  ctx2.fillStyle = "#ef4444";
3295
3901
  roundRect(ctx2, width / 2 - 32, height / 2 - 150, 64, 8, 4);
3296
3902
  ctx2.fill();
3903
+ const uiF = profile.uiScale;
3297
3904
  ctx2.fillStyle = "#ffffff";
3298
- ctx2.font = `52px Inter Bold`;
3905
+ ctx2.font = `${Math.round(52 * uiF)}px Inter Bold`;
3299
3906
  ctx2.textAlign = "center";
3300
3907
  ctx2.textBaseline = "middle";
3301
3908
  ctx2.fillText("Flow failed", width / 2, height / 2 - 70);
3302
3909
  ctx2.fillStyle = "#fca5a5";
3303
- ctx2.font = `600 30px Inter SemiBold`;
3910
+ ctx2.font = `600 ${Math.round(30 * uiF)}px Inter SemiBold`;
3304
3911
  ctx2.fillText(`at step ${card.stepRef}`, width / 2, height / 2 - 12);
3305
3912
  ctx2.fillStyle = "rgba(255,255,255,0.75)";
3306
- ctx2.font = `24px Inter`;
3913
+ ctx2.font = `${Math.round(24 * uiF)}px Inter`;
3307
3914
  const maxW = Math.min(1200, width - 200);
3308
3915
  const words = card.message.replace(/\s+/g, " ").split(" ");
3309
3916
  const lines = [];
@@ -3344,14 +3951,15 @@ function drawTitleCard(ctx2, card, tOut, theme, profile) {
3344
3951
  ctx2.fill();
3345
3952
  ctx2.globalAlpha = title.alpha;
3346
3953
  ctx2.fillStyle = "#ffffff";
3347
- ctx2.font = `56px Inter Bold`;
3954
+ const uiT = profile.uiScale;
3955
+ ctx2.font = `${Math.round(56 * uiT)}px Inter Bold`;
3348
3956
  ctx2.textAlign = "center";
3349
3957
  ctx2.textBaseline = "middle";
3350
3958
  ctx2.fillText(card.title, width / 2, height / 2 - 20 + title.rise);
3351
3959
  if (card.subtitle) {
3352
3960
  ctx2.globalAlpha = sub.alpha;
3353
3961
  ctx2.fillStyle = theme.onSurfaceDim;
3354
- ctx2.font = `26px Inter`;
3962
+ ctx2.font = `${Math.round(26 * uiT)}px Inter`;
3355
3963
  ctx2.fillText(card.subtitle, width / 2, height / 2 + 44 + sub.rise);
3356
3964
  }
3357
3965
  ctx2.restore();
@@ -3371,7 +3979,8 @@ var FrameStore = class {
3371
3979
  async frameForTime(tMs) {
3372
3980
  const idx = frameIndexForTime(this.bundle.frames, tMs);
3373
3981
  const canvas = await this.get(idx);
3374
- if (idx + 1 < this.bundle.frames.length) void this.get(idx + 1);
3982
+ if (idx + 1 < this.bundle.frames.length) this.get(idx + 1).catch(() => {
3983
+ });
3375
3984
  return canvas;
3376
3985
  }
3377
3986
  /**
@@ -3398,6 +4007,7 @@ var FrameStore = class {
3398
4007
  return hit;
3399
4008
  }
3400
4009
  const promise = this.decode(idx);
4010
+ promise.catch(() => this.cache.delete(idx));
3401
4011
  this.cache.set(idx, promise);
3402
4012
  while (this.cache.size > LRU_SIZE) {
3403
4013
  const oldest = this.cache.keys().next().value;
@@ -3523,12 +4133,19 @@ var Encoder = class {
3523
4133
  this.child.on("error", reject);
3524
4134
  this.child.on("close", (code) => resolve(code ?? -1));
3525
4135
  });
4136
+ this.child.stdin.on("error", () => {
4137
+ });
3526
4138
  }
3527
4139
  async write(rgba) {
3528
4140
  const stdin = this.child.stdin;
3529
4141
  if (!stdin.writable) throw new Error(`ffmpeg closed early: ${this.stderrTail.join("")}`);
3530
4142
  if (!stdin.write(rgba)) {
3531
- await once(stdin, "drain");
4143
+ await Promise.race([
4144
+ once(stdin, "drain"),
4145
+ this.exit.then((code) => {
4146
+ throw new Error(`ffmpeg exited (code ${code}) while the encoder awaited drain: ${this.stderrTail.join("")}`);
4147
+ })
4148
+ ]);
3532
4149
  }
3533
4150
  }
3534
4151
  async finish() {
@@ -3544,6 +4161,7 @@ var Encoder = class {
3544
4161
  var SPOTLIGHT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
3545
4162
  var SPOTLIGHT_LEAD_MS = 200;
3546
4163
  var SPOTLIGHT_TAIL_MS = 650;
4164
+ var SPOTLIGHT_TAIL_GONE_MS = 160;
3547
4165
  var SPOTLIGHT_MAX_DIM = 0.34;
3548
4166
  async function renderVideo(bundle, manifest, outPath) {
3549
4167
  registerFonts();
@@ -3575,7 +4193,7 @@ async function renderVideo(bundle, manifest, outPath) {
3575
4193
  );
3576
4194
  const transitions = manifest.transitions ?? [];
3577
4195
  const spotlightSteps = manifest.steps.filter(
3578
- (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.focusRectVp ?? s.targetRectVp)
4196
+ (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.targetRectVp ?? s.focusRectVp)
3579
4197
  );
3580
4198
  const start = Date.now();
3581
4199
  for (let i = 0; i < manifest.totalFrames; i++) {
@@ -3616,7 +4234,8 @@ async function renderVideo(bundle, manifest, outPath) {
3616
4234
  ctx2.globalAlpha = 1;
3617
4235
  };
3618
4236
  const pair = await store.framePairForTime(srcAt);
3619
- if (segment.kind === "source" && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
4237
+ const rate = segment.kind === "source" ? (segment.srcEnd - segment.srcStart) / Math.max(1, segment.outEnd - segment.outStart) : Infinity;
4238
+ if (segment.kind === "source" && rate < 1.5 && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
3620
4239
  drawSrc(pair.a, 1);
3621
4240
  drawSrc(pair.b, pair.mix);
3622
4241
  } else {
@@ -3632,7 +4251,7 @@ async function renderVideo(bundle, manifest, outPath) {
3632
4251
  }
3633
4252
  const spot = activeSpotlight(spotlightSteps, tOut);
3634
4253
  if (spot) {
3635
- const rect = spot.step.focusRectVp ?? spot.step.targetRectVp;
4254
+ const rect = spot.step.targetRectVp ?? spot.step.focusRectVp;
3636
4255
  const proj = projectRect(rect, cam, plane2);
3637
4256
  drawSpotlight(spotCtx, content, proj, spot.alpha);
3638
4257
  ctx2.drawImage(spotCanvas, content.x, content.y);
@@ -3728,11 +4347,12 @@ function displayUrl(appUrl) {
3728
4347
  }
3729
4348
  function activeSpotlight(steps, tOut) {
3730
4349
  for (const step of steps) {
4350
+ const tail = step.targetGone ? SPOTLIGHT_TAIL_GONE_MS : SPOTLIGHT_TAIL_MS;
3731
4351
  const from = step.outBeat - SPOTLIGHT_LEAD_MS;
3732
- const to = step.outBeat + SPOTLIGHT_TAIL_MS;
4352
+ const to = step.outBeat + tail;
3733
4353
  if (tOut < from || tOut > to) continue;
3734
4354
  const rampIn = clamp((tOut - from) / 180, 0, 1);
3735
- const rampOut = clamp((to - tOut) / 250, 0, 1);
4355
+ const rampOut = clamp((to - tOut) / Math.min(250, tail), 0, 1);
3736
4356
  return { step, alpha: SPOTLIGHT_MAX_DIM * Math.min(rampIn, rampOut) };
3737
4357
  }
3738
4358
  return null;
@@ -3758,33 +4378,178 @@ function cursorGlyphAt(steps, tOut) {
3758
4378
  return "arrow";
3759
4379
  }
3760
4380
 
3761
- // src/audio/tts.ts
3762
- import { execFile } from "child_process";
3763
- import { promisify } from "util";
3764
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
3765
- import { existsSync as existsSync2 } from "fs";
3766
- import { join as join5 } from "path";
3767
- import { createHash as createHash2 } from "crypto";
3768
- import { createRequire as createRequire4 } from "module";
3769
- var require3 = createRequire4(import.meta.url);
3770
- var ffprobePath2 = require3("ffprobe-static").path;
3771
- var exec = promisify(execFile);
3772
- var SayProvider = class {
3773
- constructor(voice, rate) {
3774
- this.voice = voice;
3775
- this.rate = rate;
3776
- }
3777
- voice;
3778
- rate;
3779
- name = "say";
3780
- async synth(text, outFile) {
3781
- const txt = `${outFile}.txt`;
3782
- await writeFile2(txt, text);
3783
- await exec("say", ["-v", this.voice, "-r", String(this.rate), "-f", txt, "-o", outFile]);
4381
+ // src/verify/media.ts
4382
+ import sharp3 from "sharp";
4383
+ async function extractGrayFrames(videoPath, fps, w, h) {
4384
+ const res = await runBinaryRaw(ffmpegPath(), [
4385
+ "-hide_banner",
4386
+ "-loglevel",
4387
+ "error",
4388
+ "-i",
4389
+ videoPath,
4390
+ "-vf",
4391
+ `fps=${fps},scale=${w}:${h}`,
4392
+ "-f",
4393
+ "rawvideo",
4394
+ "-pix_fmt",
4395
+ "gray",
4396
+ "pipe:1"
4397
+ ]);
4398
+ if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
4399
+ const frameSize = w * h;
4400
+ const frames = [];
4401
+ for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
4402
+ frames.push(res.stdout.subarray(off, off + frameSize));
3784
4403
  }
3785
- };
3786
- var KokoroProvider = class {
3787
- constructor(voice, speed) {
4404
+ return { frames, intervalMs: 1e3 / fps };
4405
+ }
4406
+ async function extractFrameAt(videoPath, tMs) {
4407
+ const res = await runBinaryRaw(ffmpegPath(), [
4408
+ "-hide_banner",
4409
+ "-loglevel",
4410
+ "error",
4411
+ "-ss",
4412
+ (Math.max(0, tMs) / 1e3).toFixed(3),
4413
+ "-i",
4414
+ videoPath,
4415
+ "-frames:v",
4416
+ "1",
4417
+ "-f",
4418
+ "image2pipe",
4419
+ "-vcodec",
4420
+ "png",
4421
+ "pipe:1"
4422
+ ]);
4423
+ if (res.code !== 0 || res.stdout.length === 0) {
4424
+ throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
4425
+ }
4426
+ return res.stdout;
4427
+ }
4428
+ async function probeVideo(videoPath) {
4429
+ const res = await runBinary(ffprobePath(), [
4430
+ "-v",
4431
+ "error",
4432
+ "-print_format",
4433
+ "json",
4434
+ "-show_format",
4435
+ "-show_streams",
4436
+ videoPath
4437
+ ]);
4438
+ if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
4439
+ const json = JSON.parse(res.stdout);
4440
+ const v = json.streams?.find((s) => s.codec_type === "video");
4441
+ if (!v) throw new Error("no video stream found");
4442
+ const [num, den] = v.avg_frame_rate.split("/").map(Number);
4443
+ return {
4444
+ codec: v.codec_name,
4445
+ width: v.width,
4446
+ height: v.height,
4447
+ durationSec: Number(json.format?.duration ?? 0),
4448
+ fps: den ? num / den : 0
4449
+ };
4450
+ }
4451
+ function meanAbsDiff(a, b) {
4452
+ const n = Math.min(a.length, b.length);
4453
+ let sum = 0;
4454
+ for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
4455
+ return sum / n;
4456
+ }
4457
+ function variance(a) {
4458
+ let sum = 0;
4459
+ for (let i = 0; i < a.length; i++) sum += a[i];
4460
+ const mean = sum / a.length;
4461
+ let v = 0;
4462
+ for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
4463
+ return v / a.length;
4464
+ }
4465
+ async function sourceFrameGray(bundle, tMs, width) {
4466
+ const idx = frameIndexForTime(bundle.frames, tMs);
4467
+ const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
4468
+ return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
4469
+ }
4470
+ async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
4471
+ let idx = frameIndexForTime(bundle.frames, tMs);
4472
+ if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
4473
+ const meta = await sharp3(framePath(bundle, idx)).metadata();
4474
+ const fw = meta.width ?? 0;
4475
+ const fh = meta.height ?? 0;
4476
+ const x = Math.max(0, Math.round(region.x));
4477
+ const y = Math.max(0, Math.round(region.y));
4478
+ const w = Math.min(Math.round(region.w), fw - x);
4479
+ const h = Math.min(Math.round(region.h), fh - y);
4480
+ if (w < 4 || h < 4) return null;
4481
+ let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
4482
+ if (!color) img = img.grayscale();
4483
+ const { data } = await img.raw().toBuffer({ resolveWithObject: true });
4484
+ return data;
4485
+ }
4486
+ async function regionGray48(input, rect) {
4487
+ try {
4488
+ const img = sharp3(input);
4489
+ const meta = await img.metadata();
4490
+ const W = meta.width ?? 0;
4491
+ const H = meta.height ?? 0;
4492
+ const left = Math.max(0, Math.round(rect.x));
4493
+ const top = Math.max(0, Math.round(rect.y));
4494
+ const width = Math.min(W - left, Math.round(rect.w));
4495
+ const height = Math.min(H - top, Math.round(rect.h));
4496
+ if (width < 8 || height < 8) return null;
4497
+ return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
4498
+ } catch {
4499
+ return null;
4500
+ }
4501
+ }
4502
+ function normalizedCorrelation(a, b) {
4503
+ const n = Math.min(a.length, b.length);
4504
+ let ma = 0;
4505
+ let mb = 0;
4506
+ for (let i = 0; i < n; i++) {
4507
+ ma += a[i];
4508
+ mb += b[i];
4509
+ }
4510
+ ma /= n;
4511
+ mb /= n;
4512
+ let num = 0;
4513
+ let da = 0;
4514
+ let db = 0;
4515
+ for (let i = 0; i < n; i++) {
4516
+ const xa = a[i] - ma;
4517
+ const xb = b[i] - mb;
4518
+ num += xa * xb;
4519
+ da += xa * xa;
4520
+ db += xb * xb;
4521
+ }
4522
+ if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
4523
+ return num / Math.sqrt(da * db);
4524
+ }
4525
+
4526
+ // src/audio/tts.ts
4527
+ import { execFile } from "child_process";
4528
+ import { promisify } from "util";
4529
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
4530
+ import { existsSync as existsSync3 } from "fs";
4531
+ import { join as join6 } from "path";
4532
+ import { createHash as createHash3 } from "crypto";
4533
+ import { createRequire as createRequire4 } from "module";
4534
+ var require3 = createRequire4(import.meta.url);
4535
+ var ffprobePath2 = require3("ffprobe-static").path;
4536
+ var exec = promisify(execFile);
4537
+ var SayProvider = class {
4538
+ constructor(voice, rate) {
4539
+ this.voice = voice;
4540
+ this.rate = rate;
4541
+ }
4542
+ voice;
4543
+ rate;
4544
+ name = "say";
4545
+ async synth(text, outFile) {
4546
+ const txt = `${outFile}.txt`;
4547
+ await writeFile2(txt, text);
4548
+ await exec("say", ["-v", this.voice, "-r", String(this.rate), "-f", txt, "-o", outFile]);
4549
+ }
4550
+ };
4551
+ var KokoroProvider = class {
4552
+ constructor(voice, speed) {
3788
4553
  this.voice = voice;
3789
4554
  this.speed = speed;
3790
4555
  }
@@ -3858,9 +4623,9 @@ async function synthesizeNarration(lines, opts, cacheDir) {
3858
4623
  const ext = provider.name === "kokoro" ? "wav" : "aiff";
3859
4624
  const out = /* @__PURE__ */ new Map();
3860
4625
  for (const { stepRef, text } of lines) {
3861
- const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
3862
- const file = join5(cacheDir, `${key}.${ext}`);
3863
- if (!existsSync2(file)) await provider.synth(text, file);
4626
+ const key = createHash3("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
4627
+ const file = join6(cacheDir, `${key}.${ext}`);
4628
+ if (!existsSync3(file)) await provider.synth(text, file);
3864
4629
  out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
3865
4630
  }
3866
4631
  return out;
@@ -3870,8 +4635,8 @@ async function synthesizeNarration(lines, opts, cacheDir) {
3870
4635
  import { execFile as execFile2 } from "child_process";
3871
4636
  import { promisify as promisify2 } from "util";
3872
4637
  import { rename, mkdir as mkdir3 } from "fs/promises";
3873
- import { existsSync as existsSync3 } from "fs";
3874
- import { join as join6 } from "path";
4638
+ import { existsSync as existsSync4 } from "fs";
4639
+ import { join as join7 } from "path";
3875
4640
  import { createRequire as createRequire5 } from "module";
3876
4641
  var require4 = createRequire5(import.meta.url);
3877
4642
  var ffmpegPath2 = require4("ffmpeg-static");
@@ -3879,8 +4644,9 @@ var exec2 = promisify2(execFile2);
3879
4644
  async function mixAudio(videoPath, steps, durationMs, opts) {
3880
4645
  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);
3881
4646
  const beats = opts.sfxBeats ?? [];
4647
+ const keyBeats = opts.keyBeats ?? [];
3882
4648
  const hasMusic = !!opts.music;
3883
- if (placed.length === 0 && beats.length === 0 && !hasMusic) return [];
4649
+ if (placed.length === 0 && beats.length === 0 && keyBeats.length === 0 && !hasMusic) return [];
3884
4650
  const durSec = (durationMs / 1e3).toFixed(3);
3885
4651
  const args = ["-y", "-loglevel", "error", "-i", videoPath];
3886
4652
  const inputs = [];
@@ -3918,6 +4684,21 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
3918
4684
  filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[sfx]`);
3919
4685
  inputs.push("[sfx]");
3920
4686
  }
4687
+ if (keyBeats.length > 0) {
4688
+ const key = await ensureKeySample(opts.cacheDir);
4689
+ args.push("-i", key);
4690
+ const idx = inputIdx++;
4691
+ const splits = keyBeats.map((_, i) => `[k${i}]`).join("");
4692
+ filters.push(`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,asplit=${keyBeats.length}${splits}`);
4693
+ const labels = [];
4694
+ keyBeats.forEach((t, i) => {
4695
+ const at = Math.max(0, Math.round(t));
4696
+ filters.push(`[k${i}]adelay=${at}|${at}[kk${i}]`);
4697
+ labels.push(`[kk${i}]`);
4698
+ });
4699
+ filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[keys]`);
4700
+ inputs.push("[keys]");
4701
+ }
3921
4702
  if (opts.music) {
3922
4703
  args.push("-stream_loop", "-1", "-i", opts.music.file);
3923
4704
  const idx = inputIdx++;
@@ -3961,10 +4742,28 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
3961
4742
  await rename(tmp, videoPath);
3962
4743
  return placed.map((p) => ({ stepRef: p.clip.stepRef, tStart: p.start, durMs: p.clip.durationMs }));
3963
4744
  }
4745
+ async function ensureKeySample(cacheDir) {
4746
+ await mkdir3(cacheDir, { recursive: true });
4747
+ const file = join7(cacheDir, "key.wav");
4748
+ if (existsSync4(file)) return file;
4749
+ await exec2(ffmpegPath2, [
4750
+ "-y",
4751
+ "-loglevel",
4752
+ "error",
4753
+ "-f",
4754
+ "lavfi",
4755
+ "-i",
4756
+ "sine=frequency=3400:duration=0.022",
4757
+ "-filter_complex",
4758
+ "[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",
4759
+ file
4760
+ ]);
4761
+ return file;
4762
+ }
3964
4763
  async function ensureClickSample(cacheDir) {
3965
4764
  await mkdir3(cacheDir, { recursive: true });
3966
- const file = join6(cacheDir, "click.wav");
3967
- if (existsSync3(file)) return file;
4765
+ const file = join7(cacheDir, "click.wav");
4766
+ if (existsSync4(file)) return file;
3968
4767
  await exec2(ffmpegPath2, [
3969
4768
  "-y",
3970
4769
  "-loglevel",
@@ -4003,7 +4802,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4003
4802
  const resolvedTheme = theme ?? resolveTheme(spec);
4004
4803
  const kind = kindProfile(spec.output.kind);
4005
4804
  let prof = profile ?? profileForAspect(spec.output.aspect, resolveResolution(spec), spec.output.fps);
4006
- if (kind.stage && !prof.stage) prof = withStage(prof, true);
4805
+ if (kind.stage && !prof.stage) prof = withStage(prof, true, bundle.manifest.viewport.w / bundle.manifest.viewport.h);
4007
4806
  const diagnostics = { overflows: [], cameraFallbacks: [], clockWarnings: [] };
4008
4807
  const cfg = pacingConfig(spec.output.pacing ?? kind.pacing, kind);
4009
4808
  const dwell = spec.output.dwellScale;
@@ -4014,7 +4813,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4014
4813
  }
4015
4814
  const timeline = buildTimeline(bundle.events, cfg, audioMs);
4016
4815
  const END_CARD_MS = 2400;
4017
- if (spec.output.endCard) {
4816
+ if (spec.output.endCard && !bundle.manifest.partial) {
4018
4817
  const endStart = timeline.durationMs;
4019
4818
  timeline.segments.push({ kind: "card", outStart: endStart, outEnd: endStart + END_CARD_MS, cardId: "end" });
4020
4819
  timeline.durationMs += END_CARD_MS;
@@ -4041,8 +4840,9 @@ function plan(bundle, spec, theme, profile, audioMs) {
4041
4840
  const prev = timeline.steps[i - 1];
4042
4841
  const cur = timeline.steps[i];
4043
4842
  if (cur.sceneId !== prev.sceneId) {
4044
- const tStart = cur.outStart;
4045
- transitions.push({ tStart, tEnd: tStart + 280, srcFrom: Math.max(0, prev.srcSettled - 1) });
4843
+ const tStart = prev.outEnd;
4844
+ const tEnd = Math.min(cur.outStart + 120, tStart + 340);
4845
+ transitions.push({ tStart, tEnd: Math.max(tEnd, tStart + 200), srcFrom: Math.max(0, prev.srcSettled - 1) });
4046
4846
  }
4047
4847
  }
4048
4848
  const subtitle = spec.subtitle ?? safeHost(bundle.manifest.appUrl);
@@ -4105,46 +4905,109 @@ function plan(bundle, spec, theme, profile, audioMs) {
4105
4905
  bundleHashes: bundle.manifest.hashes
4106
4906
  };
4107
4907
  }
4908
+ var BLINK_DIFF_MIN = 25;
4909
+ async function excludeTransientPreActionFrames(bundle) {
4910
+ const notes = [];
4911
+ const dpr = bundle.manifest.dpr;
4912
+ const excluded = /* @__PURE__ */ new Set();
4913
+ for (const e of actionEvents(bundle.events)) {
4914
+ if (e.kind !== "click" && e.kind !== "dblclick") continue;
4915
+ if (!e.targetPre || !e.cursorPath || e.cursorPath.length === 0) continue;
4916
+ const tArrive = e.cursorPath[e.cursorPath.length - 1].t;
4917
+ if (!(tArrive < e.tAction)) continue;
4918
+ const iArrive = frameIndexForTime(bundle.frames, tArrive);
4919
+ const iBeat = frameIndexForTime(bundle.frames, e.tAction);
4920
+ if (iBeat <= iArrive) continue;
4921
+ const b = e.targetPre.bbox;
4922
+ const region = { x: b.x * dpr, y: b.y * dpr, w: b.w * dpr, h: b.h * dpr };
4923
+ const ref = await sourceRegionGray(bundle, bundle.frames[iArrive].t, region);
4924
+ if (!ref) continue;
4925
+ for (let i = iArrive + 1; i <= iBeat; i++) {
4926
+ const cand = await sourceRegionGray(bundle, bundle.frames[i].t, region);
4927
+ if (!cand || cand.length !== ref.length) continue;
4928
+ if (meanAbsDiff(ref, cand) > BLINK_DIFF_MIN) {
4929
+ excluded.add(i);
4930
+ notes.push(
4931
+ `${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`
4932
+ );
4933
+ }
4934
+ }
4935
+ }
4936
+ if (excluded.size === 0) return { bundle, notes };
4937
+ return { bundle: { ...bundle, frames: bundle.frames.filter((_, i) => !excluded.has(i)) }, notes };
4938
+ }
4939
+ var JUMP_DIFF_MIN = 18;
4940
+ var JUMP_DISSOLVE_MS = 360;
4941
+ async function smoothJumpCuts(bundle, manifest) {
4942
+ for (const s of manifest.steps) {
4943
+ if (s.kind !== "click" && s.kind !== "dblclick" && s.kind !== "press" && s.kind !== "select") continue;
4944
+ const before = await sourceFrameGray(bundle, Math.max(0, s.srcAction - 30), 320);
4945
+ const after = await sourceFrameGray(bundle, Math.min(s.srcAction + 700, s.srcSettled), 320);
4946
+ if (after.frameT <= before.frameT || after.data.length !== before.data.length) continue;
4947
+ if (meanAbsDiff(before.data, after.data) < JUMP_DIFF_MIN) continue;
4948
+ const tStart = s.outBeat + 60;
4949
+ const tEnd = Math.min(tStart + JUMP_DISSOLVE_MS, s.outEnd);
4950
+ if ((manifest.transitions ?? []).some((w) => tStart <= w.tEnd && tEnd >= w.tStart)) continue;
4951
+ manifest.transitions = manifest.transitions ?? [];
4952
+ manifest.transitions.push({ tStart, tEnd, srcFrom: before.frameT });
4953
+ }
4954
+ }
4108
4955
  async function compose(bundle, spec, opts) {
4109
4956
  const audioCfg = spec.output.audio;
4110
4957
  let narration;
4111
4958
  let audioMs;
4959
+ let narrationDegraded;
4960
+ const voiceLabel = audioCfg?.voice ?? (audioCfg?.provider === "kokoro" ? "heart" : "Samantha");
4112
4961
  if (audioCfg && audioCfg.narration === "tts") {
4113
4962
  const texts = specStepTexts(spec);
4114
4963
  const lines = actionEvents(bundle.events).map((e) => {
4115
4964
  const ref = `${e.sceneId}/${e.stepIndex}`;
4116
4965
  return { stepRef: ref, text: writeNarration(e, texts.get(ref)) };
4117
4966
  }).filter((l) => typeof l.text === "string" && l.text.length > 0);
4118
- log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${audioCfg.voice ?? "Samantha"})`);
4967
+ log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${voiceLabel})`);
4119
4968
  try {
4120
- narration = await synthesizeNarration(lines, audioCfg, join7(opts.outDir, "narration"));
4969
+ narration = await synthesizeNarration(lines, audioCfg, join8(opts.outDir, "narration"));
4121
4970
  audioMs = new Map([...narration].map(([k, v]) => [k, v.durationMs]));
4122
4971
  } catch (e) {
4123
- log.warn(`narration unavailable on this host (${e.message.split("\n")[0]}) \u2014 rendering WITHOUT voice`);
4972
+ narrationDegraded = e.message.split("\n")[0] ?? "TTS unavailable";
4973
+ log.warn(`narration unavailable on this host (${narrationDegraded}) \u2014 rendering WITHOUT voice; the audio check will FAIL`);
4124
4974
  narration = void 0;
4125
4975
  audioMs = void 0;
4126
4976
  }
4127
4977
  }
4978
+ const transient = await excludeTransientPreActionFrames(bundle);
4979
+ bundle = transient.bundle;
4128
4980
  const manifest = plan(bundle, spec, opts.theme, opts.profile, audioMs);
4981
+ manifest.diagnostics.clockWarnings.push(...transient.notes);
4982
+ await smoothJumpCuts(bundle, manifest);
4129
4983
  for (const w of manifest.diagnostics.clockWarnings) log.warn(w);
4130
4984
  for (const f of manifest.diagnostics.cameraFallbacks) log.warn(f);
4131
- const videoPath = join7(opts.outDir, opts.fileName ?? "out.mp4");
4985
+ const videoPath = join8(opts.outDir, opts.fileName ?? "out.mp4");
4132
4986
  log.info(
4133
4987
  `composing ${(manifest.durationMs / 1e3).toFixed(1)}s (${manifest.totalFrames} frames @ ${manifest.profile.fps}fps, ${manifest.profile.width}x${manifest.profile.height})`
4134
4988
  );
4135
4989
  await renderVideo(bundle, manifest, videoPath);
4136
4990
  if (audioCfg && (narration || audioCfg.sfx || audioCfg.music)) {
4991
+ const keyBeats = [];
4992
+ if (audioCfg.sfx) {
4993
+ for (const s of manifest.steps) {
4994
+ if (s.kind !== "type") continue;
4995
+ const headEnd = Math.min(s.outEnd, s.outBeat + 1150);
4996
+ for (let t = s.outBeat + 40; t < headEnd; t += 78 + (t | 0) % 29) keyBeats.push(t);
4997
+ }
4998
+ }
4137
4999
  const clips = await mixAudio(videoPath, manifest.steps, manifest.durationMs, {
4138
5000
  ...narration ? { narration } : {},
4139
5001
  ...audioCfg.sfx ? { sfxBeats: manifest.ripples } : {},
5002
+ ...keyBeats.length > 0 ? { keyBeats } : {},
4140
5003
  ...audioCfg.music ? { music: audioCfg.music } : {},
4141
- cacheDir: join7(opts.outDir, "narration")
5004
+ cacheDir: join8(opts.outDir, "narration")
4142
5005
  });
4143
5006
  if (narration) {
4144
5007
  manifest.audio = {
4145
5008
  narrated: true,
4146
5009
  provider: audioCfg.provider,
4147
- voice: audioCfg.voice ?? "Samantha",
5010
+ voice: voiceLabel,
4148
5011
  lines: clips.length,
4149
5012
  clips
4150
5013
  };
@@ -4153,8 +5016,19 @@ async function compose(bundle, spec, opts) {
4153
5016
  `\u2713 audio: ${clips.length} narration line(s)${audioCfg.sfx ? `, ${manifest.ripples.length} click(s)` : ""}${audioCfg.music ? ", music bed" : ""} \u2192 ${videoPath}`
4154
5017
  );
4155
5018
  }
5019
+ if (narrationDegraded && !manifest.audio) {
5020
+ manifest.audio = {
5021
+ narrated: false,
5022
+ provider: audioCfg?.provider ?? "say",
5023
+ voice: voiceLabel,
5024
+ lines: 0,
5025
+ degraded: narrationDegraded
5026
+ };
5027
+ } else if (narrationDegraded && manifest.audio) {
5028
+ manifest.audio.degraded = narrationDegraded;
5029
+ }
4156
5030
  manifest.videoSha256 = await sha256File(videoPath);
4157
- const manifestPath = join7(opts.outDir, "compose-manifest.json");
5031
+ const manifestPath = join8(opts.outDir, "compose-manifest.json");
4158
5032
  await writeFile3(manifestPath, JSON.stringify(manifest, null, 2));
4159
5033
  return { videoPath, manifestPath, manifest };
4160
5034
  }
@@ -4170,154 +5044,9 @@ function safeHost(url) {
4170
5044
  }
4171
5045
 
4172
5046
  // src/verify/runner.ts
4173
- import { join as join8 } from "path";
5047
+ import { join as join9 } from "path";
4174
5048
  import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile4 } from "fs/promises";
4175
5049
 
4176
- // src/verify/media.ts
4177
- import sharp3 from "sharp";
4178
- async function extractGrayFrames(videoPath, fps, w, h) {
4179
- const res = await runBinaryRaw(ffmpegPath(), [
4180
- "-hide_banner",
4181
- "-loglevel",
4182
- "error",
4183
- "-i",
4184
- videoPath,
4185
- "-vf",
4186
- `fps=${fps},scale=${w}:${h}`,
4187
- "-f",
4188
- "rawvideo",
4189
- "-pix_fmt",
4190
- "gray",
4191
- "pipe:1"
4192
- ]);
4193
- if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
4194
- const frameSize = w * h;
4195
- const frames = [];
4196
- for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
4197
- frames.push(res.stdout.subarray(off, off + frameSize));
4198
- }
4199
- return { frames, intervalMs: 1e3 / fps };
4200
- }
4201
- async function extractFrameAt(videoPath, tMs) {
4202
- const res = await runBinaryRaw(ffmpegPath(), [
4203
- "-hide_banner",
4204
- "-loglevel",
4205
- "error",
4206
- "-ss",
4207
- (Math.max(0, tMs) / 1e3).toFixed(3),
4208
- "-i",
4209
- videoPath,
4210
- "-frames:v",
4211
- "1",
4212
- "-f",
4213
- "image2pipe",
4214
- "-vcodec",
4215
- "png",
4216
- "pipe:1"
4217
- ]);
4218
- if (res.code !== 0 || res.stdout.length === 0) {
4219
- throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
4220
- }
4221
- return res.stdout;
4222
- }
4223
- async function probeVideo(videoPath) {
4224
- const res = await runBinary(ffprobePath(), [
4225
- "-v",
4226
- "error",
4227
- "-print_format",
4228
- "json",
4229
- "-show_format",
4230
- "-show_streams",
4231
- videoPath
4232
- ]);
4233
- if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
4234
- const json = JSON.parse(res.stdout);
4235
- const v = json.streams?.find((s) => s.codec_type === "video");
4236
- if (!v) throw new Error("no video stream found");
4237
- const [num, den] = v.avg_frame_rate.split("/").map(Number);
4238
- return {
4239
- codec: v.codec_name,
4240
- width: v.width,
4241
- height: v.height,
4242
- durationSec: Number(json.format?.duration ?? 0),
4243
- fps: den ? num / den : 0
4244
- };
4245
- }
4246
- function meanAbsDiff(a, b) {
4247
- const n = Math.min(a.length, b.length);
4248
- let sum = 0;
4249
- for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
4250
- return sum / n;
4251
- }
4252
- function variance(a) {
4253
- let sum = 0;
4254
- for (let i = 0; i < a.length; i++) sum += a[i];
4255
- const mean = sum / a.length;
4256
- let v = 0;
4257
- for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
4258
- return v / a.length;
4259
- }
4260
- async function sourceFrameGray(bundle, tMs, width) {
4261
- const idx = frameIndexForTime(bundle.frames, tMs);
4262
- const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
4263
- return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
4264
- }
4265
- async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
4266
- let idx = frameIndexForTime(bundle.frames, tMs);
4267
- if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
4268
- const meta = await sharp3(framePath(bundle, idx)).metadata();
4269
- const fw = meta.width ?? 0;
4270
- const fh = meta.height ?? 0;
4271
- const x = Math.max(0, Math.round(region.x));
4272
- const y = Math.max(0, Math.round(region.y));
4273
- const w = Math.min(Math.round(region.w), fw - x);
4274
- const h = Math.min(Math.round(region.h), fh - y);
4275
- if (w < 4 || h < 4) return null;
4276
- let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
4277
- if (!color) img = img.grayscale();
4278
- const { data } = await img.raw().toBuffer({ resolveWithObject: true });
4279
- return data;
4280
- }
4281
- async function regionGray48(input, rect) {
4282
- try {
4283
- const img = sharp3(input);
4284
- const meta = await img.metadata();
4285
- const W = meta.width ?? 0;
4286
- const H = meta.height ?? 0;
4287
- const left = Math.max(0, Math.round(rect.x));
4288
- const top = Math.max(0, Math.round(rect.y));
4289
- const width = Math.min(W - left, Math.round(rect.w));
4290
- const height = Math.min(H - top, Math.round(rect.h));
4291
- if (width < 8 || height < 8) return null;
4292
- return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
4293
- } catch {
4294
- return null;
4295
- }
4296
- }
4297
- function normalizedCorrelation(a, b) {
4298
- const n = Math.min(a.length, b.length);
4299
- let ma = 0;
4300
- let mb = 0;
4301
- for (let i = 0; i < n; i++) {
4302
- ma += a[i];
4303
- mb += b[i];
4304
- }
4305
- ma /= n;
4306
- mb /= n;
4307
- let num = 0;
4308
- let da = 0;
4309
- let db = 0;
4310
- for (let i = 0; i < n; i++) {
4311
- const xa = a[i] - ma;
4312
- const xb = b[i] - mb;
4313
- num += xa * xb;
4314
- da += xa * xa;
4315
- db += xb * xb;
4316
- }
4317
- if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
4318
- return num / Math.sqrt(da * db);
4319
- }
4320
-
4321
5050
  // src/verify/checks.ts
4322
5051
  var EFFECT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "type", "select", "goto"]);
4323
5052
  var CLICK_KINDS2 = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
@@ -4397,7 +5126,47 @@ async function checkCursorOnTarget(ctx2) {
4397
5126
  }
4398
5127
  }
4399
5128
  if (checked === 0) return { id: "cursor-on-target", status: "skip", details: "no click steps" };
4400
- 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` };
5129
+ if (evidence.length > 0)
5130
+ return { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence };
5131
+ const p = plane(ctx2);
5132
+ const content = stageContent(ctx2.manifest.profile);
5133
+ const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
5134
+ const clickSteps = ctx2.manifest.steps.filter((s) => CLICK_KINDS2.has(s.kind) && s.targetRectVp);
5135
+ let sampled = 0;
5136
+ let present = 0;
5137
+ for (const step of clickSteps.slice(0, 2)) {
5138
+ const t = step.outBeat;
5139
+ const pos = sampleCursor(cursorPlan, t);
5140
+ const boxVp = { x: pos.x - 16, y: pos.y - 16, w: 32, h: 32 };
5141
+ const cam = sampleCamera(ctx2.manifest.camera, t);
5142
+ const proj = projectRect(boxVp, cam, p);
5143
+ const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
5144
+ const { srcAt } = sampleSource(ctx2.manifest.segments, t);
5145
+ if (srcAt === null) continue;
5146
+ const outPng = await extractFrameAt(ctx2.videoPath, t).catch(() => null);
5147
+ if (!outPng) continue;
5148
+ const [outRegion, srcRegion] = await Promise.all([
5149
+ regionGray48(outPng, projFrame),
5150
+ regionGray48(framePath(ctx2.bundle, frameIndexForTime(ctx2.bundle.frames, srcAt)), {
5151
+ x: boxVp.x * frameScale,
5152
+ y: boxVp.y * frameScale,
5153
+ w: boxVp.w * frameScale,
5154
+ h: boxVp.h * frameScale
5155
+ })
5156
+ ]);
5157
+ if (!outRegion || !srcRegion) continue;
5158
+ sampled += 1;
5159
+ if (meanAbsDiff(outRegion, srcRegion) > 6) present += 1;
5160
+ }
5161
+ if (sampled > 0 && present === 0) {
5162
+ return {
5163
+ id: "cursor-on-target",
5164
+ status: "fail",
5165
+ 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`
5166
+ };
5167
+ }
5168
+ const renderedNote = sampled > 0 ? `; sprite verified on film at ${present}/${sampled} sampled beat(s)` : "";
5169
+ return { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target${renderedNote}` };
4401
5170
  }
4402
5171
  async function checkActionEffect(ctx2) {
4403
5172
  const evidence = [];
@@ -4455,12 +5224,14 @@ async function checkActionEffect(ctx2) {
4455
5224
  }
4456
5225
  const maskedNote = skippedMasked > 0 ? ` (${skippedMasked} masked skipped)` : "";
4457
5226
  const inconcNote = inconclusive.length > 0 ? `, ${inconclusive.length} inconclusive (capture gap)` : "";
5227
+ const coverage = { verified: checked - evidence.length - inconclusive.length, total: checked + skippedMasked };
4458
5228
  if (evidence.length > 0) {
4459
5229
  return {
4460
5230
  id: "action-effect",
4461
5231
  status: "fail",
4462
5232
  details: `${evidence.length}/${checked} actions show no visible effect${maskedNote}${inconcNote}`,
4463
- evidence: [...evidence, ...inconclusive]
5233
+ evidence: [...evidence, ...inconclusive],
5234
+ coverage
4464
5235
  };
4465
5236
  }
4466
5237
  if (inconclusive.length > 0) {
@@ -4468,10 +5239,11 @@ async function checkActionEffect(ctx2) {
4468
5239
  id: "action-effect",
4469
5240
  status: "warn",
4470
5241
  details: `${checked - inconclusive.length}/${checked} actions visibly effective; ${inconclusive.length} unverified (capture gap)${maskedNote}`,
4471
- evidence: inconclusive
5242
+ evidence: inconclusive,
5243
+ coverage
4472
5244
  };
4473
5245
  }
4474
- return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}` };
5246
+ return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}`, coverage };
4475
5247
  }
4476
5248
  async function checkFrozenBlank(ctx2) {
4477
5249
  const { frames, intervalMs } = await extractGrayFrames(ctx2.videoPath, 2, 320, 180);
@@ -4479,11 +5251,23 @@ async function checkFrozenBlank(ctx2) {
4479
5251
  const evidence = [];
4480
5252
  const dips = ctx2.manifest.overlays.filter((o) => o.kind === "dip");
4481
5253
  const inTransition = (t) => segmentAt(ctx2.manifest.segments, t).kind === "card" || dips.some((d) => t >= d.tStart - 60 && t <= d.tEnd + 60);
5254
+ const centerBlank = [];
5255
+ const W = 320;
5256
+ const H = 180;
4482
5257
  for (let i = 0; i < frames.length; i++) {
4483
5258
  const t = i * intervalMs;
4484
5259
  if (inTransition(t)) continue;
4485
5260
  if (variance(frames[i]) < 2) {
4486
5261
  evidence.push({ t, note: `blank frame at ${(t / 1e3).toFixed(1)}s` });
5262
+ continue;
5263
+ }
5264
+ const f = frames[i];
5265
+ const center = [];
5266
+ for (let y = Math.floor(H * 0.3); y < Math.floor(H * 0.7); y++) {
5267
+ for (let x = Math.floor(W * 0.25); x < Math.floor(W * 0.75); x++) center.push(f[y * W + x]);
5268
+ }
5269
+ if (variance(Buffer.from(center)) < 3) {
5270
+ 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` });
4487
5271
  }
4488
5272
  }
4489
5273
  const maskedWindows = ctx2.manifest.steps.filter((s) => s.masked).map((s) => ({ start: s.outStart, end: s.outEnd }));
@@ -4518,7 +5302,18 @@ async function checkFrozenBlank(ctx2) {
4518
5302
  runStart = i;
4519
5303
  }
4520
5304
  }
4521
- 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` };
5305
+ if (evidence.length > 0) {
5306
+ return { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence: [...evidence, ...centerBlank] };
5307
+ }
5308
+ if (centerBlank.length > 0) {
5309
+ return {
5310
+ id: "frozen-blank",
5311
+ status: "warn",
5312
+ details: `frames live, but ${centerBlank.length} sampled frame(s) have a blank content region (mid-hydration footage?)`,
5313
+ evidence: centerBlank
5314
+ };
5315
+ }
5316
+ return { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
4522
5317
  }
4523
5318
  async function checkCaptions(ctx2) {
4524
5319
  const evidence = [];
@@ -4529,7 +5324,7 @@ async function checkCaptions(ctx2) {
4529
5324
  if (o.kind !== "caption") continue;
4530
5325
  count += 1;
4531
5326
  if (o.truncated) evidence.push({ stepRef: o.stepRef, note: `caption truncated: "${o.text}"` });
4532
- if (o.fontPx < ctx2.manifest.theme.captionMinFontPx)
5327
+ if (o.fontPx < Math.round(ctx2.manifest.theme.captionMinFontPx * ctx2.manifest.profile.uiScale))
4533
5328
  evidence.push({ stepRef: o.stepRef, note: `caption font ${o.fontPx}px below minimum` });
4534
5329
  if (!rectContains(out, o.box, 24)) evidence.push({ stepRef: o.stepRef, note: "caption box outside safe bounds" });
4535
5330
  if (o.shrunk && !o.truncated) warned += 1;
@@ -4537,7 +5332,35 @@ async function checkCaptions(ctx2) {
4537
5332
  if (count === 0) return { id: "captions", status: "skip", details: "no captions" };
4538
5333
  if (evidence.length > 0)
4539
5334
  return { id: "captions", status: "fail", details: `${evidence.length} caption problem(s)`, evidence };
4540
- 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` };
5335
+ const caps = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
5336
+ const stride = Math.max(1, Math.floor(caps.length / 3));
5337
+ let sampled = 0;
5338
+ let drawn = 0;
5339
+ for (let i = 0; i < caps.length && sampled < 3; i += stride) {
5340
+ const c = caps[i];
5341
+ const tOn = (c.tStart + c.tEnd) / 2;
5342
+ const tOff = c.tEnd + 500;
5343
+ const clashes = caps.some((o) => o !== c && tOff >= o.tStart - 100 && tOff <= o.tEnd + 100);
5344
+ if (clashes || tOff >= ctx2.manifest.durationMs - 200) continue;
5345
+ const [onPng, offPng] = await Promise.all([
5346
+ extractFrameAt(ctx2.videoPath, tOn).catch(() => null),
5347
+ extractFrameAt(ctx2.videoPath, tOff).catch(() => null)
5348
+ ]);
5349
+ if (!onPng || !offPng) continue;
5350
+ const [ra, rb] = await Promise.all([regionGray48(onPng, c.box), regionGray48(offPng, c.box)]);
5351
+ if (!ra || !rb) continue;
5352
+ sampled += 1;
5353
+ if (meanAbsDiff(ra, rb) > 4) drawn += 1;
5354
+ }
5355
+ if (sampled > 0 && drawn === 0) {
5356
+ return {
5357
+ id: "captions",
5358
+ status: "fail",
5359
+ details: `captions planned but NOT FOUND in the output pixels (${sampled} sampled boxes identical with and without caption)`
5360
+ };
5361
+ }
5362
+ const renderedNote = sampled > 0 ? `; ${drawn}/${sampled} sampled on film` : "";
5363
+ 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}` };
4541
5364
  }
4542
5365
  async function checkPacing(ctx2) {
4543
5366
  const evidence = [];
@@ -4553,7 +5376,20 @@ async function checkPacing(ctx2) {
4553
5376
  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` };
4554
5377
  }
4555
5378
  async function checkAudio(ctx2) {
4556
- if (!ctx2.manifest.audio?.narrated) return { id: "audio", status: "skip", details: "no narration" };
5379
+ if (ctx2.manifest.audio?.degraded) {
5380
+ return {
5381
+ id: "audio",
5382
+ status: "fail",
5383
+ details: `narration was requested but degraded to silence: ${ctx2.manifest.audio.degraded}`
5384
+ };
5385
+ }
5386
+ if (!ctx2.manifest.audio?.narrated) {
5387
+ return {
5388
+ id: "audio",
5389
+ status: "skip",
5390
+ details: "no narration \u2014 for a voice-over, set output.audio: { narration: tts } and re-compose"
5391
+ };
5392
+ }
4557
5393
  const evidence = [];
4558
5394
  const probe = await runBinary(ffprobePath(), [
4559
5395
  "-v",
@@ -4688,9 +5524,37 @@ async function checkMasks(ctx2) {
4688
5524
  if (evidence.length > 0) {
4689
5525
  return { id: "masks", status: "fail", details: `${evidence.length} mask violation(s)`, evidence };
4690
5526
  }
5527
+ const p = plane(ctx2);
5528
+ const content = stageContent(ctx2.manifest.profile);
5529
+ 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);
5530
+ let outSampled = 0;
5531
+ let outCovered = 0;
5532
+ for (const s of solidSamples) {
5533
+ const tOut = outTimeForSrc(ctx2.manifest.segments, s.t);
5534
+ if (tOut === null || tOut >= ctx2.manifest.durationMs - 100) continue;
5535
+ const cam = sampleCamera(ctx2.manifest.camera, tOut);
5536
+ const rectVp = { x: s.r.x / frameScale, y: s.r.y / frameScale, w: s.r.w / frameScale, h: s.r.h / frameScale };
5537
+ const proj = projectRect(rectVp, cam, p);
5538
+ const projFrame = { x: proj.x + content.x + 4, y: proj.y + content.y + 4, w: proj.w - 8, h: proj.h - 8 };
5539
+ if (projFrame.w < 12 || projFrame.h < 12) continue;
5540
+ const png = await extractFrameAt(ctx2.videoPath, tOut).catch(() => null);
5541
+ if (!png) continue;
5542
+ const region = await regionGray48(png, projFrame);
5543
+ if (!region) continue;
5544
+ outSampled += 1;
5545
+ if (Math.sqrt(variance(region)) < 16) outCovered += 1;
5546
+ }
5547
+ if (outSampled > 0 && outCovered === 0) {
5548
+ return {
5549
+ id: "masks",
5550
+ status: "fail",
5551
+ details: `masked regions verified in SOURCE frames but NOT covered in the delivered video (${outSampled} output sample(s) show content)`
5552
+ };
5553
+ }
4691
5554
  const parts = [];
4692
5555
  if (buckets.size > 0) parts.push(`${buckets.size} solid region(s) verified opaque`);
4693
5556
  if (blurKeys.size > 0) parts.push(`${blurKeys.size} blurred region(s) applied`);
5557
+ if (outSampled > 0) parts.push(`${outCovered}/${outSampled} re-verified in the output video`);
4694
5558
  return { id: "masks", status: "pass", details: `${parts.join(", ")}; log clean` };
4695
5559
  }
4696
5560
  function scaleRect(r, s) {
@@ -4748,7 +5612,7 @@ async function checkOutputPixels(ctx2) {
4748
5612
  }
4749
5613
  }
4750
5614
  if (checked === 0) return { id: "output-pixels", status: "skip", details: "no comparable samples (captions overlapped or frames unavailable)" };
4751
- 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` };
5615
+ 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 } };
4752
5616
  }
4753
5617
 
4754
5618
  // src/verify/contact-sheet.ts
@@ -4896,13 +5760,17 @@ function extractJson(text) {
4896
5760
  }
4897
5761
 
4898
5762
  // src/verify/report.ts
4899
- import { createHmac } from "crypto";
5763
+ import { createHmac, createHash as createHash4, sign as edSign, verify as edVerify, createPrivateKey, createPublicKey } from "crypto";
5764
+ import { readFileSync as readFileSync2 } from "fs";
4900
5765
  function signablePayload(v) {
4901
5766
  return JSON.stringify({
4902
5767
  schema: v.schema,
4903
5768
  verdict: v.verdict,
4904
5769
  videoSha256: v.video.sha256,
5770
+ manifestHash: v.manifestHash,
5771
+ contactSheetSha256: v.contactSheetSha256,
4905
5772
  bundleHashes: { events: v.bundleHashes.events, framesIndex: v.bundleHashes.framesIndex },
5773
+ coverage: v.coverage,
4906
5774
  provenance: {
4907
5775
  playheadVersion: v.provenance.playheadVersion,
4908
5776
  specHash: v.provenance.specHash,
@@ -4911,22 +5779,53 @@ function signablePayload(v) {
4911
5779
  verifiedAt: v.provenance.verifiedAt,
4912
5780
  checkSuiteVersion: v.provenance.checkSuiteVersion,
4913
5781
  host: v.provenance.host,
4914
- partial: v.provenance.partial ?? false
5782
+ partial: v.provenance.partial ?? false,
5783
+ failure: v.provenance.failure ?? null
4915
5784
  },
4916
- checks: v.checks.map((c) => ({ id: c.id, status: c.status }))
5785
+ checks: v.checks.map((c) => ({
5786
+ id: c.id,
5787
+ status: c.status,
5788
+ details: c.details,
5789
+ evidence: (c.evidence ?? []).map((e) => ({ stepRef: e.stepRef ?? null, t: e.t ?? null, note: e.note }))
5790
+ })),
5791
+ vision: v.vision
4917
5792
  });
4918
5793
  }
4919
- function signVerdict(v, key = process.env.PLAYHEAD_SIGNING_KEY) {
4920
- if (!key) return null;
4921
- return createHmac("sha256", key).update(signablePayload(v)).digest("hex");
5794
+ function signVerdict(v, env = {
5795
+ ...process.env.PLAYHEAD_SIGNING_KEY ? { hmacKey: process.env.PLAYHEAD_SIGNING_KEY } : {},
5796
+ ...process.env.PLAYHEAD_SIGNING_KEY_FILE ? { keyFile: process.env.PLAYHEAD_SIGNING_KEY_FILE } : {}
5797
+ }) {
5798
+ const payload = Buffer.from(signablePayload(v));
5799
+ if (env.keyFile) {
5800
+ const privateKey = createPrivateKey(readFileSync2(env.keyFile, "utf8"));
5801
+ const publicKey = createPublicKey(privateKey);
5802
+ const spki = publicKey.export({ type: "spki", format: "der" });
5803
+ return {
5804
+ alg: "Ed25519",
5805
+ keyId: keyIdFor(spki),
5806
+ publicKey: spki.toString("base64"),
5807
+ value: edSign(null, payload, privateKey).toString("base64")
5808
+ };
5809
+ }
5810
+ if (env.hmacKey) {
5811
+ return {
5812
+ alg: "HS256",
5813
+ keyId: keyIdFor(Buffer.from(env.hmacKey)),
5814
+ value: createHmac("sha256", env.hmacKey).update(payload).digest("hex")
5815
+ };
5816
+ }
5817
+ return null;
5818
+ }
5819
+ function keyIdFor(material) {
5820
+ return createHash4("sha256").update(material).digest("hex").slice(0, 16);
4922
5821
  }
4923
5822
 
4924
5823
  // src/verify/runner.ts
4925
5824
  import { platform, arch } from "os";
4926
5825
  import pc2 from "picocolors";
4927
- var CHECK_SUITE_VERSION = "playhead/checks@2";
5826
+ var CHECK_SUITE_VERSION = "playhead/checks@3";
4928
5827
  async function verify(bundle, manifest, videoPath, opts) {
4929
- const verifyDir = join8(opts.outDir, "verify");
5828
+ const verifyDir = join9(opts.outDir, "verify");
4930
5829
  await mkdir4(verifyDir, { recursive: true });
4931
5830
  const ctx2 = { bundle, manifest, videoPath, outDir: verifyDir };
4932
5831
  log.info("verifying output");
@@ -4950,7 +5849,7 @@ async function verify(bundle, manifest, videoPath, opts) {
4950
5849
  checks.push(result);
4951
5850
  report(result);
4952
5851
  }
4953
- const contactSheet = join8(verifyDir, "contact-sheet.png");
5852
+ const contactSheet = join9(verifyDir, "contact-sheet.png");
4954
5853
  await renderContactSheet(manifest, videoPath, contactSheet);
4955
5854
  log.ok(`contact sheet \u2192 ${contactSheet}`);
4956
5855
  let vision = null;
@@ -4961,11 +5860,28 @@ async function verify(bundle, manifest, videoPath, opts) {
4961
5860
  vision = review;
4962
5861
  }
4963
5862
  const failed = checks.some((c) => c.status === "fail");
5863
+ const effectCoverage = checks.find((c) => c.id === "action-effect")?.coverage;
5864
+ const pixelCoverage = checks.find((c) => c.id === "output-pixels")?.coverage;
5865
+ const coverage = {
5866
+ actionsVerified: effectCoverage?.verified ?? 0,
5867
+ actionsTotal: effectCoverage?.total ?? 0,
5868
+ outputSamplesVerified: pixelCoverage?.verified ?? 0,
5869
+ checksSkipped: checks.filter((c) => c.status === "skip").length,
5870
+ checksWarned: checks.filter((c) => c.status === "warn").length
5871
+ };
4964
5872
  const unsigned = {
4965
- schema: "playhead/verdict@2",
5873
+ schema: "playhead/verdict@3",
4966
5874
  verdict: failed ? "not-publishable" : "publishable",
4967
- video: { path: videoPath, sha256: manifest.videoSha256 ?? await sha256File(videoPath) },
5875
+ // ALWAYS hash the file the checks actually ran against — never trust the manifest's claim
5876
+ // (an unauthenticated JSON from disk). Round-2 audit: a doctored manifest could bind the
5877
+ // signed verdict to a video that was never verified.
5878
+ video: { path: videoPath, sha256: await sha256File(videoPath) },
4968
5879
  bundleHashes: manifest.bundleHashes,
5880
+ // The compose manifest is the oracle for the plan-side checks — hash it into the verdict
5881
+ // so a doctored manifest can no longer mint passes undetected.
5882
+ manifestHash: sha256Json(manifest),
5883
+ contactSheetSha256: await sha256File(contactSheet),
5884
+ coverage,
4969
5885
  provenance: {
4970
5886
  playheadVersion: PLAYHEAD_VERSION,
4971
5887
  specHash: bundle.manifest.specHash,
@@ -4983,9 +5899,10 @@ async function verify(bundle, manifest, videoPath, opts) {
4983
5899
  };
4984
5900
  const verdict = { ...unsigned, signature: signVerdict(unsigned) };
4985
5901
  if (!verdict.signature) log.debug("verdict unsigned \u2014 set PLAYHEAD_SIGNING_KEY to sign");
4986
- await writeFile5(join8(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
4987
- if (failed) log.error(`verdict: NOT PUBLISHABLE`);
4988
- else log.ok(`verdict: publishable`);
5902
+ await writeFile5(join9(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
5903
+ const cov = coverage.actionsTotal > 0 ? ` (coverage: ${coverage.actionsVerified}/${coverage.actionsTotal} actions pixel-verified, ${coverage.outputSamplesVerified} output samples)` : " (coverage: no effectful actions)";
5904
+ if (failed) log.error(`verdict: NOT PUBLISHABLE${cov}`);
5905
+ else log.ok(`verdict: publishable${cov}`);
4989
5906
  return verdict;
4990
5907
  }
4991
5908
  function report(c) {