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/cli/index.js CHANGED
@@ -10,10 +10,33 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/spec/locators.ts
13
+ function splitSegments(raw) {
14
+ const parts = [];
15
+ let cur = "";
16
+ let quote2 = null;
17
+ for (let i = 0; i < raw.length; i++) {
18
+ const ch = raw[i];
19
+ if (quote2) {
20
+ cur += ch;
21
+ if (ch === quote2) quote2 = null;
22
+ } else if (ch === '"' || ch === "'") {
23
+ quote2 = ch;
24
+ cur += ch;
25
+ } else if (ch === ">" && raw[i + 1] === ">") {
26
+ parts.push(cur.trim());
27
+ cur = "";
28
+ i += 1;
29
+ } else {
30
+ cur += ch;
31
+ }
32
+ }
33
+ parts.push(cur.trim());
34
+ return parts;
35
+ }
13
36
  function parseLocator(input) {
14
37
  const raw = input.trim();
15
38
  let nth;
16
- const parts = raw.split(">>").map((p) => p.trim());
39
+ const parts = splitSegments(raw);
17
40
  const frames = [];
18
41
  while (parts.length > 0 && parts[0].startsWith("frame=")) {
19
42
  const sel = unquote(parts.shift().slice("frame=".length).trim());
@@ -119,6 +142,16 @@ var init_locators = __esm({
119
142
  });
120
143
 
121
144
  // src/spec/schema.ts
145
+ var schema_exports = {};
146
+ __export(schema_exports, {
147
+ ASPECTS: () => ASPECTS,
148
+ editDistance: () => editDistance,
149
+ flattenSteps: () => flattenSteps,
150
+ resolveResolution: () => resolveResolution,
151
+ resolveViewport: () => resolveViewport,
152
+ specSchema: () => specSchema,
153
+ stepSchema: () => stepSchema
154
+ });
122
155
  import { z } from "zod";
123
156
  function nearestAction(key) {
124
157
  let best = null;
@@ -183,7 +216,7 @@ function flattenSteps(spec) {
183
216
  });
184
217
  return out;
185
218
  }
186
- var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
219
+ var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, rightclickStep, uploadStep, dragStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
187
220
  var init_schema = __esm({
188
221
  "src/spec/schema.ts"() {
189
222
  "use strict";
@@ -200,7 +233,7 @@ var init_schema = __esm({
200
233
  }
201
234
  });
202
235
  focusHint = z.union([z.literal("target"), z.literal("wide"), locatorString]);
203
- EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout"];
236
+ EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout", "optional"];
204
237
  stepExtras = {
205
238
  caption: z.string().optional(),
206
239
  /** The SPOKEN line for TTS narration — unconstrained by the caption card's size. Resolution:
@@ -212,7 +245,10 @@ var init_schema = __esm({
212
245
  * scene structure (scenes stay narrative). */
213
246
  shot: z.enum(["cut", "continue"]).optional(),
214
247
  /** Per-step budget override (ms) for finding the target / meeting the expectation. */
215
- timeout: z.number().int().positive().optional()
248
+ timeout: z.number().int().positive().optional(),
249
+ /** A failing optional step is SKIPPED (warned, unfilmed beat) instead of killing the whole
250
+ * capture — for cookie banners, A/B'd tooltips, and other environment noise. */
251
+ optional: z.boolean().optional()
216
252
  };
217
253
  targetOrShorthand = z.union([
218
254
  locatorString,
@@ -229,9 +265,15 @@ var init_schema = __esm({
229
265
  target: locatorString,
230
266
  text: z.string(),
231
267
  mask: z.boolean().default(false),
268
+ /** Select-all + overwrite instead of appending — editing a pre-filled field without this
269
+ * produces "Janenew value". */
270
+ clear: z.boolean().default(false),
232
271
  ...stepExtras
233
272
  }).strict();
234
273
  pressStep = z.object({ action: z.literal("press"), keys: z.string(), ...stepExtras }).strict();
274
+ rightclickStep = z.object({ action: z.literal("rightclick"), target: locatorString, ...stepExtras }).strict();
275
+ uploadStep = z.object({ action: z.literal("upload"), target: locatorString, file: z.string(), ...stepExtras }).strict();
276
+ dragStep = z.object({ action: z.literal("drag"), target: locatorString, to: locatorString, ...stepExtras }).strict();
235
277
  selectStep = z.object({
236
278
  action: z.literal("select"),
237
279
  target: locatorString,
@@ -246,13 +288,24 @@ var init_schema = __esm({
246
288
  }).strict();
247
289
  expectStep = z.object({
248
290
  action: z.literal("expect"),
249
- target: locatorString,
291
+ target: locatorString.optional(),
250
292
  visible: z.boolean().optional(),
251
293
  text: z.string().optional(),
252
294
  /** Exact number of matching elements (e.g. rows in a filtered table). */
253
295
  count: z.number().int().min(0).optional(),
296
+ /** Current page URL must CONTAIN this substring (or match when wrapped /like this/). */
297
+ url: z.string().optional(),
298
+ /** Form control's current value. */
299
+ value: z.string().optional(),
300
+ /** Element enabled/disabled and checked state. */
301
+ disabled: z.boolean().optional(),
302
+ checked: z.boolean().optional(),
254
303
  ...stepExtras
255
- }).strict();
304
+ }).strict().refine((s) => s.target !== void 0 || s.url !== void 0, {
305
+ message: "expect needs a target locator (element assertions) and/or a url"
306
+ }).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, {
307
+ message: "element assertions (visible/text/count/value/disabled/checked) need a target"
308
+ });
256
309
  waitStep = z.object({
257
310
  action: z.literal("wait"),
258
311
  ms: z.number().int().positive().optional(),
@@ -267,12 +320,15 @@ var init_schema = __esm({
267
320
  pointerStep,
268
321
  typeStep,
269
322
  pressStep,
323
+ rightclickStep,
324
+ uploadStep,
325
+ dragStep,
270
326
  selectStep,
271
327
  scrollStep,
272
328
  expectStep,
273
329
  waitStep
274
330
  ]);
275
- ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "select", "scroll", "expect", "wait"];
331
+ ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "rightclick", "upload", "drag", "select", "scroll", "expect", "wait"];
276
332
  authoredStep = z.record(z.string(), z.unknown()).superRefine((obj, ctx2) => {
277
333
  const actions = ACTION_KEYS.filter((k) => k in obj);
278
334
  if (actions.length !== 1) {
@@ -375,7 +431,10 @@ var init_schema = __esm({
375
431
  }).strict().refine(
376
432
  (a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
377
433
  { message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
378
- ).optional(),
434
+ ).refine((a) => a.provider !== "kokoro" || a.rate === void 0 || a.rate >= 80 && a.rate <= 140, {
435
+ 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",
436
+ path: ["rate"]
437
+ }).optional(),
379
438
  /** Closing card. When set, the video ends on a title-card-styled end card. */
380
439
  endCard: z.object({
381
440
  title: z.string().min(1),
@@ -394,13 +453,24 @@ var init_schema = __esm({
394
453
  style: z.enum(["solid", "blur"]).default("solid")
395
454
  }).strict()
396
455
  ).default([]),
456
+ /** Steps that run BEFORE recording starts and never appear on film — dismiss a cookie-consent
457
+ * banner, close a first-run tour, prime app state. Same step grammar as scenes. */
458
+ setup: z.array(authoredStep.pipe(stepSchema)).default([]),
397
459
  scenes: z.array(
398
460
  z.object({
399
461
  id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scene ids are lowercase kebab-case"),
400
462
  title: z.string().optional(),
401
463
  steps: z.array(authoredStep.pipe(stepSchema)).min(1)
402
464
  }).strict()
403
- ).min(1)
465
+ ).min(1).superRefine((scenes, ctx2) => {
466
+ const seen = /* @__PURE__ */ new Set();
467
+ scenes.forEach((s, i) => {
468
+ if (seen.has(s.id)) {
469
+ ctx2.addIssue({ code: "custom", path: [i, "id"], message: `duplicate scene id "${s.id}" \u2014 ids must be unique (check the extended base spec too)` });
470
+ }
471
+ seen.add(s.id);
472
+ });
473
+ })
404
474
  }).strict();
405
475
  ASPECTS = {
406
476
  "16:9": { viewport: { w: 1280, h: 720 }, resolution: { w: 1920, h: 1080 } },
@@ -411,6 +481,12 @@ var init_schema = __esm({
411
481
  });
412
482
 
413
483
  // src/spec/parse.ts
484
+ var parse_exports = {};
485
+ __export(parse_exports, {
486
+ SpecError: () => SpecError,
487
+ loadSpec: () => loadSpec,
488
+ parseSpec: () => parseSpec
489
+ });
414
490
  import { readFile } from "fs/promises";
415
491
  import { dirname, resolve as resolvePath } from "path";
416
492
  import { parseDocument, LineCounter } from "yaml";
@@ -444,7 +520,13 @@ async function loadSpec(path) {
444
520
  const text = await readFile(resolvePath(path), "utf8");
445
521
  const lineCounter = new LineCounter();
446
522
  const doc = parseDocument(text, { lineCounter });
447
- return validateResolved(interpolate(merged, path), path, doc, lineCounter);
523
+ const leaf = doc.toJS() ?? {};
524
+ const arrLen = (v) => Array.isArray(v) ? v.length : 0;
525
+ const offsets = {
526
+ scenes: arrLen(merged.scenes) - arrLen(leaf.scenes),
527
+ masking: arrLen(merged.masking) - arrLen(leaf.masking)
528
+ };
529
+ return validateResolved(interpolate(merged, path), path, doc, lineCounter, offsets);
448
530
  }
449
531
  async function loadRaw(path, depth) {
450
532
  if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
@@ -483,6 +565,28 @@ function interpolate(raw, sourcePath) {
483
565
  for (const [name, value] of Object.entries(varsIn)) {
484
566
  vars.set(name, resolveEnv(String(value), sourcePath));
485
567
  }
568
+ for (let pass = 0; pass < 6; pass++) {
569
+ let changed = false;
570
+ for (const [name, value] of vars) {
571
+ const next = value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (m, ref) => {
572
+ if (ref === name) throw new SpecError(`${sourcePath}: variable {{${name}}} references itself`);
573
+ const v = vars.get(ref);
574
+ return v !== void 0 && !v.includes(`{{${name}}}`) ? v : m;
575
+ });
576
+ if (next !== value) {
577
+ vars.set(name, next);
578
+ changed = true;
579
+ }
580
+ }
581
+ if (!changed) break;
582
+ if (pass === 5) throw new SpecError(`${sourcePath}: variable references did not resolve after 6 passes \u2014 circular vars?`);
583
+ }
584
+ for (const [name, value] of vars) {
585
+ const m = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(value);
586
+ if (m && vars.has(m[1])) {
587
+ throw new SpecError(`${sourcePath}: circular variable reference \u2014 {{${name}}} and {{${m[1]}}} depend on each other`);
588
+ }
589
+ }
486
590
  const doc = { ...raw };
487
591
  delete doc.vars;
488
592
  const seen = (s) => {
@@ -514,18 +618,18 @@ function resolveEnv(s, sourcePath) {
514
618
  throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
515
619
  });
516
620
  }
517
- function validateResolved(resolved, sourcePath, doc, lineCounter) {
621
+ function validateResolved(resolved, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
518
622
  const result = specSchema.safeParse(resolved);
519
623
  if (!result.success) {
520
- const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter));
624
+ const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter, offsets));
521
625
  throw new SpecError(`${sourcePath}: invalid spec
522
626
  - ${issues.join("\n - ")}`, issues);
523
627
  }
524
628
  return result.data;
525
629
  }
526
- function formatIssue(iss, sourcePath, doc, lineCounter) {
630
+ function formatIssue(iss, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
527
631
  const where = iss.path.length ? humanPath(iss.path) : "spec";
528
- const pos = positionOf(iss.path, doc, lineCounter);
632
+ const pos = positionOf(iss.path, doc, lineCounter, offsets);
529
633
  const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
530
634
  if (iss.code === "unrecognized_keys") {
531
635
  const keys = iss.keys;
@@ -534,7 +638,15 @@ function formatIssue(iss, sourcePath, doc, lineCounter) {
534
638
  }
535
639
  return `${at}${where}: ${iss.message}`;
536
640
  }
537
- function positionOf(path, doc, lineCounter) {
641
+ function positionOf(path, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
642
+ if ((path[0] === "scenes" || path[0] === "masking") && typeof path[1] === "number") {
643
+ const off = offsets[path[0]];
644
+ if (off > 0) {
645
+ const adjusted = path[1] - off;
646
+ if (adjusted < 0) return null;
647
+ path = [path[0], adjusted, ...path.slice(2)];
648
+ }
649
+ }
538
650
  for (let depth = path.length; depth > 0; depth--) {
539
651
  try {
540
652
  const node = doc.getIn(path.slice(0, depth), true);
@@ -573,6 +685,7 @@ var init_parse = __esm({
573
685
  "scenes",
574
686
  "vars",
575
687
  "extends",
688
+ "setup",
576
689
  "url",
577
690
  "viewport",
578
691
  "storageState",
@@ -643,7 +756,15 @@ var init_parse = __esm({
643
756
  "for",
644
757
  "state",
645
758
  "x",
646
- "y"
759
+ "y",
760
+ "optional",
761
+ "clear",
762
+ "to",
763
+ "disabled",
764
+ "checked",
765
+ "rightclick",
766
+ "upload",
767
+ "drag"
647
768
  ];
648
769
  }
649
770
  });
@@ -718,12 +839,52 @@ var init_log = __esm({
718
839
  }
719
840
  });
720
841
 
842
+ // src/shared/exit.ts
843
+ var exit_exports = {};
844
+ __export(exit_exports, {
845
+ EXIT: () => EXIT,
846
+ FlowError: () => FlowError,
847
+ InfraError: () => InfraError,
848
+ exitCodeFor: () => exitCodeFor
849
+ });
850
+ function exitCodeFor(e) {
851
+ if (e && typeof e === "object") {
852
+ if ("exitCode" in e && typeof e.exitCode === "number") {
853
+ return e.exitCode;
854
+ }
855
+ if (e.name === "SpecError" || e instanceof Object && e.constructor?.name === "SpecError") {
856
+ return EXIT.USAGE;
857
+ }
858
+ }
859
+ return EXIT.INFRA;
860
+ }
861
+ var EXIT, FlowError, InfraError;
862
+ var init_exit = __esm({
863
+ "src/shared/exit.ts"() {
864
+ "use strict";
865
+ EXIT = {
866
+ OK: 0,
867
+ FLOW: 1,
868
+ QUALITY: 2,
869
+ INFRA: 3,
870
+ USAGE: 4
871
+ };
872
+ FlowError = class extends Error {
873
+ exitCode = EXIT.FLOW;
874
+ };
875
+ InfraError = class extends Error {
876
+ exitCode = EXIT.INFRA;
877
+ };
878
+ }
879
+ });
880
+
721
881
  // src/capture/playwright-driver.ts
722
882
  var playwright_driver_exports = {};
723
883
  __export(playwright_driver_exports, {
724
884
  PlaywrightDriver: () => PlaywrightDriver
725
885
  });
726
886
  import { chromium } from "playwright";
887
+ import { access } from "fs/promises";
727
888
  function toPw(m) {
728
889
  if ("exact" in m) return m.exact;
729
890
  if ("substring" in m) return m.substring;
@@ -760,12 +921,31 @@ function withTimeout(p, ms) {
760
921
  );
761
922
  });
762
923
  }
924
+ function imageWidth(data, format) {
925
+ try {
926
+ if (format === "png") {
927
+ return data.length >= 24 ? data.readUInt32BE(16) : null;
928
+ }
929
+ let i = 2;
930
+ while (i + 9 < data.length) {
931
+ if (data[i] !== 255) return null;
932
+ const marker = data[i + 1];
933
+ if (marker >= 192 && marker <= 195) return data.readUInt16BE(i + 7);
934
+ const len = data.readUInt16BE(i + 2);
935
+ i += 2 + len;
936
+ }
937
+ return null;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
763
942
  var INPUT_TIMEOUT_MS, MASK_INIT_SCRIPT, FALLBACK_NAME_FN, COLLECT_INTERACTABLES_FN, PlaywrightDriver;
764
943
  var init_playwright_driver = __esm({
765
944
  "src/capture/playwright-driver.ts"() {
766
945
  "use strict";
767
946
  init_easing();
768
947
  init_log();
948
+ init_exit();
769
949
  INPUT_TIMEOUT_MS = 5e3;
770
950
  MASK_INIT_SCRIPT = `
771
951
  (() => {
@@ -808,11 +988,24 @@ var init_playwright_driver = __esm({
808
988
  // is tagged the frame it appears \u2014 not seconds later at the next step boundary. Node-side
809
989
  // tagging remains the backstop for rules needing Playwright semantics (role/label/text).
810
990
  let scanTick = 0;
991
+ // querySelectorAll does NOT pierce shadow roots \u2014 but Playwright's tagging does, so a masked
992
+ // element inside a web component (any design system) would be tagged yet never overlaid,
993
+ // filming the secret while masks.json claims coverage (round-2 audit). Walk shadow roots too.
994
+ const deepQuery = (sel) => {
995
+ const out = [];
996
+ const walk = (root) => {
997
+ try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch (e) {}
998
+ const all = root.querySelectorAll('*');
999
+ for (const el of all) if (el.shadowRoot) walk(el.shadowRoot);
1000
+ };
1001
+ walk(document);
1002
+ return out;
1003
+ };
811
1004
  const scanRules = () => {
812
1005
  const rules = window.__playheadMaskCssRules || [];
813
1006
  for (const r of rules) {
814
1007
  try {
815
- for (const el of document.querySelectorAll(r.css)) {
1008
+ for (const el of deepQuery(r.css)) {
816
1009
  if (!el.hasAttribute('data-playhead-mask')) el.setAttribute('data-playhead-mask', r.style);
817
1010
  }
818
1011
  } catch (e) {}
@@ -821,7 +1014,7 @@ var init_playwright_driver = __esm({
821
1014
  const tick = () => {
822
1015
  try {
823
1016
  if (scanTick++ % 3 === 0) scanRules(); // every ~3 frames \u2014 cheap, and a 1-frame leak beats a 1-step leak
824
- const tagged = new Set(document.querySelectorAll('[data-playhead-mask]'));
1017
+ const tagged = new Set(deepQuery('[data-playhead-mask]'));
825
1018
  for (const [el, box] of boxes) {
826
1019
  if (!tagged.has(el) || !el.isConnected) { box.remove(); boxes.delete(el); }
827
1020
  }
@@ -971,9 +1164,23 @@ var init_playwright_driver = __esm({
971
1164
  tagCounter = 0;
972
1165
  typingMaskLoc = null;
973
1166
  async launch(opts) {
1167
+ try {
1168
+ await access(chromium.executablePath());
1169
+ } catch {
1170
+ throw new InfraError(
1171
+ "Chromium is not installed (one-time setup) \u2014 run: npx playwright install chromium"
1172
+ );
1173
+ }
974
1174
  const env = opts.environment;
975
- this.browser = await chromium.launch({ headless: opts.headless ?? true });
1175
+ this.forcedDsf = opts.dpr > 1 && process.env.PLAYHEAD_CAPTURE !== "screenshot" ? opts.dpr : 1;
1176
+ this.browser = await chromium.launch({
1177
+ headless: opts.headless ?? true,
1178
+ ...this.forcedDsf > 1 ? { args: [`--force-device-scale-factor=${this.forcedDsf}`] } : {}
1179
+ });
1180
+ const chromeMajor = this.browser.version().split(".")[0] ?? this.browser.version();
1181
+ 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";
976
1182
  this.context = await this.browser.newContext({
1183
+ userAgent: `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`,
977
1184
  viewport: { width: opts.viewport.w, height: opts.viewport.h },
978
1185
  deviceScaleFactor: opts.dpr,
979
1186
  colorScheme: env?.colorScheme ?? "light",
@@ -1012,7 +1219,10 @@ var init_playwright_driver = __esm({
1012
1219
  });
1013
1220
  this.page.on("console", (msg) => this.pushConsole(msg.type(), msg.text()));
1014
1221
  this.page.on("pageerror", (err) => this.pushConsole("pageerror", err.message));
1015
- this.page.on("crash", () => this.pushConsole("crash", "page crashed"));
1222
+ this.page.on("crash", () => {
1223
+ this.pushConsole("crash", "page crashed");
1224
+ this.crashed = true;
1225
+ });
1016
1226
  const dialogPolicy = opts.dialogs ?? "accept";
1017
1227
  this.page.on("dialog", (dialog) => {
1018
1228
  this.pushConsole("dialog", `${dialog.type()}("${dialog.message()}") \u2192 ${dialogPolicy}`);
@@ -1029,6 +1239,12 @@ var init_playwright_driver = __esm({
1029
1239
  await this.page.mouse.move(this.cursor.x, this.cursor.y);
1030
1240
  }
1031
1241
  consoleBuf = [];
1242
+ /** Browser-level forced device scale (--force-device-scale-factor); 1 = not forced. */
1243
+ forcedDsf = 1;
1244
+ crashed = false;
1245
+ assertAlive() {
1246
+ if (this.crashed) throw new Error("page crashed \u2014 the browser renderer died (see console.json)");
1247
+ }
1032
1248
  pushConsole(type, text) {
1033
1249
  this.consoleBuf.push({ t: Date.now(), type, text: text.slice(0, 500) });
1034
1250
  if (this.consoleBuf.length > 500) this.consoleBuf.shift();
@@ -1146,6 +1362,7 @@ var init_playwright_driver = __esm({
1146
1362
  return l;
1147
1363
  }
1148
1364
  async resolveTarget(loc, timeoutMs) {
1365
+ this.assertAlive();
1149
1366
  const locator = this.toLocator(loc);
1150
1367
  try {
1151
1368
  await locator.waitFor({ state: "visible", timeout: timeoutMs });
@@ -1209,6 +1426,8 @@ var init_playwright_driver = __esm({
1209
1426
  if (loc.frames && loc.frames.length > 0) {
1210
1427
  const box = await withTimeout(locator.boundingBox(), 3e3).catch(() => null);
1211
1428
  if (box) geom.bbox = { x: box.x, y: box.y, w: box.width, h: box.height };
1429
+ const main = await withTimeout(this.page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })), 2e3).catch(() => null);
1430
+ if (main) geom.scroll = main;
1212
1431
  }
1213
1432
  let role = null;
1214
1433
  let name = null;
@@ -1300,12 +1519,69 @@ var init_playwright_driver = __esm({
1300
1519
  * through the checks a raw coordinate click bypasses (a toast/sticky header drifting under
1301
1520
  * the point between measure and click silently redirects a coordinate click).
1302
1521
  */
1522
+ async setInputFiles(loc, filePath) {
1523
+ await this.toLocator(loc).setInputFiles(filePath, { timeout: INPUT_TIMEOUT_MS * 2 });
1524
+ }
1525
+ /** Real drag: hover the source, press, glide to the destination in eased steps (recording
1526
+ * pressed-cursor waypoints for the film), release over the target's current position. */
1527
+ async dragTo(from, to, timeoutMs) {
1528
+ const src = this.toLocator(from);
1529
+ const dst = this.toLocator(to);
1530
+ await src.hover({ timeout: timeoutMs });
1531
+ const a = await src.boundingBox();
1532
+ if (!a) throw new Error(`drag source vanished: ${from.raw}`);
1533
+ const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 };
1534
+ const tDown = Date.now();
1535
+ await withTimeout(this.page.mouse.down(), INPUT_TIMEOUT_MS);
1536
+ const path = [{ x: start.x, y: start.y, t: tDown }];
1537
+ const b = await dst.boundingBox();
1538
+ if (!b) {
1539
+ await this.page.mouse.up().catch(() => {
1540
+ });
1541
+ throw new Error(`drag destination not found: ${to.raw}`);
1542
+ }
1543
+ const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
1544
+ const STEPS = 24;
1545
+ for (let i = 1; i <= STEPS; i++) {
1546
+ const u = minJerk(i / STEPS);
1547
+ const p = { x: start.x + (end.x - start.x) * u, y: start.y + (end.y - start.y) * u };
1548
+ await withTimeout(this.page.mouse.move(p.x, p.y), INPUT_TIMEOUT_MS);
1549
+ path.push({ x: p.x, y: p.y, t: Date.now() });
1550
+ await sleep(18);
1551
+ }
1552
+ const tUp = Date.now();
1553
+ await withTimeout(this.page.mouse.up(), INPUT_TIMEOUT_MS);
1554
+ this.cursor = end;
1555
+ return { tDown, tUp, path };
1556
+ }
1303
1557
  async actClick(loc, opts) {
1304
1558
  const locator = this.toLocator(loc);
1305
- const tDown = Date.now();
1559
+ await locator.hover({ timeout: opts?.timeoutMs ?? 1e4 }).catch(() => {
1560
+ });
1561
+ await withTimeout(
1562
+ this.page.evaluate(() => new Promise(requestAnimationFrame).then(() => new Promise(requestAnimationFrame))),
1563
+ 1200
1564
+ ).catch(() => {
1565
+ });
1566
+ await this.captureNow();
1567
+ const before = Date.now();
1568
+ await locator.evaluate((el) => {
1569
+ const w = el.ownerDocument.defaultView;
1570
+ if (w) {
1571
+ w.__phTDown = null;
1572
+ el.addEventListener("pointerdown", () => w.__phTDown = Date.now(), { once: true, capture: true });
1573
+ }
1574
+ }).catch(() => {
1575
+ });
1306
1576
  if (opts?.double) await locator.dblclick({ delay: 60, timeout: opts?.timeoutMs ?? 1e4 });
1307
- else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4 });
1308
- return { tDown, tUp: Date.now() };
1577
+ else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4, ...opts?.button ? { button: opts.button } : {} });
1578
+ const tUp = Date.now();
1579
+ const browserTDown = await withTimeout(
1580
+ locator.evaluate((el) => el.ownerDocument.defaultView?.__phTDown ?? null),
1581
+ 1500
1582
+ ).catch(() => null);
1583
+ const tDown = typeof browserTDown === "number" ? browserTDown - this.clockOffset : Math.max(before, tUp - 90);
1584
+ return { tDown, tUp };
1309
1585
  }
1310
1586
  async selectOption(loc, value) {
1311
1587
  const locator = this.toLocator(loc);
@@ -1314,8 +1590,6 @@ var init_playwright_driver = __esm({
1314
1590
  } catch {
1315
1591
  await locator.selectOption(value);
1316
1592
  }
1317
- await withTimeout(this.page.keyboard.press("Escape"), INPUT_TIMEOUT_MS).catch(() => {
1318
- });
1319
1593
  await withTimeout(
1320
1594
  locator.evaluate((el) => el.blur?.()),
1321
1595
  2e3
@@ -1351,6 +1625,22 @@ var init_playwright_driver = __esm({
1351
1625
  }
1352
1626
  }
1353
1627
  async expectState(loc, opts, timeoutMs) {
1628
+ if (opts.url !== void 0) {
1629
+ const want = opts.url;
1630
+ const matches = (u) => {
1631
+ const m = /^\/(.+)\/([a-z]*)$/.exec(want);
1632
+ return m ? new RegExp(m[1], m[2]).test(u) : u.includes(want);
1633
+ };
1634
+ const deadlineUrl = Date.now() + timeoutMs;
1635
+ while (!matches(this.page.url())) {
1636
+ if (Date.now() > deadlineUrl) {
1637
+ throw new Error(`Expectation not met for URL: expected ${want}, got ${this.page.url()}`);
1638
+ }
1639
+ await sleep(100);
1640
+ }
1641
+ if (!loc) return;
1642
+ }
1643
+ if (!loc) return;
1354
1644
  const locator = this.toLocator(loc);
1355
1645
  const deadline = Date.now() + timeoutMs;
1356
1646
  let lastErr = "condition not met";
@@ -1366,6 +1656,18 @@ var init_playwright_driver = __esm({
1366
1656
  const visible = await locator.first().isVisible();
1367
1657
  if (opts.visible !== void 0 && visible !== opts.visible) {
1368
1658
  lastErr = `expected visible=${opts.visible}, got ${visible}`;
1659
+ } else if (opts.value !== void 0) {
1660
+ const v = await locator.inputValue({ timeout: 1e3 }).catch(() => null);
1661
+ if (v === opts.value) return;
1662
+ lastErr = `expected value ${JSON.stringify(opts.value)}, got ${JSON.stringify(v)}`;
1663
+ } else if (opts.disabled !== void 0) {
1664
+ const d = await locator.isDisabled({ timeout: 1e3 }).catch(() => null);
1665
+ if (d === opts.disabled) return;
1666
+ lastErr = `expected disabled=${opts.disabled}, got ${d}`;
1667
+ } else if (opts.checked !== void 0) {
1668
+ const c = await locator.isChecked({ timeout: 1e3 }).catch(() => null);
1669
+ if (c === opts.checked) return;
1670
+ lastErr = `expected checked=${opts.checked}, got ${c}`;
1369
1671
  } else if (opts.text !== void 0) {
1370
1672
  const content = visible ? await locator.first().innerText() : "";
1371
1673
  if (!content.includes(opts.text)) {
@@ -1407,6 +1709,7 @@ var init_playwright_driver = __esm({
1407
1709
  }
1408
1710
  static LONG_REQUEST_MS = 2e3;
1409
1711
  async settle(opts) {
1712
+ this.assertAlive();
1410
1713
  this.installNetTracking();
1411
1714
  const start = Date.now();
1412
1715
  let capped = true;
@@ -1449,7 +1752,20 @@ var init_playwright_driver = __esm({
1449
1752
  })
1450
1753
  ),
1451
1754
  1800
1452
- ).catch(() => {
1755
+ ).catch(async (e) => {
1756
+ if (/context.*destroyed|navigat/i.test(e.message ?? "")) {
1757
+ await this.page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
1758
+ });
1759
+ await withTimeout(
1760
+ this.page.evaluate(
1761
+ () => new Promise((resolve3) => {
1762
+ setTimeout(resolve3, 400);
1763
+ })
1764
+ ),
1765
+ 1e3
1766
+ ).catch(() => {
1767
+ });
1768
+ }
1453
1769
  });
1454
1770
  await this.nextFrame();
1455
1771
  }
@@ -1506,10 +1822,56 @@ var init_playwright_driver = __esm({
1506
1822
  // Headless Chromium's CDP screencast is hard-locked to CSS-pixel resolution and ignores
1507
1823
  // deviceScaleFactor, so a paced Page.captureScreenshot loop with clip.scale is what actually
1508
1824
  // yields 2x frames (the zoom headroom the camera planner needs).
1825
+ screencastActive = false;
1509
1826
  async startCapture(onFrame, opts) {
1510
1827
  this.captureOnFrame = onFrame;
1511
1828
  this.captureOpts = opts;
1512
1829
  this.captureActive = true;
1830
+ if (process.env.PLAYHEAD_CAPTURE !== "screenshot") {
1831
+ try {
1832
+ const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
1833
+ const expectedW = Math.round(vp.width * opts.scale);
1834
+ let sizeChecked = false;
1835
+ this.captureCdp.on("Page.screencastFrame", (ev) => {
1836
+ void this.captureCdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
1837
+ });
1838
+ if (!this.captureActive || !this.captureOnFrame || !this.screencastActive) return;
1839
+ const data = Buffer.from(ev.data, "base64");
1840
+ if (!sizeChecked) {
1841
+ sizeChecked = true;
1842
+ const w = imageWidth(data, opts.format);
1843
+ if (w !== null && w < expectedW * 0.9) {
1844
+ log.warn(
1845
+ `screencast emits ${w}px-wide frames (need ${expectedW} for zoom headroom) \u2014 reverting to the 2x screenshot loop`
1846
+ );
1847
+ this.screencastActive = false;
1848
+ void withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1849
+ });
1850
+ this.startScreenshotLoop(opts);
1851
+ return;
1852
+ }
1853
+ }
1854
+ const tNodeMs = ev.metadata?.timestamp ? ev.metadata.timestamp * 1e3 - this.clockOffset : Date.now();
1855
+ this.lastFrameNodeMs = tNodeMs;
1856
+ this.captureOnFrame({ data, tNodeMs });
1857
+ });
1858
+ await this.captureCdp.send("Page.startScreencast", {
1859
+ format: opts.format,
1860
+ quality: opts.quality,
1861
+ maxWidth: expectedW,
1862
+ maxHeight: Math.round(vp.height * opts.scale),
1863
+ // Compositor paints at up to ~60; halve toward the requested rate.
1864
+ everyNthFrame: Math.max(1, Math.round(60 / Math.max(15, opts.fps * 1.25)))
1865
+ });
1866
+ this.screencastActive = true;
1867
+ return;
1868
+ } catch (e) {
1869
+ log.warn(`screencast unavailable (${e.message.split("\n")[0]}) \u2014 falling back to the paced screenshot loop`);
1870
+ }
1871
+ }
1872
+ this.startScreenshotLoop(opts);
1873
+ }
1874
+ startScreenshotLoop(opts) {
1513
1875
  const intervalMs = 1e3 / opts.fps;
1514
1876
  this.captureLoop = (async () => {
1515
1877
  while (this.captureActive) {
@@ -1520,7 +1882,8 @@ var init_playwright_driver = __esm({
1520
1882
  }
1521
1883
  })();
1522
1884
  }
1523
- /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one). */
1885
+ /** Grab a frame, coalescing on any in-flight grab (so callers can await the current one).
1886
+ * Resolves true iff a frame was actually stored. */
1524
1887
  grabFrame() {
1525
1888
  if (this.inflightGrab) return this.inflightGrab;
1526
1889
  this.inflightGrab = this.doGrab().finally(() => {
@@ -1530,7 +1893,7 @@ var init_playwright_driver = __esm({
1530
1893
  }
1531
1894
  grabFailures = 0;
1532
1895
  async doGrab() {
1533
- if (!this.captureOpts || !this.captureOnFrame) return;
1896
+ if (!this.captureOpts || !this.captureOnFrame) return false;
1534
1897
  try {
1535
1898
  const opts = this.captureOpts;
1536
1899
  const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
@@ -1539,7 +1902,9 @@ var init_playwright_driver = __esm({
1539
1902
  this.captureCdp.send("Page.captureScreenshot", {
1540
1903
  format: opts.format,
1541
1904
  quality: opts.quality,
1542
- clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale },
1905
+ // Under --force-device-scale-factor the surface is already scaled divide it out
1906
+ // or screenshots come back double-scaled (5120-wide).
1907
+ clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale / this.forcedDsf },
1543
1908
  captureBeyondViewport: false
1544
1909
  }),
1545
1910
  1500
@@ -1548,7 +1913,9 @@ var init_playwright_driver = __esm({
1548
1913
  this.lastFrameNodeMs = tNodeMs;
1549
1914
  this.grabFailures = 0;
1550
1915
  this.captureOnFrame({ data: Buffer.from(shot.data, "base64"), tNodeMs });
1551
- } catch {
1916
+ return true;
1917
+ } catch (err) {
1918
+ log.debug(`frame grab failed: ${err.message.split("\n")[0]}`);
1552
1919
  this.grabFailures += 1;
1553
1920
  if (this.grabFailures >= 2) {
1554
1921
  try {
@@ -1560,6 +1927,7 @@ var init_playwright_driver = __esm({
1560
1927
  } catch {
1561
1928
  }
1562
1929
  }
1930
+ return false;
1563
1931
  }
1564
1932
  }
1565
1933
  async readScroll() {
@@ -1573,6 +1941,11 @@ var init_playwright_driver = __esm({
1573
1941
  }
1574
1942
  async stopCapture() {
1575
1943
  this.captureActive = false;
1944
+ if (this.screencastActive) {
1945
+ this.screencastActive = false;
1946
+ await withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
1947
+ });
1948
+ }
1576
1949
  await this.captureLoop?.catch(() => {
1577
1950
  });
1578
1951
  this.captureLoop = null;
@@ -1581,9 +1954,12 @@ var init_playwright_driver = __esm({
1581
1954
  return this.lastFrameNodeMs;
1582
1955
  }
1583
1956
  async captureNow() {
1584
- if (this.inflightGrab) await this.inflightGrab.catch(() => {
1585
- });
1586
- await this.grabFrame();
1957
+ if (this.inflightGrab) await this.inflightGrab.catch(() => false);
1958
+ for (let i = 0; i < 3; i++) {
1959
+ if (await this.grabFrame()) return;
1960
+ await sleep(120);
1961
+ }
1962
+ log.warn("captureNow: no frame stored after 3 attempts \u2014 footage may hold a stale state here");
1587
1963
  }
1588
1964
  };
1589
1965
  }
@@ -1609,18 +1985,49 @@ var init_hash = __esm({
1609
1985
 
1610
1986
  // src/shared/version.ts
1611
1987
  import { createRequire } from "module";
1988
+ import { fileURLToPath } from "url";
1989
+ import { dirname as dirname2, join } from "path";
1990
+ import { existsSync, readFileSync } from "fs";
1991
+ function resolveOwnPackageJson() {
1992
+ let dir = dirname2(fileURLToPath(import.meta.url));
1993
+ for (let i = 0; i < 6; i++) {
1994
+ const p = join(dir, "package.json");
1995
+ if (existsSync(p)) {
1996
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
1997
+ if (pkg.name === "playhead-cli" || pkg.name === "playhead") return { version: pkg.version ?? "0.0.0" };
1998
+ }
1999
+ const parent = dirname2(dir);
2000
+ if (parent === dir) break;
2001
+ dir = parent;
2002
+ }
2003
+ try {
2004
+ return createRequire(import.meta.url)("../../package.json");
2005
+ } catch {
2006
+ return { version: "0.0.0" };
2007
+ }
2008
+ }
1612
2009
  var PLAYHEAD_VERSION;
1613
2010
  var init_version = __esm({
1614
2011
  "src/shared/version.ts"() {
1615
2012
  "use strict";
1616
- PLAYHEAD_VERSION = createRequire(import.meta.url)("../../package.json").version;
2013
+ PLAYHEAD_VERSION = resolveOwnPackageJson().version;
1617
2014
  }
1618
2015
  });
1619
2016
 
1620
2017
  // src/bundle/writer.ts
1621
2018
  import { mkdir, writeFile } from "fs/promises";
1622
- import { join } from "path";
2019
+ import { createHash as createHash2 } from "crypto";
2020
+ import { join as join2 } from "path";
1623
2021
  import sharp from "sharp";
2022
+ function redactMaskedText(spec) {
2023
+ const copy = JSON.parse(JSON.stringify(spec));
2024
+ for (const scene of copy.scenes) {
2025
+ for (const step of scene.steps) {
2026
+ if (step.action === "type" && step.mask && step.text) step.text = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
2027
+ }
2028
+ }
2029
+ return copy;
2030
+ }
1624
2031
  function round1(n) {
1625
2032
  return Math.round(n * 10) / 10;
1626
2033
  }
@@ -1630,11 +2037,12 @@ var init_writer = __esm({
1630
2037
  "use strict";
1631
2038
  init_hash();
1632
2039
  init_version();
2040
+ init_log();
1633
2041
  BundleWriter = class {
1634
2042
  constructor(dir, captureFormat) {
1635
2043
  this.dir = dir;
1636
2044
  this.captureFormat = captureFormat;
1637
- this.ready = mkdir(join(dir, "frames"), { recursive: true }).then(() => {
2045
+ this.ready = mkdir(join2(dir, "frames"), { recursive: true }).then(() => {
1638
2046
  });
1639
2047
  }
1640
2048
  dir;
@@ -1643,14 +2051,22 @@ var init_writer = __esm({
1643
2051
  pendingWrites = [];
1644
2052
  maskSamples = [];
1645
2053
  counter = 0;
2054
+ failedFrames = /* @__PURE__ */ new Set();
2055
+ writeError;
1646
2056
  ready;
1647
- /** Queue a frame write. `t` is capture-relative ms. */
2057
+ /** Queue a frame write. `t` is capture-relative ms. Each frame's BYTES are hashed at write
2058
+ * time — the index entry carries the digest, so the framesIndex hash transitively covers
2059
+ * every pixel in the bundle. (Round-2 audit: filename+timestamp hashing left frame images
2060
+ * freely swappable under a passing attest.) */
1648
2061
  addFrame(data, t) {
1649
2062
  this.counter += 1;
1650
2063
  const name = `${String(this.counter).padStart(6, "0")}.${this.captureFormat === "jpeg" ? "jpg" : "png"}`;
1651
- this.frames.push({ f: name, t: round1(t) });
2064
+ this.frames.push({ f: name, t: round1(t), h: createHash2("sha256").update(data).digest("hex") });
1652
2065
  this.pendingWrites.push(
1653
- this.ready.then(() => writeFile(join(this.dir, "frames", name), data))
2066
+ this.ready.then(() => writeFile(join2(this.dir, "frames", name), data)).catch((e) => {
2067
+ this.failedFrames.add(name);
2068
+ this.writeError ??= e;
2069
+ })
1654
2070
  );
1655
2071
  }
1656
2072
  addMaskSample(sample) {
@@ -1662,21 +2078,28 @@ var init_writer = __esm({
1662
2078
  async finish(meta) {
1663
2079
  await this.ready;
1664
2080
  await Promise.all(this.pendingWrites);
2081
+ if (this.failedFrames.size > 0) {
2082
+ this.frames = this.frames.filter((f) => !this.failedFrames.has(f.f));
2083
+ log.warn(`${this.failedFrames.size} frame write(s) failed (${this.writeError?.message?.split("\n")[0]}) \u2014 those frames dropped from the index`);
2084
+ }
1665
2085
  this.frames.sort((a, b) => a.t - b.t);
1666
2086
  let frameW = meta.viewport.w * meta.dpr;
1667
2087
  let frameH = meta.viewport.h * meta.dpr;
1668
2088
  const first = this.frames[0];
1669
2089
  if (first) {
1670
- const info = await sharp(join(this.dir, "frames", first.f)).metadata();
2090
+ const info = await sharp(join2(this.dir, "frames", first.f)).metadata();
1671
2091
  if (info.width && info.height) {
1672
2092
  frameW = info.width;
1673
2093
  frameH = info.height;
1674
2094
  }
1675
2095
  }
2096
+ meta.events.meta.frameW = frameW;
2097
+ meta.events.meta.frameH = frameH;
2098
+ const spec = redactMaskedText(meta.spec);
1676
2099
  const manifest = {
1677
2100
  schema: "playhead/bundle@1",
1678
2101
  playheadVersion: PLAYHEAD_VERSION,
1679
- specHash: sha256Json(meta.spec),
2102
+ specHash: sha256Json(spec),
1680
2103
  appUrl: meta.appUrl,
1681
2104
  viewport: meta.viewport,
1682
2105
  dpr: meta.dpr,
@@ -1695,11 +2118,11 @@ var init_writer = __esm({
1695
2118
  ...meta.failure ? { failure: meta.failure } : {}
1696
2119
  };
1697
2120
  await Promise.all([
1698
- writeFile(join(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
1699
- writeFile(join(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
1700
- writeFile(join(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
1701
- writeFile(join(this.dir, "spec.resolved.json"), JSON.stringify(meta.spec, null, 2)),
1702
- writeFile(join(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
2121
+ writeFile(join2(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
2122
+ writeFile(join2(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
2123
+ writeFile(join2(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
2124
+ writeFile(join2(this.dir, "spec.resolved.json"), JSON.stringify(spec, null, 2)),
2125
+ writeFile(join2(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
1703
2126
  ]);
1704
2127
  return manifest;
1705
2128
  }
@@ -1767,42 +2190,10 @@ var init_geometry = __esm({
1767
2190
  }
1768
2191
  });
1769
2192
 
1770
- // src/shared/exit.ts
1771
- function exitCodeFor(e) {
1772
- if (e && typeof e === "object") {
1773
- if ("exitCode" in e && typeof e.exitCode === "number") {
1774
- return e.exitCode;
1775
- }
1776
- if (e.name === "SpecError" || e instanceof Object && e.constructor?.name === "SpecError") {
1777
- return EXIT.USAGE;
1778
- }
1779
- }
1780
- return EXIT.INFRA;
1781
- }
1782
- var EXIT, FlowError, InfraError;
1783
- var init_exit = __esm({
1784
- "src/shared/exit.ts"() {
1785
- "use strict";
1786
- EXIT = {
1787
- OK: 0,
1788
- FLOW: 1,
1789
- QUALITY: 2,
1790
- INFRA: 3,
1791
- USAGE: 4
1792
- };
1793
- FlowError = class extends Error {
1794
- exitCode = EXIT.FLOW;
1795
- };
1796
- InfraError = class extends Error {
1797
- exitCode = EXIT.INFRA;
1798
- };
1799
- }
1800
- });
1801
-
1802
2193
  // src/capture/executor.ts
1803
- import { join as join2 } from "path";
2194
+ import { join as join3 } from "path";
1804
2195
  async function capture(spec, opts) {
1805
- const bundleDir = join2(opts.outDir, "capture");
2196
+ const bundleDir = join3(opts.outDir, "capture");
1806
2197
  const driver = opts.driver ?? new PlaywrightDriver();
1807
2198
  const dpr = 2;
1808
2199
  const format = opts.captureFormat ?? "jpeg";
@@ -1836,18 +2227,20 @@ async function capture(spec, opts) {
1836
2227
  try {
1837
2228
  clockOffsetMs = await driver.measureClockOffset();
1838
2229
  t0 = Date.now();
2230
+ let recordEvents = !opts.fromScene;
1839
2231
  driver.onNavigation((url, tNode) => {
1840
- events.push({ type: "navigation", url, t: rel(tNode) });
2232
+ if (recordEvents) events.push({ type: "navigation", url, t: rel(tNode) });
1841
2233
  });
1842
2234
  const startFilming = () => driver.startCapture((frame) => writer.addFrame(frame.data, rel(frame.tNodeMs)), {
1843
2235
  format,
1844
2236
  quality: opts.quality ?? 82,
1845
2237
  scale: dpr,
1846
- // 24 requested 20-22 achieved with the screenshot loop's ~25ms capture cost; frame
1847
- // blending at compose smooths the rest. (True 30+ needs the screencast source — planned.)
1848
- fps: 24
2238
+ // The screencast source delivers paint-driven frames up to ~30fps during motion; the
2239
+ // paced screenshot fallback (PLAYHEAD_CAPTURE=screenshot) tops out ~20.
2240
+ fps: 30
1849
2241
  });
1850
- if (!opts.fromScene) await startFilming();
2242
+ const preRoll = spec.setup.length > 0 || Boolean(opts.fromScene);
2243
+ if (!preRoll) await startFilming();
1851
2244
  await driver.installMaskRules(maskRules);
1852
2245
  await driver.goto(spec.app.url);
1853
2246
  await applyMasksAndSample(driver, maskRules, writer, now);
@@ -1859,7 +2252,15 @@ async function capture(spec, opts) {
1859
2252
  );
1860
2253
  });
1861
2254
  }
1862
- if (!opts.fromScene) await driver.captureNow();
2255
+ if (!preRoll) await driver.captureNow();
2256
+ if (spec.setup.length > 0) {
2257
+ log.info(`running ${spec.setup.length} setup step(s) (state only, not filmed)`);
2258
+ for (const [i, s] of spec.setup.entries()) {
2259
+ currentStepRef = `setup/${i}`;
2260
+ await fastForwardStep(driver, s);
2261
+ }
2262
+ currentStepRef = "";
2263
+ }
1863
2264
  let steps = flattenSteps(spec);
1864
2265
  const totalSteps = steps.length;
1865
2266
  if (opts.fromScene) {
@@ -1872,7 +2273,10 @@ async function capture(spec, opts) {
1872
2273
  }
1873
2274
  currentStepRef = "";
1874
2275
  steps = steps.slice(idx);
2276
+ }
2277
+ if (preRoll) {
1875
2278
  await driver.settle(settleCfg);
2279
+ recordEvents = true;
1876
2280
  await startFilming();
1877
2281
  await driver.captureNow();
1878
2282
  }
@@ -1882,11 +2286,20 @@ async function capture(spec, opts) {
1882
2286
  throw new Error(`capture watchdog: exceeded ${CAPTURE_DEADLINE_MS / 6e4} minutes at step ${currentStepRef}`);
1883
2287
  }
1884
2288
  log.step(`${addressed.ordinal}/${totalSteps} ${describeStep(addressed)}`);
1885
- const event = await withStepBudget(
1886
- executeStep(driver, addressed, { rel, now }, spec.app.url),
1887
- STEP_BUDGET_MS,
1888
- currentStepRef
1889
- );
2289
+ let event;
2290
+ try {
2291
+ event = await withStepBudget(
2292
+ executeStep(driver, addressed, { rel, now }, spec.app.url),
2293
+ STEP_BUDGET_MS,
2294
+ currentStepRef
2295
+ );
2296
+ } catch (e) {
2297
+ if (addressed.step.optional) {
2298
+ log.warn(`optional step ${currentStepRef} skipped: ${e.message.split("\n")[0]}`);
2299
+ continue;
2300
+ }
2301
+ throw e;
2302
+ }
1890
2303
  await applyMasksAndSample(driver, maskRules, writer, now);
1891
2304
  await driver.settle(settleCfg);
1892
2305
  const focus = "focus" in addressed.step ? addressed.step.focus : void 0;
@@ -1924,10 +2337,10 @@ async function capture(spec, opts) {
1924
2337
  const url = driver.currentUrl();
1925
2338
  const { writeFile: writeFile9 } = await import("fs/promises");
1926
2339
  await writeFile9(
1927
- join2(bundleDir, "failure.json"),
2340
+ join3(bundleDir, "failure.json"),
1928
2341
  JSON.stringify({ stepRef: failure.stepRef, message, url, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
1929
2342
  );
1930
- if (aria) await writeFile9(join2(bundleDir, "failure-aria.txt"), aria);
2343
+ if (aria) await writeFile9(join3(bundleDir, "failure-aria.txt"), aria);
1931
2344
  } catch {
1932
2345
  }
1933
2346
  throw new FlowError(
@@ -1942,7 +2355,7 @@ async function capture(spec, opts) {
1942
2355
  if (consoleLog.length > 0) {
1943
2356
  const { writeFile: writeFile9 } = await import("fs/promises");
1944
2357
  await writeFile9(
1945
- join2(bundleDir, "console.json"),
2358
+ join3(bundleDir, "console.json"),
1946
2359
  JSON.stringify(consoleLog.map((c) => ({ ...c, t: rel(c.t) })), null, 2)
1947
2360
  );
1948
2361
  }
@@ -1994,13 +2407,15 @@ async function fastForwardStep(driver, step) {
1994
2407
  break;
1995
2408
  case "click":
1996
2409
  case "dblclick": {
1997
- const t = await driver.resolveTarget(parseLocator(step.target), T);
1998
- await driver.clickAt(rectCenter(t.bbox), { double: step.action === "dblclick" });
2410
+ await driver.resolveTarget(parseLocator(step.target), T);
2411
+ await driver.actClick(parseLocator(step.target), { double: step.action === "dblclick", timeoutMs: T });
1999
2412
  break;
2000
2413
  }
2001
- case "hover":
2414
+ case "hover": {
2415
+ const t = await driver.resolveTarget(parseLocator(step.target), T);
2416
+ await driver.moveCursor(rectCenter(t.bbox), 60);
2002
2417
  break;
2003
- // no lasting state
2418
+ }
2004
2419
  case "type": {
2005
2420
  const t = await driver.resolveTarget(parseLocator(step.target), T);
2006
2421
  await driver.clickAt(rectCenter(t.bbox));
@@ -2018,8 +2433,23 @@ async function fastForwardStep(driver, step) {
2018
2433
  else if (step.by) await driver.scrollBy(step.by);
2019
2434
  await driver.waitForScrollSettle(1500);
2020
2435
  break;
2436
+ case "rightclick": {
2437
+ await driver.resolveTarget(parseLocator(step.target), T);
2438
+ await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: T });
2439
+ break;
2440
+ }
2441
+ case "upload":
2442
+ await driver.setInputFiles(parseLocator(step.target), step.file);
2443
+ break;
2444
+ case "drag":
2445
+ await driver.dragTo(parseLocator(step.target), parseLocator(step.to), T);
2446
+ break;
2021
2447
  case "expect":
2022
- await driver.expectState(parseLocator(step.target), { ...step.visible !== void 0 ? { visible: step.visible } : {} }, T).catch(() => {
2448
+ await driver.expectState(
2449
+ step.target ? parseLocator(step.target) : null,
2450
+ { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.url !== void 0 ? { url: step.url } : {} },
2451
+ T
2452
+ ).catch(() => {
2023
2453
  });
2024
2454
  break;
2025
2455
  case "wait":
@@ -2105,6 +2535,9 @@ async function executeStep(driver, addressed, clock, appUrl) {
2105
2535
  base.targetPre = pre;
2106
2536
  base.cursorPath = await moveToTarget(driver, pre, clock);
2107
2537
  await driver.actClick(loc, { timeoutMs: budgetMs });
2538
+ if (step.clear) {
2539
+ await driver.press("ControlOrMeta+A");
2540
+ }
2108
2541
  if (step.mask) {
2109
2542
  await driver.setTypingMask(loc);
2110
2543
  }
@@ -2134,6 +2567,42 @@ async function executeStep(driver, addressed, clock, appUrl) {
2134
2567
  base.tActionEnd = clock.now();
2135
2568
  return base;
2136
2569
  }
2570
+ case "rightclick": {
2571
+ base.locator = step.target;
2572
+ const loc = parseLocator(step.target);
2573
+ const pre = await driver.resolveTarget(loc, budgetMs);
2574
+ base.targetPre = pre;
2575
+ base.cursorPath = await moveToTarget(driver, pre, clock);
2576
+ const { tDown, tUp } = await driver.actClick(loc, { button: "right", timeoutMs: budgetMs });
2577
+ base.tAction = clock.rel(tDown);
2578
+ base.tActionEnd = clock.rel(tUp);
2579
+ return base;
2580
+ }
2581
+ case "upload": {
2582
+ base.locator = step.target;
2583
+ const loc = parseLocator(step.target);
2584
+ base.targetPre = await driver.tryMeasure(loc);
2585
+ if (base.targetPre) base.cursorPath = await moveToTarget(driver, base.targetPre, clock);
2586
+ base.tAction = clock.now();
2587
+ await driver.setInputFiles(loc, step.file);
2588
+ base.typedText = step.file.split(/[\\/]/).pop() ?? step.file;
2589
+ base.tActionEnd = clock.now();
2590
+ return base;
2591
+ }
2592
+ case "drag": {
2593
+ base.locator = step.target;
2594
+ const from = parseLocator(step.target);
2595
+ const to = parseLocator(step.to);
2596
+ const pre = await driver.resolveTarget(from, budgetMs);
2597
+ base.targetPre = pre;
2598
+ base.cursorPath = await moveToTarget(driver, pre, clock);
2599
+ const { tDown, tUp, path } = await driver.dragTo(from, to, budgetMs);
2600
+ base.cursorPath = [...base.cursorPath, ...path.map((w) => ({ ...w, t: clock.rel(w.t) }))];
2601
+ base.tAction = clock.rel(tDown);
2602
+ base.tActionEnd = clock.rel(tUp);
2603
+ base.targetPost = await driver.tryMeasure(to);
2604
+ return base;
2605
+ }
2137
2606
  case "scroll": {
2138
2607
  base.tAction = clock.now();
2139
2608
  if (step.target) {
@@ -2150,14 +2619,22 @@ async function executeStep(driver, addressed, clock, appUrl) {
2150
2619
  return base;
2151
2620
  }
2152
2621
  case "expect": {
2153
- base.locator = step.target;
2154
- const loc = parseLocator(step.target);
2622
+ base.locator = step.target ?? step.url ?? "";
2623
+ const loc = step.target ? parseLocator(step.target) : null;
2155
2624
  await driver.expectState(
2156
2625
  loc,
2157
- { ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.text !== void 0 ? { text: step.text } : {}, ...step.count !== void 0 ? { count: step.count } : {} },
2626
+ {
2627
+ ...step.visible !== void 0 ? { visible: step.visible } : {},
2628
+ ...step.text !== void 0 ? { text: step.text } : {},
2629
+ ...step.count !== void 0 ? { count: step.count } : {},
2630
+ ...step.url !== void 0 ? { url: step.url } : {},
2631
+ ...step.value !== void 0 ? { value: step.value } : {},
2632
+ ...step.disabled !== void 0 ? { disabled: step.disabled } : {},
2633
+ ...step.checked !== void 0 ? { checked: step.checked } : {}
2634
+ },
2158
2635
  step.timeout ?? EXPECT_TIMEOUT_MS
2159
2636
  );
2160
- base.targetPre = await driver.tryMeasure(loc);
2637
+ if (loc) base.targetPre = await driver.tryMeasure(loc);
2161
2638
  base.tAction = clock.now();
2162
2639
  base.tActionEnd = base.tAction;
2163
2640
  return base;
@@ -2183,6 +2660,7 @@ async function moveToTarget(driver, target, clock) {
2183
2660
  const to = rectCenter(target.bbox);
2184
2661
  const travel = clamp(250 + dist(driver.cursorPos(), to) * 0.5, 300, 700);
2185
2662
  const waypoints = await driver.moveCursor(to, travel);
2663
+ await driver.captureNow();
2186
2664
  return waypoints.map((w) => ({ x: round12(w.x), y: round12(w.y), t: round12(clock.rel(w.t)) }));
2187
2665
  }
2188
2666
  async function ensurePostActionFrame(driver, tAction, rel) {
@@ -2248,13 +2726,13 @@ var init_executor = __esm({
2248
2726
 
2249
2727
  // src/bundle/reader.ts
2250
2728
  import { readFile as readFile2 } from "fs/promises";
2251
- import { join as join3 } from "path";
2729
+ import { join as join4 } from "path";
2252
2730
  async function openBundle(dir) {
2253
2731
  const [manifestRaw, eventsRaw, framesRaw, masksRaw] = await Promise.all([
2254
- readFile2(join3(dir, "manifest.json"), "utf8"),
2255
- readFile2(join3(dir, "events.json"), "utf8"),
2256
- readFile2(join3(dir, "frames", "index.json"), "utf8"),
2257
- readFile2(join3(dir, "masks.json"), "utf8").catch(() => "[]")
2732
+ readFile2(join4(dir, "manifest.json"), "utf8"),
2733
+ readFile2(join4(dir, "events.json"), "utf8"),
2734
+ readFile2(join4(dir, "frames", "index.json"), "utf8"),
2735
+ readFile2(join4(dir, "masks.json"), "utf8").catch(() => "[]")
2258
2736
  ]);
2259
2737
  const manifest = JSON.parse(manifestRaw);
2260
2738
  if (manifest.schema !== "playhead/bundle@1") {
@@ -2279,7 +2757,7 @@ function frameIndexForTime(frames, tMs) {
2279
2757
  return lo;
2280
2758
  }
2281
2759
  function framePath(bundle, index) {
2282
- return join3(bundle.dir, "frames", bundle.frames[index].f);
2760
+ return join4(bundle.dir, "frames", bundle.frames[index].f);
2283
2761
  }
2284
2762
  var init_reader = __esm({
2285
2763
  "src/bundle/reader.ts"() {
@@ -2293,8 +2771,8 @@ __export(theme_exports, {
2293
2771
  registerFonts: () => registerFonts,
2294
2772
  resolveTheme: () => resolveTheme
2295
2773
  });
2296
- import { existsSync } from "fs";
2297
- import { join as join4, dirname as dirname2 } from "path";
2774
+ import { existsSync as existsSync2 } from "fs";
2775
+ import { join as join5, dirname as dirname3 } from "path";
2298
2776
  import { createRequire as createRequire2 } from "module";
2299
2777
  import { GlobalFonts } from "@napi-rs/canvas";
2300
2778
  function resolveTheme(spec) {
@@ -2310,7 +2788,7 @@ function registerFonts() {
2310
2788
  const require5 = createRequire2(import.meta.url);
2311
2789
  let pkgDir;
2312
2790
  try {
2313
- pkgDir = dirname2(require5.resolve("@expo-google-fonts/inter/package.json"));
2791
+ pkgDir = dirname3(require5.resolve("@expo-google-fonts/inter/package.json"));
2314
2792
  } catch {
2315
2793
  throw new Error("Font package @expo-google-fonts/inter not found \u2014 run npm install");
2316
2794
  }
@@ -2321,8 +2799,8 @@ function registerFonts() {
2321
2799
  ["700Bold/Inter_700Bold.ttf", "Inter Bold"]
2322
2800
  ];
2323
2801
  for (const [rel, family] of faces) {
2324
- const p = join4(pkgDir, rel);
2325
- if (existsSync(p)) GlobalFonts.registerFromPath(p, family);
2802
+ const p = join5(pkgDir, rel);
2803
+ if (existsSync2(p)) GlobalFonts.registerFromPath(p, family);
2326
2804
  }
2327
2805
  }
2328
2806
  var DEFAULT_THEME, fontsRegistered;
@@ -2351,20 +2829,37 @@ var init_theme = __esm({
2351
2829
  });
2352
2830
 
2353
2831
  // src/compose/types.ts
2832
+ var types_exports = {};
2833
+ __export(types_exports, {
2834
+ PROFILE_16x9: () => PROFILE_16x9,
2835
+ PROFILE_1x1: () => PROFILE_1x1,
2836
+ PROFILE_9x16: () => PROFILE_9x16,
2837
+ actReactWindows: () => actReactWindows,
2838
+ profileForAspect: () => profileForAspect,
2839
+ stageContent: () => stageContent,
2840
+ withStage: () => withStage
2841
+ });
2354
2842
  function stageContent(profile) {
2355
2843
  return profile.stage?.content ?? { x: 0, y: 0, w: profile.width, h: profile.height };
2356
2844
  }
2357
- function withStage(profile, chrome) {
2845
+ function withStage(profile, chrome, viewportAspect) {
2358
2846
  const { width, height } = profile;
2847
+ const va = viewportAspect ?? width / height;
2848
+ const ui = Math.min(width, height) / 1080;
2359
2849
  const marginY = Math.round(height * 0.055);
2360
- const chromeH = chrome ? Math.round(44 * (Math.min(width, height) / 1080)) : 0;
2361
- const contentH = height - 2 * marginY - chromeH;
2362
- const contentW = Math.round(contentH * (width / height));
2850
+ const marginX = Math.round(width * 0.05);
2851
+ const chromeH = chrome ? Math.round(44 * ui) : 0;
2852
+ let contentH = height - 2 * marginY - chromeH;
2853
+ let contentW = Math.round(contentH * va);
2854
+ if (contentW > width - 2 * marginX) {
2855
+ contentW = width - 2 * marginX;
2856
+ contentH = Math.round(contentW / va);
2857
+ }
2363
2858
  const x = Math.round((width - contentW) / 2);
2364
- const y = marginY + chromeH;
2859
+ const y = Math.round((height - contentH - chromeH) / 2) + chromeH;
2365
2860
  return {
2366
2861
  ...profile,
2367
- stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * (Math.min(width, height) / 1080)) }
2862
+ stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * ui) }
2368
2863
  };
2369
2864
  }
2370
2865
  function profileForAspect(aspect, resolution, fps) {
@@ -2382,7 +2877,8 @@ function profileForAspect(aspect, resolution, fps) {
2382
2877
  left: Math.round(base.safeArea.left * scaleX),
2383
2878
  right: Math.round(base.safeArea.right * scaleX)
2384
2879
  },
2385
- captionMaxWidth: Math.round(base.captionMaxWidth * scaleX)
2880
+ captionMaxWidth: Math.round(base.captionMaxWidth * scaleX),
2881
+ uiScale: Math.min(resolution.w, resolution.h) / 1080
2386
2882
  };
2387
2883
  }
2388
2884
  function actReactWindows(step) {
@@ -2405,7 +2901,8 @@ var init_types = __esm({
2405
2901
  safeArea: { top: 54, right: 96, bottom: 160, left: 96 },
2406
2902
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2407
2903
  minFocusPx: 110,
2408
- captionMaxWidth: 1040
2904
+ captionMaxWidth: 1040,
2905
+ uiScale: 1
2409
2906
  };
2410
2907
  PROFILE_9x16 = {
2411
2908
  width: 1080,
@@ -2414,7 +2911,8 @@ var init_types = __esm({
2414
2911
  safeArea: { top: 230, right: 56, bottom: 320, left: 56 },
2415
2912
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2416
2913
  minFocusPx: 96,
2417
- captionMaxWidth: 980
2914
+ captionMaxWidth: 980,
2915
+ uiScale: 1
2418
2916
  };
2419
2917
  PROFILE_1x1 = {
2420
2918
  width: 1080,
@@ -2423,7 +2921,8 @@ var init_types = __esm({
2423
2921
  safeArea: { top: 80, right: 72, bottom: 200, left: 72 },
2424
2922
  zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
2425
2923
  minFocusPx: 100,
2426
- captionMaxWidth: 940
2924
+ captionMaxWidth: 940,
2925
+ uiScale: 1
2427
2926
  };
2428
2927
  }
2429
2928
  });
@@ -2520,6 +3019,9 @@ function pacingConfig(mode, kind) {
2520
3019
  type: 450 * factor,
2521
3020
  press: 450 * factor,
2522
3021
  select: 600 * factor,
3022
+ rightclick: 700 * factor,
3023
+ upload: 800 * factor,
3024
+ drag: 700 * factor,
2523
3025
  scroll: 350 * factor,
2524
3026
  expect: 800 * factor,
2525
3027
  wait: 150
@@ -2544,10 +3046,37 @@ function buildTimeline(log2, cfg, audioMs) {
2544
3046
  const srcEnd = Math.max(e.tSettled, e.tActionEnd, e.tStart + 1);
2545
3047
  const spanSrc = srcEnd - e.tStart;
2546
3048
  let spanOut = spanSrc;
2547
- if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) spanOut = cfg.maxTypeOutMs;
2548
- segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
2549
- const outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
2550
- out += spanOut;
3049
+ let outBeat;
3050
+ if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) {
3051
+ const HEAD_MS = 1200;
3052
+ const TAIL_MS = 700;
3053
+ const headSrc = Math.min(HEAD_MS, spanSrc * 0.4);
3054
+ const tailSrc = Math.min(TAIL_MS, spanSrc * 0.25);
3055
+ const midSrc = spanSrc - headSrc - tailSrc;
3056
+ const midOut = Math.max(250, cfg.maxTypeOutMs - headSrc - tailSrc);
3057
+ segments.push({ kind: "source", outStart: out, outEnd: out + headSrc, srcStart: e.tStart, srcEnd: e.tStart + headSrc });
3058
+ segments.push({
3059
+ kind: "source",
3060
+ outStart: out + headSrc,
3061
+ outEnd: out + headSrc + midOut,
3062
+ srcStart: e.tStart + headSrc,
3063
+ srcEnd: e.tStart + headSrc + midSrc
3064
+ });
3065
+ segments.push({
3066
+ kind: "source",
3067
+ outStart: out + headSrc + midOut,
3068
+ outEnd: out + headSrc + midOut + tailSrc,
3069
+ srcStart: srcEnd - tailSrc,
3070
+ srcEnd
3071
+ });
3072
+ spanOut = headSrc + midOut + tailSrc;
3073
+ outBeat = out + Math.min(e.tAction - e.tStart, headSrc);
3074
+ out += spanOut;
3075
+ } else {
3076
+ segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
3077
+ outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
3078
+ out += spanOut;
3079
+ }
2551
3080
  let hold = cfg.holds[e.kind];
2552
3081
  if (out + hold - stepOutStart < cfg.minStepMs) hold = cfg.minStepMs - (out - stepOutStart);
2553
3082
  const aud = audioMs?.get(`${e.sceneId}/${e.stepIndex}`);
@@ -2576,6 +3105,7 @@ function buildTimeline(log2, cfg, audioMs) {
2576
3105
  ...e.focus ? { focus: e.focus } : {},
2577
3106
  ...e.shot ? { shot: e.shot } : {},
2578
3107
  ...e.focusTarget ? { focusRectVp: e.focusTarget.bbox } : {},
3108
+ ...e.targetPre && !e.targetPost ? { targetGone: true } : {},
2579
3109
  outStart: stepOutStart,
2580
3110
  outBeat,
2581
3111
  outEnd: out,
@@ -2602,6 +3132,18 @@ function sampleSource(segments, tOut) {
2602
3132
  }
2603
3133
  }
2604
3134
  }
3135
+ function outTimeForSrc(segments, tSrc) {
3136
+ for (const seg of segments) {
3137
+ if (seg.kind === "source" && tSrc >= seg.srcStart && tSrc <= seg.srcEnd) {
3138
+ const u = (tSrc - seg.srcStart) / Math.max(1e-6, seg.srcEnd - seg.srcStart);
3139
+ return seg.outStart + u * (seg.outEnd - seg.outStart);
3140
+ }
3141
+ if (seg.kind === "freeze" && Math.abs(seg.srcAt - tSrc) < 400) {
3142
+ return seg.outStart + (seg.outEnd - seg.outStart) / 2;
3143
+ }
3144
+ }
3145
+ return null;
3146
+ }
2605
3147
  function segmentAt(segments, tOut) {
2606
3148
  let lo = 0;
2607
3149
  let hi = segments.length - 1;
@@ -2642,13 +3184,48 @@ function sampleCamera(keyframes, tOut) {
2642
3184
  }
2643
3185
  const a = keyframes[lo];
2644
3186
  const b = keyframes[hi];
2645
- const u = (tOut - a.tOut) / Math.max(1e-6, b.tOut - a.tOut);
2646
- const e = cubicBezier(b.ease, u);
2647
- return {
2648
- cx: a.state.cx + (b.state.cx - a.state.cx) * e,
2649
- cy: a.state.cy + (b.state.cy - a.state.cy) * e,
2650
- zoom: Math.exp(Math.log(a.state.zoom) + (Math.log(b.state.zoom) - Math.log(a.state.zoom)) * e)
2651
- };
3187
+ const dt = Math.max(1e-6, b.tOut - a.tOut);
3188
+ const u = (tOut - a.tOut) / dt;
3189
+ if (sameState(a.state, b.state)) return { ...a.state };
3190
+ const va = [a.state.cx, a.state.cy, Math.log(a.state.zoom)];
3191
+ const vb = [b.state.cx, b.state.cy, Math.log(b.state.zoom)];
3192
+ const TENSION = 0.5;
3193
+ const ma = tangentAt(keyframes, lo, TENSION);
3194
+ const mb = tangentAt(keyframes, hi, TENSION);
3195
+ const isolated = ma.every((m) => m === 0) && mb.every((m) => m === 0);
3196
+ if (isolated) {
3197
+ const e = cubicBezier(b.ease, u);
3198
+ return {
3199
+ cx: va[0] + (vb[0] - va[0]) * e,
3200
+ cy: va[1] + (vb[1] - va[1]) * e,
3201
+ zoom: Math.exp(va[2] + (vb[2] - va[2]) * e)
3202
+ };
3203
+ }
3204
+ const u2 = u * u;
3205
+ const u3 = u2 * u;
3206
+ const h00 = 2 * u3 - 3 * u2 + 1;
3207
+ const h10 = u3 - 2 * u2 + u;
3208
+ const h01 = -2 * u3 + 3 * u2;
3209
+ const h11 = u3 - u2;
3210
+ const out = [0, 0, 0];
3211
+ for (let c = 0; c < 3; c++) {
3212
+ out[c] = h00 * va[c] + h10 * dt * ma[c] + h01 * vb[c] + h11 * dt * mb[c];
3213
+ }
3214
+ return { cx: out[0], cy: out[1], zoom: Math.exp(out[2]) };
3215
+ }
3216
+ function tangentAt(keyframes, i, tension) {
3217
+ const cur = keyframes[i];
3218
+ const prev = i > 0 ? keyframes[i - 1] : null;
3219
+ const next = i < keyframes.length - 1 ? keyframes[i + 1] : null;
3220
+ if (!prev || !next) return [0, 0, 0];
3221
+ if (sameState(prev.state, cur.state) || sameState(cur.state, next.state)) return [0, 0, 0];
3222
+ const dt = Math.max(1e-6, next.tOut - prev.tOut);
3223
+ const vp = [prev.state.cx, prev.state.cy, Math.log(prev.state.zoom)];
3224
+ const vn = [next.state.cx, next.state.cy, Math.log(next.state.zoom)];
3225
+ return [0, 1, 2].map((c) => tension * (vn[c] - vp[c]) / dt);
3226
+ }
3227
+ function sameState(a, b) {
3228
+ 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;
2652
3229
  }
2653
3230
  var init_interpolate = __esm({
2654
3231
  "src/compose/camera/interpolate.ts"() {
@@ -2674,7 +3251,8 @@ function planCamera(steps, log2, profile, durationMs, opts) {
2674
3251
  if (quantums.length === 0) quantums.push(1);
2675
3252
  const navTimes = navigationEvents(log2).map((n) => n.t);
2676
3253
  const shots = groupShots(steps, navTimes, plane2, durationMs);
2677
- for (let attempt = 0; attempt < 4; attempt++) {
3254
+ const maxAttempts = Math.max(8, shots.length * (quantums.length + 1));
3255
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
2678
3256
  for (const shot2 of shots) shot2.state = shotState(shot2, quantums, plane2, profile, emphasis);
2679
3257
  const keyframes = emitKeyframes(shots, plane2, durationMs);
2680
3258
  const violation = checkConstraints(keyframes, steps, plane2, profile);
@@ -2691,7 +3269,9 @@ function planCamera(steps, log2, profile, durationMs, opts) {
2691
3269
  fallbacks.push(`${violation.stepRef}: reduced shot zoom to satisfy framing constraints`);
2692
3270
  }
2693
3271
  }
3272
+ for (const shot of shots) shot.wide = true;
2694
3273
  for (const shot of shots) shot.state = shotState(shot, quantums, plane2, profile, emphasis);
3274
+ fallbacks.push("camera planner exhausted its attempt budget \u2014 all shots forced wide (check for conflicting focus hints)");
2695
3275
  return { keyframes: emitKeyframes(shots, plane2, durationMs), fallbacks };
2696
3276
  }
2697
3277
  function groupShots(steps, navTimes, plane2, durationMs) {
@@ -2766,6 +3346,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
2766
3346
  continue;
2767
3347
  }
2768
3348
  }
3349
+ const arriveBeat = step.kind === "goto" && rect ? Math.min(step.outEnd - 400, step.outBeat + Math.max(200, step.srcSettled - step.srcAction) + 150) : step.outBeat;
2769
3350
  current = {
2770
3351
  stepRefs: [step.stepRef],
2771
3352
  sceneId: step.sceneId,
@@ -2773,7 +3354,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
2773
3354
  minTargetDim: rect ? Math.min(rect.w, rect.h) : Math.min(plane2.vpW, plane2.vpH),
2774
3355
  wide,
2775
3356
  outStart: step.outStart,
2776
- firstBeat: step.outBeat,
3357
+ firstBeat: arriveBeat,
2777
3358
  outEnd: step.outEnd,
2778
3359
  zoomIndex: Number.MAX_SAFE_INTEGER
2779
3360
  // resolved in shotState
@@ -3030,6 +3611,12 @@ function writeCaption(event, texts) {
3030
3611
  return label ? `Enter the ${lowerFirst(label)}` : "Enter a value";
3031
3612
  case "press":
3032
3613
  return `Press ${event.locator ?? "the key"}`;
3614
+ case "rightclick":
3615
+ return label ? `Right-click \u201C${label}\u201D` : "Right-click the element";
3616
+ case "upload":
3617
+ return label ? `Upload a file to ${lowerFirst(label)}` : "Upload the file";
3618
+ case "drag":
3619
+ return label ? `Drag \u201C${label}\u201D into place` : "Drag the item into place";
3033
3620
  case "select":
3034
3621
  return label && event.typedText ? `Choose \u201C${event.typedText}\u201D under ${label}` : label ? `Choose an option under ${label}` : "Choose an option";
3035
3622
  case "goto":
@@ -3180,29 +3767,46 @@ function buildOverlays(steps, log2, theme, profile, title, subtitle, titleCardEn
3180
3767
  const text = kind.captionStyle === "factual" ? `Step ${number} \u2014 ${lowerFirst2(base)}` : base;
3181
3768
  const fontFor = (px) => `${px}px Inter Medium`;
3182
3769
  const showBadge = kind.numberedCaptions && kind.captionStyle !== "factual";
3183
- const badgeFontPx = 15;
3770
+ const ui = profile.uiScale;
3771
+ const padX = Math.round(CARD_PAD_X * ui);
3772
+ const padY = Math.round(CARD_PAD_Y * ui);
3773
+ const badgeFontPx = Math.max(11, Math.round(15 * ui));
3184
3774
  const badgeText = `${number}/${total}`;
3185
- const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + 20 : 0;
3186
- const badgeGap = showBadge ? BADGE_GAP : 0;
3187
- const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - CARD_PAD_X * 2 - badgeW - badgeGap;
3188
- const wrap = wrapText(text, fontFor, maxTextWidth, theme.captionFontPx, theme.captionMinFontPx, theme.captionMaxLines);
3775
+ const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + Math.round(20 * ui) : 0;
3776
+ const badgeGap = showBadge ? Math.round(BADGE_GAP * ui) : 0;
3777
+ const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - padX * 2 - badgeW - badgeGap;
3778
+ const wrap = wrapText(
3779
+ text,
3780
+ fontFor,
3781
+ maxTextWidth,
3782
+ Math.round(theme.captionFontPx * ui),
3783
+ Math.round(theme.captionMinFontPx * ui),
3784
+ theme.captionMaxLines
3785
+ );
3189
3786
  if (wrap.truncated) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "truncated" });
3190
3787
  else if (wrap.shrunk) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "shrunk" });
3191
3788
  const lineH = Math.round(wrap.fontPx * 1.35);
3192
- const cardW = CARD_PAD_X * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
3193
- const cardH = CARD_PAD_Y * 2 + wrap.lines.length * lineH;
3789
+ const cardW = padX * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
3790
+ const cardH = padY * 2 + wrap.lines.length * lineH;
3194
3791
  const x = (profile.width - cardW) / 2;
3195
- let y = theme.captionPosition === "bottom" ? profile.height - CARD_BOTTOM_MARGIN - cardH : profile.safeArea.top;
3792
+ let y = theme.captionPosition === "bottom" ? profile.height - Math.round(CARD_BOTTOM_MARGIN * ui) - cardH : profile.safeArea.top;
3196
3793
  if (camera && theme.captionPosition === "bottom") {
3197
- const rect = step.focusRectVp ?? step.targetRectVp;
3198
- if (rect) {
3199
- const content = stageContent(profile);
3200
- const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
3201
- const cam = sampleCamera(camera, Math.min(step.outBeat + 250, step.outEnd));
3202
- const proj = projectRect(rect, cam, capPlane);
3794
+ const content = stageContent(profile);
3795
+ const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
3796
+ const capBox = { x, y, w: cardW, h: cardH };
3797
+ const handoff = actReactWindows(step);
3798
+ const probes = handoff ? [
3799
+ { rect: handoff.act.rect, t: Math.min(step.outBeat + 250, handoff.act.to) },
3800
+ { rect: handoff.react.rect, t: handoff.react.from + 50 }
3801
+ ] : step.focusRectVp ?? step.targetRectVp ? [{ rect: step.focusRectVp ?? step.targetRectVp, t: Math.min(step.outBeat + 250, step.outEnd) }] : [];
3802
+ for (const probe of probes) {
3803
+ const cam = sampleCamera(camera, probe.t);
3804
+ const proj = projectRect(probe.rect, cam, capPlane);
3203
3805
  const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
3204
- const capBox = { x, y, w: cardW, h: cardH };
3205
- if (overlaps(projFrame, capBox)) y = profile.safeArea.top;
3806
+ if (overlaps(projFrame, capBox)) {
3807
+ y = profile.safeArea.top;
3808
+ break;
3809
+ }
3206
3810
  }
3207
3811
  }
3208
3812
  const next = captioned[i + 1];
@@ -3349,19 +3953,38 @@ function drawOverlays(ctx2, overlays, tOut, theme, profile) {
3349
3953
  if (bottomCaptions.length > 0) {
3350
3954
  const a = Math.max(...bottomCaptions.map((o) => captionAlpha(o, tOut)));
3351
3955
  if (a > 0) {
3352
- const g = ctx2.createLinearGradient(0, profile.height - SCRIM_H, 0, profile.height);
3956
+ const scrimH = Math.round(SCRIM_H * profile.uiScale);
3957
+ const g = ctx2.createLinearGradient(0, profile.height - scrimH, 0, profile.height);
3353
3958
  g.addColorStop(0, "rgba(8,10,16,0)");
3354
3959
  g.addColorStop(1, `rgba(8,10,16,${(0.55 * a).toFixed(3)})`);
3355
3960
  ctx2.fillStyle = g;
3356
- ctx2.fillRect(0, profile.height - SCRIM_H, profile.width, SCRIM_H);
3961
+ ctx2.fillRect(0, profile.height - scrimH, profile.width, scrimH);
3357
3962
  }
3358
3963
  }
3964
+ const activeCaps = overlays.filter(
3965
+ (o) => o.kind === "caption" && tOut >= o.tStart && tOut <= o.tEnd && captionAlpha(o, tOut) > 0
3966
+ );
3967
+ if (activeCaps.length === 2 && activeCaps[0].box.y === activeCaps[1].box.y) {
3968
+ const [a, b] = activeCaps[0].tStart <= activeCaps[1].tStart ? [activeCaps[0], activeCaps[1]] : [activeCaps[1], activeCaps[0]];
3969
+ const u = captionAlpha(b, tOut);
3970
+ const box = {
3971
+ x: a.box.x + (b.box.x - a.box.x) * u,
3972
+ y: a.box.y,
3973
+ w: a.box.w + (b.box.w - a.box.w) * u,
3974
+ h: Math.max(a.box.h, b.box.h)
3975
+ };
3976
+ drawCaptionCard(ctx2, box, 1, theme);
3977
+ drawCaptionContent(ctx2, a, box, 1 - u, theme);
3978
+ drawCaptionContent(ctx2, b, box, u, theme);
3979
+ } else {
3980
+ for (const o of activeCaps) drawCaption(ctx2, o, tOut, theme);
3981
+ }
3359
3982
  for (const o of overlays) {
3360
3983
  if (tOut < o.tStart || tOut > o.tEnd) continue;
3361
3984
  switch (o.kind) {
3362
3985
  case "caption":
3363
- drawCaption(ctx2, o, tOut, theme);
3364
3986
  break;
3987
+ // handled by the grouped pass above
3365
3988
  case "dip": {
3366
3989
  const u = (tOut - o.tStart) / Math.max(1, o.tEnd - o.tStart);
3367
3990
  ctx2.save();
@@ -3387,13 +4010,14 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
3387
4010
  const ms = Math.max(0, tOut - zeroAtMs);
3388
4011
  const s = Math.floor(ms / 1e3);
3389
4012
  const label = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}.${String(Math.floor(ms % 1e3 / 100))}`;
3390
- const font = "600 26px Inter SemiBold";
4013
+ const ui = profile.uiScale;
4014
+ const font = `600 ${Math.round(26 * ui)}px Inter SemiBold`;
3391
4015
  ctx2.save();
3392
4016
  ctx2.font = font;
3393
4017
  const textW = ctx2.measureText(label).width;
3394
- const padX = 16;
3395
- const w = textW + padX * 2 + 30;
3396
- const h = 44;
4018
+ const padX = 16 * ui;
4019
+ const w = textW + padX * 2 + 30 * ui;
4020
+ const h = 44 * ui;
3397
4021
  const x = profile.width - profile.safeArea.right - w;
3398
4022
  const y = profile.safeArea.top;
3399
4023
  ctx2.fillStyle = "rgba(18,22,31,0.82)";
@@ -3401,13 +4025,13 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
3401
4025
  ctx2.fill();
3402
4026
  ctx2.fillStyle = "#ef4444";
3403
4027
  ctx2.beginPath();
3404
- ctx2.arc(x + padX + 6, y + h / 2, 6, 0, Math.PI * 2);
4028
+ ctx2.arc(x + padX + 6 * ui, y + h / 2, 6 * ui, 0, Math.PI * 2);
3405
4029
  ctx2.fill();
3406
4030
  ctx2.fillStyle = "#ffffff";
3407
4031
  ctx2.textAlign = "left";
3408
4032
  ctx2.textBaseline = "middle";
3409
4033
  ctx2.font = font;
3410
- ctx2.fillText(label, x + padX + 24, y + h / 2 + 1);
4034
+ ctx2.fillText(label, x + padX + 24 * ui, y + h / 2 + 1);
3411
4035
  ctx2.restore();
3412
4036
  }
3413
4037
  function drawChapterLabel(ctx2, o, tOut, theme, profile) {
@@ -3415,23 +4039,24 @@ function drawChapterLabel(ctx2, o, tOut, theme, profile) {
3415
4039
  if (alpha <= 0) return;
3416
4040
  ctx2.save();
3417
4041
  ctx2.globalAlpha = alpha;
3418
- const font = "700 30px Inter Bold";
4042
+ const ui = profile.uiScale;
4043
+ const font = `700 ${Math.round(30 * ui)}px Inter Bold`;
3419
4044
  ctx2.font = font;
3420
4045
  const textW = ctx2.measureText(o.title).width;
3421
- const barW = textW + 96;
3422
- const barH = 56;
4046
+ const barW = textW + 96 * ui;
4047
+ const barH = 56 * ui;
3423
4048
  const x = (profile.width - barW) / 2;
3424
4049
  const y = profile.stage ? profile.stage.content.y + 14 : profile.safeArea.top + 8;
3425
4050
  ctx2.fillStyle = theme.accent;
3426
4051
  roundRect(ctx2, x, y, barW, barH, 12);
3427
4052
  ctx2.fill();
3428
4053
  ctx2.fillStyle = "#ffffff";
3429
- ctx2.font = "600 16px Inter SemiBold";
4054
+ ctx2.font = `600 ${Math.round(16 * ui)}px Inter SemiBold`;
3430
4055
  ctx2.textAlign = "left";
3431
4056
  ctx2.textBaseline = "middle";
3432
- ctx2.fillText(String(o.ordinal), x + 22, y + barH / 2 + 1);
4057
+ ctx2.fillText(String(o.ordinal), x + 22 * ui, y + barH / 2 + 1);
3433
4058
  ctx2.font = font;
3434
- ctx2.fillText(o.title, x + 48, y + barH / 2 + 1);
4059
+ ctx2.fillText(o.title, x + 48 * ui, y + barH / 2 + 1);
3435
4060
  ctx2.restore();
3436
4061
  }
3437
4062
  function captionAlpha(o, tOut) {
@@ -3440,23 +4065,36 @@ function captionAlpha(o, tOut) {
3440
4065
  function drawCaption(ctx2, o, tOut, theme) {
3441
4066
  const alpha = captionAlpha(o, tOut);
3442
4067
  if (alpha <= 0) return;
4068
+ ctx2.save();
4069
+ ctx2.globalAlpha = alpha;
4070
+ drawCaptionCard(ctx2, o.box, 1, theme);
4071
+ ctx2.restore();
4072
+ drawCaptionContent(ctx2, o, o.box, alpha, theme);
4073
+ }
4074
+ function drawCaptionCard(ctx2, box, alpha, theme) {
3443
4075
  ctx2.save();
3444
4076
  ctx2.globalAlpha = alpha;
3445
4077
  ctx2.shadowColor = "rgba(0,0,0,0.30)";
3446
4078
  ctx2.shadowBlur = 18;
3447
4079
  ctx2.shadowOffsetY = 4;
3448
4080
  ctx2.fillStyle = theme.surface;
3449
- roundRect(ctx2, o.box.x, o.box.y, o.box.w, o.box.h, theme.radius);
4081
+ roundRect(ctx2, box.x, box.y, box.w, box.h, theme.radius);
3450
4082
  ctx2.fill();
3451
- ctx2.shadowColor = "transparent";
4083
+ ctx2.restore();
4084
+ }
4085
+ function drawCaptionContent(ctx2, o, box, alpha, theme) {
4086
+ if (alpha <= 0) return;
4087
+ ctx2.save();
4088
+ ctx2.globalAlpha = alpha;
3452
4089
  let badgeW = 0;
3453
- const bx = o.box.x + 26;
4090
+ const bx = box.x + 26;
3454
4091
  if (o.showBadge) {
3455
4092
  const badgeText = `${o.ordinal}/${o.totalSteps}`;
3456
- const badgeFont = `600 15px Inter SemiBold`;
3457
- badgeW = Math.ceil(measureText(badgeText, badgeFont)) + 20;
3458
- const badgeH = 26;
3459
- const by = o.box.y + o.box.h / 2 - badgeH / 2;
4093
+ const bScale = o.fontPx / 36;
4094
+ const badgeFont = `600 ${Math.max(11, Math.round(15 * bScale))}px Inter SemiBold`;
4095
+ badgeW = Math.ceil(measureText(badgeText, badgeFont)) + Math.round(20 * bScale);
4096
+ const badgeH = Math.round(26 * bScale);
4097
+ const by = box.y + box.h / 2 - badgeH / 2;
3460
4098
  ctx2.fillStyle = theme.accent;
3461
4099
  roundRect(ctx2, bx, by, badgeW, badgeH, 13);
3462
4100
  ctx2.fill();
@@ -3468,7 +4106,7 @@ function drawCaption(ctx2, o, tOut, theme) {
3468
4106
  }
3469
4107
  const lineH = Math.round(o.fontPx * 1.35);
3470
4108
  const textX = o.showBadge ? bx + badgeW + 14 : bx;
3471
- const textTop = o.box.y + (o.box.h - o.lines.length * lineH) / 2;
4109
+ const textTop = box.y + (box.h - o.lines.length * lineH) / 2;
3472
4110
  ctx2.fillStyle = theme.onSurface;
3473
4111
  ctx2.font = `${o.fontPx}px Inter Medium`;
3474
4112
  ctx2.textAlign = "left";
@@ -3492,16 +4130,17 @@ function drawFailureCard(ctx2, card, tOut, theme, profile) {
3492
4130
  ctx2.fillStyle = "#ef4444";
3493
4131
  roundRect(ctx2, width / 2 - 32, height / 2 - 150, 64, 8, 4);
3494
4132
  ctx2.fill();
4133
+ const uiF = profile.uiScale;
3495
4134
  ctx2.fillStyle = "#ffffff";
3496
- ctx2.font = `52px Inter Bold`;
4135
+ ctx2.font = `${Math.round(52 * uiF)}px Inter Bold`;
3497
4136
  ctx2.textAlign = "center";
3498
4137
  ctx2.textBaseline = "middle";
3499
4138
  ctx2.fillText("Flow failed", width / 2, height / 2 - 70);
3500
4139
  ctx2.fillStyle = "#fca5a5";
3501
- ctx2.font = `600 30px Inter SemiBold`;
4140
+ ctx2.font = `600 ${Math.round(30 * uiF)}px Inter SemiBold`;
3502
4141
  ctx2.fillText(`at step ${card.stepRef}`, width / 2, height / 2 - 12);
3503
4142
  ctx2.fillStyle = "rgba(255,255,255,0.75)";
3504
- ctx2.font = `24px Inter`;
4143
+ ctx2.font = `${Math.round(24 * uiF)}px Inter`;
3505
4144
  const maxW = Math.min(1200, width - 200);
3506
4145
  const words = card.message.replace(/\s+/g, " ").split(" ");
3507
4146
  const lines = [];
@@ -3542,14 +4181,15 @@ function drawTitleCard(ctx2, card, tOut, theme, profile) {
3542
4181
  ctx2.fill();
3543
4182
  ctx2.globalAlpha = title.alpha;
3544
4183
  ctx2.fillStyle = "#ffffff";
3545
- ctx2.font = `56px Inter Bold`;
4184
+ const uiT = profile.uiScale;
4185
+ ctx2.font = `${Math.round(56 * uiT)}px Inter Bold`;
3546
4186
  ctx2.textAlign = "center";
3547
4187
  ctx2.textBaseline = "middle";
3548
4188
  ctx2.fillText(card.title, width / 2, height / 2 - 20 + title.rise);
3549
4189
  if (card.subtitle) {
3550
4190
  ctx2.globalAlpha = sub.alpha;
3551
4191
  ctx2.fillStyle = theme.onSurfaceDim;
3552
- ctx2.font = `26px Inter`;
4192
+ ctx2.font = `${Math.round(26 * uiT)}px Inter`;
3553
4193
  ctx2.fillText(card.subtitle, width / 2, height / 2 + 44 + sub.rise);
3554
4194
  }
3555
4195
  ctx2.restore();
@@ -3584,7 +4224,8 @@ var init_frameStore = __esm({
3584
4224
  async frameForTime(tMs) {
3585
4225
  const idx = frameIndexForTime(this.bundle.frames, tMs);
3586
4226
  const canvas = await this.get(idx);
3587
- if (idx + 1 < this.bundle.frames.length) void this.get(idx + 1);
4227
+ if (idx + 1 < this.bundle.frames.length) this.get(idx + 1).catch(() => {
4228
+ });
3588
4229
  return canvas;
3589
4230
  }
3590
4231
  /**
@@ -3611,6 +4252,7 @@ var init_frameStore = __esm({
3611
4252
  return hit;
3612
4253
  }
3613
4254
  const promise = this.decode(idx);
4255
+ promise.catch(() => this.cache.delete(idx));
3614
4256
  this.cache.set(idx, promise);
3615
4257
  while (this.cache.size > LRU_SIZE) {
3616
4258
  const oldest = this.cache.keys().next().value;
@@ -3664,10 +4306,10 @@ function runBinary(bin, args, stdin) {
3664
4306
  }
3665
4307
  async function concatVideos(inputs, outPath) {
3666
4308
  const { writeFile: writeFile9, mkdtemp: mkdtemp2, rm } = await import("fs/promises");
3667
- const { join: join13 } = await import("path");
4309
+ const { join: join14 } = await import("path");
3668
4310
  const { tmpdir: tmpdir2 } = await import("os");
3669
- const dir = await mkdtemp2(join13(tmpdir2(), "playhead-concat-"));
3670
- const listPath = join13(dir, "list.txt");
4311
+ const dir = await mkdtemp2(join14(tmpdir2(), "playhead-concat-"));
4312
+ const listPath = join14(dir, "list.txt");
3671
4313
  await writeFile9(listPath, inputs.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n"));
3672
4314
  try {
3673
4315
  const res = await runBinary(ffmpegPath(), [
@@ -3778,12 +4420,19 @@ var init_encoder = __esm({
3778
4420
  this.child.on("error", reject);
3779
4421
  this.child.on("close", (code) => resolve3(code ?? -1));
3780
4422
  });
4423
+ this.child.stdin.on("error", () => {
4424
+ });
3781
4425
  }
3782
4426
  async write(rgba) {
3783
4427
  const stdin = this.child.stdin;
3784
4428
  if (!stdin.writable) throw new Error(`ffmpeg closed early: ${this.stderrTail.join("")}`);
3785
4429
  if (!stdin.write(rgba)) {
3786
- await once(stdin, "drain");
4430
+ await Promise.race([
4431
+ once(stdin, "drain"),
4432
+ this.exit.then((code) => {
4433
+ throw new Error(`ffmpeg exited (code ${code}) while the encoder awaited drain: ${this.stderrTail.join("")}`);
4434
+ })
4435
+ ]);
3787
4436
  }
3788
4437
  }
3789
4438
  async finish() {
@@ -3829,7 +4478,7 @@ async function renderVideo(bundle, manifest, outPath) {
3829
4478
  );
3830
4479
  const transitions = manifest.transitions ?? [];
3831
4480
  const spotlightSteps = manifest.steps.filter(
3832
- (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.focusRectVp ?? s.targetRectVp)
4481
+ (s) => SPOTLIGHT_KINDS.has(s.kind) && (s.targetRectVp ?? s.focusRectVp)
3833
4482
  );
3834
4483
  const start = Date.now();
3835
4484
  for (let i = 0; i < manifest.totalFrames; i++) {
@@ -3870,7 +4519,8 @@ async function renderVideo(bundle, manifest, outPath) {
3870
4519
  ctx2.globalAlpha = 1;
3871
4520
  };
3872
4521
  const pair = await store.framePairForTime(srcAt);
3873
- if (segment.kind === "source" && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
4522
+ const rate = segment.kind === "source" ? (segment.srcEnd - segment.srcStart) / Math.max(1, segment.outEnd - segment.outStart) : Infinity;
4523
+ if (segment.kind === "source" && rate < 1.5 && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
3874
4524
  drawSrc(pair.a, 1);
3875
4525
  drawSrc(pair.b, pair.mix);
3876
4526
  } else {
@@ -3886,7 +4536,7 @@ async function renderVideo(bundle, manifest, outPath) {
3886
4536
  }
3887
4537
  const spot = activeSpotlight(spotlightSteps, tOut);
3888
4538
  if (spot) {
3889
- const rect = spot.step.focusRectVp ?? spot.step.targetRectVp;
4539
+ const rect = spot.step.targetRectVp ?? spot.step.focusRectVp;
3890
4540
  const proj = projectRect(rect, cam, plane2);
3891
4541
  drawSpotlight(spotCtx, content, proj, spot.alpha);
3892
4542
  ctx2.drawImage(spotCanvas, content.x, content.y);
@@ -3982,11 +4632,12 @@ function displayUrl(appUrl) {
3982
4632
  }
3983
4633
  function activeSpotlight(steps, tOut) {
3984
4634
  for (const step of steps) {
4635
+ const tail = step.targetGone ? SPOTLIGHT_TAIL_GONE_MS : SPOTLIGHT_TAIL_MS;
3985
4636
  const from = step.outBeat - SPOTLIGHT_LEAD_MS;
3986
- const to = step.outBeat + SPOTLIGHT_TAIL_MS;
4637
+ const to = step.outBeat + tail;
3987
4638
  if (tOut < from || tOut > to) continue;
3988
4639
  const rampIn = clamp((tOut - from) / 180, 0, 1);
3989
- const rampOut = clamp((to - tOut) / 250, 0, 1);
4640
+ const rampOut = clamp((to - tOut) / Math.min(250, tail), 0, 1);
3990
4641
  return { step, alpha: SPOTLIGHT_MAX_DIM * Math.min(rampIn, rampOut) };
3991
4642
  }
3992
4643
  return null;
@@ -4011,7 +4662,7 @@ function cursorGlyphAt(steps, tOut) {
4011
4662
  }
4012
4663
  return "arrow";
4013
4664
  }
4014
- var SPOTLIGHT_KINDS, SPOTLIGHT_LEAD_MS, SPOTLIGHT_TAIL_MS, SPOTLIGHT_MAX_DIM;
4665
+ var SPOTLIGHT_KINDS, SPOTLIGHT_LEAD_MS, SPOTLIGHT_TAIL_MS, SPOTLIGHT_TAIL_GONE_MS, SPOTLIGHT_MAX_DIM;
4015
4666
  var init_renderer = __esm({
4016
4667
  "src/compose/render/renderer.ts"() {
4017
4668
  "use strict";
@@ -4030,17 +4681,170 @@ var init_renderer = __esm({
4030
4681
  SPOTLIGHT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
4031
4682
  SPOTLIGHT_LEAD_MS = 200;
4032
4683
  SPOTLIGHT_TAIL_MS = 650;
4684
+ SPOTLIGHT_TAIL_GONE_MS = 160;
4033
4685
  SPOTLIGHT_MAX_DIM = 0.34;
4034
4686
  }
4035
4687
  });
4036
4688
 
4037
- // src/audio/tts.ts
4038
- import { execFile } from "child_process";
4039
- import { promisify } from "util";
4689
+ // src/verify/media.ts
4690
+ import sharp3 from "sharp";
4691
+ async function extractGrayFrames(videoPath, fps, w, h) {
4692
+ const res = await runBinaryRaw(ffmpegPath(), [
4693
+ "-hide_banner",
4694
+ "-loglevel",
4695
+ "error",
4696
+ "-i",
4697
+ videoPath,
4698
+ "-vf",
4699
+ `fps=${fps},scale=${w}:${h}`,
4700
+ "-f",
4701
+ "rawvideo",
4702
+ "-pix_fmt",
4703
+ "gray",
4704
+ "pipe:1"
4705
+ ]);
4706
+ if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
4707
+ const frameSize = w * h;
4708
+ const frames = [];
4709
+ for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
4710
+ frames.push(res.stdout.subarray(off, off + frameSize));
4711
+ }
4712
+ return { frames, intervalMs: 1e3 / fps };
4713
+ }
4714
+ async function extractFrameAt(videoPath, tMs) {
4715
+ const res = await runBinaryRaw(ffmpegPath(), [
4716
+ "-hide_banner",
4717
+ "-loglevel",
4718
+ "error",
4719
+ "-ss",
4720
+ (Math.max(0, tMs) / 1e3).toFixed(3),
4721
+ "-i",
4722
+ videoPath,
4723
+ "-frames:v",
4724
+ "1",
4725
+ "-f",
4726
+ "image2pipe",
4727
+ "-vcodec",
4728
+ "png",
4729
+ "pipe:1"
4730
+ ]);
4731
+ if (res.code !== 0 || res.stdout.length === 0) {
4732
+ throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
4733
+ }
4734
+ return res.stdout;
4735
+ }
4736
+ async function probeVideo(videoPath) {
4737
+ const res = await runBinary(ffprobePath(), [
4738
+ "-v",
4739
+ "error",
4740
+ "-print_format",
4741
+ "json",
4742
+ "-show_format",
4743
+ "-show_streams",
4744
+ videoPath
4745
+ ]);
4746
+ if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
4747
+ const json = JSON.parse(res.stdout);
4748
+ const v = json.streams?.find((s) => s.codec_type === "video");
4749
+ if (!v) throw new Error("no video stream found");
4750
+ const [num, den] = v.avg_frame_rate.split("/").map(Number);
4751
+ return {
4752
+ codec: v.codec_name,
4753
+ width: v.width,
4754
+ height: v.height,
4755
+ durationSec: Number(json.format?.duration ?? 0),
4756
+ fps: den ? num / den : 0
4757
+ };
4758
+ }
4759
+ function meanAbsDiff(a, b) {
4760
+ const n = Math.min(a.length, b.length);
4761
+ let sum = 0;
4762
+ for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
4763
+ return sum / n;
4764
+ }
4765
+ function variance(a) {
4766
+ let sum = 0;
4767
+ for (let i = 0; i < a.length; i++) sum += a[i];
4768
+ const mean = sum / a.length;
4769
+ let v = 0;
4770
+ for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
4771
+ return v / a.length;
4772
+ }
4773
+ async function sourceFrameGray(bundle, tMs, width) {
4774
+ const idx = frameIndexForTime(bundle.frames, tMs);
4775
+ const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
4776
+ return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
4777
+ }
4778
+ async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
4779
+ let idx = frameIndexForTime(bundle.frames, tMs);
4780
+ if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
4781
+ const meta = await sharp3(framePath(bundle, idx)).metadata();
4782
+ const fw = meta.width ?? 0;
4783
+ const fh = meta.height ?? 0;
4784
+ const x = Math.max(0, Math.round(region.x));
4785
+ const y = Math.max(0, Math.round(region.y));
4786
+ const w = Math.min(Math.round(region.w), fw - x);
4787
+ const h = Math.min(Math.round(region.h), fh - y);
4788
+ if (w < 4 || h < 4) return null;
4789
+ let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
4790
+ if (!color) img = img.grayscale();
4791
+ const { data } = await img.raw().toBuffer({ resolveWithObject: true });
4792
+ return data;
4793
+ }
4794
+ async function regionGray48(input, rect) {
4795
+ try {
4796
+ const img = sharp3(input);
4797
+ const meta = await img.metadata();
4798
+ const W = meta.width ?? 0;
4799
+ const H = meta.height ?? 0;
4800
+ const left = Math.max(0, Math.round(rect.x));
4801
+ const top = Math.max(0, Math.round(rect.y));
4802
+ const width = Math.min(W - left, Math.round(rect.w));
4803
+ const height = Math.min(H - top, Math.round(rect.h));
4804
+ if (width < 8 || height < 8) return null;
4805
+ return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
4806
+ } catch {
4807
+ return null;
4808
+ }
4809
+ }
4810
+ function normalizedCorrelation(a, b) {
4811
+ const n = Math.min(a.length, b.length);
4812
+ let ma = 0;
4813
+ let mb = 0;
4814
+ for (let i = 0; i < n; i++) {
4815
+ ma += a[i];
4816
+ mb += b[i];
4817
+ }
4818
+ ma /= n;
4819
+ mb /= n;
4820
+ let num = 0;
4821
+ let da = 0;
4822
+ let db = 0;
4823
+ for (let i = 0; i < n; i++) {
4824
+ const xa = a[i] - ma;
4825
+ const xb = b[i] - mb;
4826
+ num += xa * xb;
4827
+ da += xa * xa;
4828
+ db += xb * xb;
4829
+ }
4830
+ if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
4831
+ return num / Math.sqrt(da * db);
4832
+ }
4833
+ var init_media = __esm({
4834
+ "src/verify/media.ts"() {
4835
+ "use strict";
4836
+ init_ffmpeg();
4837
+ init_reader();
4838
+ }
4839
+ });
4840
+
4841
+ // src/audio/tts.ts
4842
+ import { execFile } from "child_process";
4843
+ import { promisify } from "util";
4040
4844
  import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
4041
- import { existsSync as existsSync2 } from "fs";
4042
- import { join as join5 } from "path";
4043
- import { createHash as createHash2 } from "crypto";
4845
+ import { existsSync as existsSync3 } from "fs";
4846
+ import { join as join6 } from "path";
4847
+ import { createHash as createHash3 } from "crypto";
4044
4848
  import { createRequire as createRequire4 } from "module";
4045
4849
  function providerFor(opts) {
4046
4850
  switch (opts.provider) {
@@ -4084,9 +4888,9 @@ async function synthesizeNarration(lines, opts, cacheDir) {
4084
4888
  const ext = provider.name === "kokoro" ? "wav" : "aiff";
4085
4889
  const out = /* @__PURE__ */ new Map();
4086
4890
  for (const { stepRef, text } of lines) {
4087
- const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
4088
- const file = join5(cacheDir, `${key}.${ext}`);
4089
- if (!existsSync2(file)) await provider.synth(text, file);
4891
+ const key = createHash3("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
4892
+ const file = join6(cacheDir, `${key}.${ext}`);
4893
+ if (!existsSync3(file)) await provider.synth(text, file);
4090
4894
  out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
4091
4895
  }
4092
4896
  return out;
@@ -4152,14 +4956,15 @@ var init_tts = __esm({
4152
4956
  import { execFile as execFile2 } from "child_process";
4153
4957
  import { promisify as promisify2 } from "util";
4154
4958
  import { rename, mkdir as mkdir3 } from "fs/promises";
4155
- import { existsSync as existsSync3 } from "fs";
4156
- import { join as join6 } from "path";
4959
+ import { existsSync as existsSync4 } from "fs";
4960
+ import { join as join7 } from "path";
4157
4961
  import { createRequire as createRequire5 } from "module";
4158
4962
  async function mixAudio(videoPath, steps, durationMs, opts) {
4159
4963
  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);
4160
4964
  const beats = opts.sfxBeats ?? [];
4965
+ const keyBeats = opts.keyBeats ?? [];
4161
4966
  const hasMusic = !!opts.music;
4162
- if (placed.length === 0 && beats.length === 0 && !hasMusic) return [];
4967
+ if (placed.length === 0 && beats.length === 0 && keyBeats.length === 0 && !hasMusic) return [];
4163
4968
  const durSec = (durationMs / 1e3).toFixed(3);
4164
4969
  const args = ["-y", "-loglevel", "error", "-i", videoPath];
4165
4970
  const inputs = [];
@@ -4197,6 +5002,21 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
4197
5002
  filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[sfx]`);
4198
5003
  inputs.push("[sfx]");
4199
5004
  }
5005
+ if (keyBeats.length > 0) {
5006
+ const key = await ensureKeySample(opts.cacheDir);
5007
+ args.push("-i", key);
5008
+ const idx = inputIdx++;
5009
+ const splits = keyBeats.map((_, i) => `[k${i}]`).join("");
5010
+ filters.push(`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,asplit=${keyBeats.length}${splits}`);
5011
+ const labels = [];
5012
+ keyBeats.forEach((t, i) => {
5013
+ const at = Math.max(0, Math.round(t));
5014
+ filters.push(`[k${i}]adelay=${at}|${at}[kk${i}]`);
5015
+ labels.push(`[kk${i}]`);
5016
+ });
5017
+ filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[keys]`);
5018
+ inputs.push("[keys]");
5019
+ }
4200
5020
  if (opts.music) {
4201
5021
  args.push("-stream_loop", "-1", "-i", opts.music.file);
4202
5022
  const idx = inputIdx++;
@@ -4240,10 +5060,28 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
4240
5060
  await rename(tmp, videoPath);
4241
5061
  return placed.map((p) => ({ stepRef: p.clip.stepRef, tStart: p.start, durMs: p.clip.durationMs }));
4242
5062
  }
5063
+ async function ensureKeySample(cacheDir) {
5064
+ await mkdir3(cacheDir, { recursive: true });
5065
+ const file = join7(cacheDir, "key.wav");
5066
+ if (existsSync4(file)) return file;
5067
+ await exec2(ffmpegPath2, [
5068
+ "-y",
5069
+ "-loglevel",
5070
+ "error",
5071
+ "-f",
5072
+ "lavfi",
5073
+ "-i",
5074
+ "sine=frequency=3400:duration=0.022",
5075
+ "-filter_complex",
5076
+ "[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",
5077
+ file
5078
+ ]);
5079
+ return file;
5080
+ }
4243
5081
  async function ensureClickSample(cacheDir) {
4244
5082
  await mkdir3(cacheDir, { recursive: true });
4245
- const file = join6(cacheDir, "click.wav");
4246
- if (existsSync3(file)) return file;
5083
+ const file = join7(cacheDir, "click.wav");
5084
+ if (existsSync4(file)) return file;
4247
5085
  await exec2(ffmpegPath2, [
4248
5086
  "-y",
4249
5087
  "-loglevel",
@@ -4274,7 +5112,7 @@ var init_mux = __esm({
4274
5112
  });
4275
5113
 
4276
5114
  // src/compose/index.ts
4277
- import { join as join7 } from "path";
5115
+ import { join as join8 } from "path";
4278
5116
  import { writeFile as writeFile3 } from "fs/promises";
4279
5117
  function specStepTexts(spec) {
4280
5118
  const map = /* @__PURE__ */ new Map();
@@ -4294,7 +5132,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4294
5132
  const resolvedTheme = theme ?? resolveTheme(spec);
4295
5133
  const kind = kindProfile(spec.output.kind);
4296
5134
  let prof = profile ?? profileForAspect(spec.output.aspect, resolveResolution(spec), spec.output.fps);
4297
- if (kind.stage && !prof.stage) prof = withStage(prof, true);
5135
+ if (kind.stage && !prof.stage) prof = withStage(prof, true, bundle.manifest.viewport.w / bundle.manifest.viewport.h);
4298
5136
  const diagnostics = { overflows: [], cameraFallbacks: [], clockWarnings: [] };
4299
5137
  const cfg = pacingConfig(spec.output.pacing ?? kind.pacing, kind);
4300
5138
  const dwell = spec.output.dwellScale;
@@ -4305,7 +5143,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
4305
5143
  }
4306
5144
  const timeline = buildTimeline(bundle.events, cfg, audioMs);
4307
5145
  const END_CARD_MS = 2400;
4308
- if (spec.output.endCard) {
5146
+ if (spec.output.endCard && !bundle.manifest.partial) {
4309
5147
  const endStart = timeline.durationMs;
4310
5148
  timeline.segments.push({ kind: "card", outStart: endStart, outEnd: endStart + END_CARD_MS, cardId: "end" });
4311
5149
  timeline.durationMs += END_CARD_MS;
@@ -4332,8 +5170,9 @@ function plan(bundle, spec, theme, profile, audioMs) {
4332
5170
  const prev = timeline.steps[i - 1];
4333
5171
  const cur = timeline.steps[i];
4334
5172
  if (cur.sceneId !== prev.sceneId) {
4335
- const tStart = cur.outStart;
4336
- transitions.push({ tStart, tEnd: tStart + 280, srcFrom: Math.max(0, prev.srcSettled - 1) });
5173
+ const tStart = prev.outEnd;
5174
+ const tEnd = Math.min(cur.outStart + 120, tStart + 340);
5175
+ transitions.push({ tStart, tEnd: Math.max(tEnd, tStart + 200), srcFrom: Math.max(0, prev.srcSettled - 1) });
4337
5176
  }
4338
5177
  }
4339
5178
  const subtitle = spec.subtitle ?? safeHost(bundle.manifest.appUrl);
@@ -4396,238 +5235,163 @@ function plan(bundle, spec, theme, profile, audioMs) {
4396
5235
  bundleHashes: bundle.manifest.hashes
4397
5236
  };
4398
5237
  }
5238
+ async function excludeTransientPreActionFrames(bundle) {
5239
+ const notes = [];
5240
+ const dpr = bundle.manifest.dpr;
5241
+ const excluded = /* @__PURE__ */ new Set();
5242
+ for (const e of actionEvents(bundle.events)) {
5243
+ if (e.kind !== "click" && e.kind !== "dblclick") continue;
5244
+ if (!e.targetPre || !e.cursorPath || e.cursorPath.length === 0) continue;
5245
+ const tArrive = e.cursorPath[e.cursorPath.length - 1].t;
5246
+ if (!(tArrive < e.tAction)) continue;
5247
+ const iArrive = frameIndexForTime(bundle.frames, tArrive);
5248
+ const iBeat = frameIndexForTime(bundle.frames, e.tAction);
5249
+ if (iBeat <= iArrive) continue;
5250
+ const b = e.targetPre.bbox;
5251
+ const region = { x: b.x * dpr, y: b.y * dpr, w: b.w * dpr, h: b.h * dpr };
5252
+ const ref = await sourceRegionGray(bundle, bundle.frames[iArrive].t, region);
5253
+ if (!ref) continue;
5254
+ for (let i = iArrive + 1; i <= iBeat; i++) {
5255
+ const cand = await sourceRegionGray(bundle, bundle.frames[i].t, region);
5256
+ if (!cand || cand.length !== ref.length) continue;
5257
+ if (meanAbsDiff(ref, cand) > BLINK_DIFF_MIN) {
5258
+ excluded.add(i);
5259
+ notes.push(
5260
+ `${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`
5261
+ );
5262
+ }
5263
+ }
5264
+ }
5265
+ if (excluded.size === 0) return { bundle, notes };
5266
+ return { bundle: { ...bundle, frames: bundle.frames.filter((_, i) => !excluded.has(i)) }, notes };
5267
+ }
5268
+ async function smoothJumpCuts(bundle, manifest) {
5269
+ for (const s of manifest.steps) {
5270
+ if (s.kind !== "click" && s.kind !== "dblclick" && s.kind !== "press" && s.kind !== "select") continue;
5271
+ const before = await sourceFrameGray(bundle, Math.max(0, s.srcAction - 30), 320);
5272
+ const after = await sourceFrameGray(bundle, Math.min(s.srcAction + 700, s.srcSettled), 320);
5273
+ if (after.frameT <= before.frameT || after.data.length !== before.data.length) continue;
5274
+ if (meanAbsDiff(before.data, after.data) < JUMP_DIFF_MIN) continue;
5275
+ const tStart = s.outBeat + 60;
5276
+ const tEnd = Math.min(tStart + JUMP_DISSOLVE_MS, s.outEnd);
5277
+ if ((manifest.transitions ?? []).some((w) => tStart <= w.tEnd && tEnd >= w.tStart)) continue;
5278
+ manifest.transitions = manifest.transitions ?? [];
5279
+ manifest.transitions.push({ tStart, tEnd, srcFrom: before.frameT });
5280
+ }
5281
+ }
4399
5282
  async function compose(bundle, spec, opts) {
4400
5283
  const audioCfg = spec.output.audio;
4401
5284
  let narration;
4402
5285
  let audioMs;
5286
+ let narrationDegraded;
5287
+ const voiceLabel = audioCfg?.voice ?? (audioCfg?.provider === "kokoro" ? "heart" : "Samantha");
4403
5288
  if (audioCfg && audioCfg.narration === "tts") {
4404
5289
  const texts = specStepTexts(spec);
4405
5290
  const lines = actionEvents(bundle.events).map((e) => {
4406
5291
  const ref = `${e.sceneId}/${e.stepIndex}`;
4407
5292
  return { stepRef: ref, text: writeNarration(e, texts.get(ref)) };
4408
5293
  }).filter((l) => typeof l.text === "string" && l.text.length > 0);
4409
- log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${audioCfg.voice ?? "Samantha"})`);
5294
+ log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${voiceLabel})`);
4410
5295
  try {
4411
- narration = await synthesizeNarration(lines, audioCfg, join7(opts.outDir, "narration"));
5296
+ narration = await synthesizeNarration(lines, audioCfg, join8(opts.outDir, "narration"));
4412
5297
  audioMs = new Map([...narration].map(([k, v]) => [k, v.durationMs]));
4413
5298
  } catch (e) {
4414
- log.warn(`narration unavailable on this host (${e.message.split("\n")[0]}) \u2014 rendering WITHOUT voice`);
5299
+ narrationDegraded = e.message.split("\n")[0] ?? "TTS unavailable";
5300
+ log.warn(`narration unavailable on this host (${narrationDegraded}) \u2014 rendering WITHOUT voice; the audio check will FAIL`);
4415
5301
  narration = void 0;
4416
5302
  audioMs = void 0;
4417
5303
  }
4418
5304
  }
5305
+ const transient = await excludeTransientPreActionFrames(bundle);
5306
+ bundle = transient.bundle;
4419
5307
  const manifest = plan(bundle, spec, opts.theme, opts.profile, audioMs);
5308
+ manifest.diagnostics.clockWarnings.push(...transient.notes);
5309
+ await smoothJumpCuts(bundle, manifest);
4420
5310
  for (const w of manifest.diagnostics.clockWarnings) log.warn(w);
4421
5311
  for (const f of manifest.diagnostics.cameraFallbacks) log.warn(f);
4422
- const videoPath = join7(opts.outDir, opts.fileName ?? "out.mp4");
5312
+ const videoPath = join8(opts.outDir, opts.fileName ?? "out.mp4");
4423
5313
  log.info(
4424
5314
  `composing ${(manifest.durationMs / 1e3).toFixed(1)}s (${manifest.totalFrames} frames @ ${manifest.profile.fps}fps, ${manifest.profile.width}x${manifest.profile.height})`
4425
5315
  );
4426
5316
  await renderVideo(bundle, manifest, videoPath);
4427
5317
  if (audioCfg && (narration || audioCfg.sfx || audioCfg.music)) {
5318
+ const keyBeats = [];
5319
+ if (audioCfg.sfx) {
5320
+ for (const s of manifest.steps) {
5321
+ if (s.kind !== "type") continue;
5322
+ const headEnd = Math.min(s.outEnd, s.outBeat + 1150);
5323
+ for (let t = s.outBeat + 40; t < headEnd; t += 78 + (t | 0) % 29) keyBeats.push(t);
5324
+ }
5325
+ }
4428
5326
  const clips = await mixAudio(videoPath, manifest.steps, manifest.durationMs, {
4429
5327
  ...narration ? { narration } : {},
4430
5328
  ...audioCfg.sfx ? { sfxBeats: manifest.ripples } : {},
5329
+ ...keyBeats.length > 0 ? { keyBeats } : {},
4431
5330
  ...audioCfg.music ? { music: audioCfg.music } : {},
4432
- cacheDir: join7(opts.outDir, "narration")
5331
+ cacheDir: join8(opts.outDir, "narration")
4433
5332
  });
4434
5333
  if (narration) {
4435
5334
  manifest.audio = {
4436
5335
  narrated: true,
4437
5336
  provider: audioCfg.provider,
4438
- voice: audioCfg.voice ?? "Samantha",
5337
+ voice: voiceLabel,
4439
5338
  lines: clips.length,
4440
5339
  clips
4441
5340
  };
4442
5341
  }
4443
5342
  log.info(
4444
5343
  `\u2713 audio: ${clips.length} narration line(s)${audioCfg.sfx ? `, ${manifest.ripples.length} click(s)` : ""}${audioCfg.music ? ", music bed" : ""} \u2192 ${videoPath}`
4445
- );
4446
- }
4447
- manifest.videoSha256 = await sha256File(videoPath);
4448
- const manifestPath = join7(opts.outDir, "compose-manifest.json");
4449
- await writeFile3(manifestPath, JSON.stringify(manifest, null, 2));
4450
- return { videoPath, manifestPath, manifest };
4451
- }
4452
- function safeHost(url) {
4453
- try {
4454
- return new URL(url).host;
4455
- } catch {
4456
- return url;
4457
- }
4458
- }
4459
- var init_compose = __esm({
4460
- "src/compose/index.ts"() {
4461
- "use strict";
4462
- init_schema();
4463
- init_theme();
4464
- init_types();
4465
- init_kinds();
4466
- init_pacing();
4467
- init_planner();
4468
- init_path();
4469
- init_build();
4470
- init_renderer();
4471
- init_theme();
4472
- init_hash();
4473
- init_log();
4474
- init_events();
4475
- init_captions();
4476
- init_schema();
4477
- init_tts();
4478
- init_mux();
4479
- }
4480
- });
4481
-
4482
- // src/verify/media.ts
4483
- import sharp3 from "sharp";
4484
- async function extractGrayFrames(videoPath, fps, w, h) {
4485
- const res = await runBinaryRaw(ffmpegPath(), [
4486
- "-hide_banner",
4487
- "-loglevel",
4488
- "error",
4489
- "-i",
4490
- videoPath,
4491
- "-vf",
4492
- `fps=${fps},scale=${w}:${h}`,
4493
- "-f",
4494
- "rawvideo",
4495
- "-pix_fmt",
4496
- "gray",
4497
- "pipe:1"
4498
- ]);
4499
- if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
4500
- const frameSize = w * h;
4501
- const frames = [];
4502
- for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
4503
- frames.push(res.stdout.subarray(off, off + frameSize));
4504
- }
4505
- return { frames, intervalMs: 1e3 / fps };
4506
- }
4507
- async function extractFrameAt(videoPath, tMs) {
4508
- const res = await runBinaryRaw(ffmpegPath(), [
4509
- "-hide_banner",
4510
- "-loglevel",
4511
- "error",
4512
- "-ss",
4513
- (Math.max(0, tMs) / 1e3).toFixed(3),
4514
- "-i",
4515
- videoPath,
4516
- "-frames:v",
4517
- "1",
4518
- "-f",
4519
- "image2pipe",
4520
- "-vcodec",
4521
- "png",
4522
- "pipe:1"
4523
- ]);
4524
- if (res.code !== 0 || res.stdout.length === 0) {
4525
- throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
4526
- }
4527
- return res.stdout;
4528
- }
4529
- async function probeVideo(videoPath) {
4530
- const res = await runBinary(ffprobePath(), [
4531
- "-v",
4532
- "error",
4533
- "-print_format",
4534
- "json",
4535
- "-show_format",
4536
- "-show_streams",
4537
- videoPath
4538
- ]);
4539
- if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
4540
- const json = JSON.parse(res.stdout);
4541
- const v = json.streams?.find((s) => s.codec_type === "video");
4542
- if (!v) throw new Error("no video stream found");
4543
- const [num, den] = v.avg_frame_rate.split("/").map(Number);
4544
- return {
4545
- codec: v.codec_name,
4546
- width: v.width,
4547
- height: v.height,
4548
- durationSec: Number(json.format?.duration ?? 0),
4549
- fps: den ? num / den : 0
4550
- };
4551
- }
4552
- function meanAbsDiff(a, b) {
4553
- const n = Math.min(a.length, b.length);
4554
- let sum = 0;
4555
- for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
4556
- return sum / n;
4557
- }
4558
- function variance(a) {
4559
- let sum = 0;
4560
- for (let i = 0; i < a.length; i++) sum += a[i];
4561
- const mean = sum / a.length;
4562
- let v = 0;
4563
- for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
4564
- return v / a.length;
4565
- }
4566
- async function sourceFrameGray(bundle, tMs, width) {
4567
- const idx = frameIndexForTime(bundle.frames, tMs);
4568
- const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
4569
- return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
4570
- }
4571
- async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
4572
- let idx = frameIndexForTime(bundle.frames, tMs);
4573
- if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
4574
- const meta = await sharp3(framePath(bundle, idx)).metadata();
4575
- const fw = meta.width ?? 0;
4576
- const fh = meta.height ?? 0;
4577
- const x = Math.max(0, Math.round(region.x));
4578
- const y = Math.max(0, Math.round(region.y));
4579
- const w = Math.min(Math.round(region.w), fw - x);
4580
- const h = Math.min(Math.round(region.h), fh - y);
4581
- if (w < 4 || h < 4) return null;
4582
- let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
4583
- if (!color) img = img.grayscale();
4584
- const { data } = await img.raw().toBuffer({ resolveWithObject: true });
4585
- return data;
4586
- }
4587
- async function regionGray48(input, rect) {
4588
- try {
4589
- const img = sharp3(input);
4590
- const meta = await img.metadata();
4591
- const W = meta.width ?? 0;
4592
- const H = meta.height ?? 0;
4593
- const left = Math.max(0, Math.round(rect.x));
4594
- const top = Math.max(0, Math.round(rect.y));
4595
- const width = Math.min(W - left, Math.round(rect.w));
4596
- const height = Math.min(H - top, Math.round(rect.h));
4597
- if (width < 8 || height < 8) return null;
4598
- return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
4599
- } catch {
4600
- return null;
5344
+ );
4601
5345
  }
4602
- }
4603
- function normalizedCorrelation(a, b) {
4604
- const n = Math.min(a.length, b.length);
4605
- let ma = 0;
4606
- let mb = 0;
4607
- for (let i = 0; i < n; i++) {
4608
- ma += a[i];
4609
- mb += b[i];
5346
+ if (narrationDegraded && !manifest.audio) {
5347
+ manifest.audio = {
5348
+ narrated: false,
5349
+ provider: audioCfg?.provider ?? "say",
5350
+ voice: voiceLabel,
5351
+ lines: 0,
5352
+ degraded: narrationDegraded
5353
+ };
5354
+ } else if (narrationDegraded && manifest.audio) {
5355
+ manifest.audio.degraded = narrationDegraded;
4610
5356
  }
4611
- ma /= n;
4612
- mb /= n;
4613
- let num = 0;
4614
- let da = 0;
4615
- let db = 0;
4616
- for (let i = 0; i < n; i++) {
4617
- const xa = a[i] - ma;
4618
- const xb = b[i] - mb;
4619
- num += xa * xb;
4620
- da += xa * xa;
4621
- db += xb * xb;
5357
+ manifest.videoSha256 = await sha256File(videoPath);
5358
+ const manifestPath = join8(opts.outDir, "compose-manifest.json");
5359
+ await writeFile3(manifestPath, JSON.stringify(manifest, null, 2));
5360
+ return { videoPath, manifestPath, manifest };
5361
+ }
5362
+ function safeHost(url) {
5363
+ try {
5364
+ return new URL(url).host;
5365
+ } catch {
5366
+ return url;
4622
5367
  }
4623
- if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
4624
- return num / Math.sqrt(da * db);
4625
5368
  }
4626
- var init_media = __esm({
4627
- "src/verify/media.ts"() {
5369
+ var BLINK_DIFF_MIN, JUMP_DIFF_MIN, JUMP_DISSOLVE_MS;
5370
+ var init_compose = __esm({
5371
+ "src/compose/index.ts"() {
4628
5372
  "use strict";
4629
- init_ffmpeg();
5373
+ init_schema();
5374
+ init_theme();
5375
+ init_types();
5376
+ init_kinds();
5377
+ init_pacing();
5378
+ init_planner();
5379
+ init_path();
5380
+ init_build();
5381
+ init_renderer();
5382
+ init_media();
4630
5383
  init_reader();
5384
+ init_theme();
5385
+ init_hash();
5386
+ init_log();
5387
+ init_events();
5388
+ init_captions();
5389
+ init_schema();
5390
+ init_tts();
5391
+ init_mux();
5392
+ BLINK_DIFF_MIN = 25;
5393
+ JUMP_DIFF_MIN = 18;
5394
+ JUMP_DISSOLVE_MS = 360;
4631
5395
  }
4632
5396
  });
4633
5397
 
@@ -4708,7 +5472,47 @@ async function checkCursorOnTarget(ctx2) {
4708
5472
  }
4709
5473
  }
4710
5474
  if (checked === 0) return { id: "cursor-on-target", status: "skip", details: "no click steps" };
4711
- 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` };
5475
+ if (evidence.length > 0)
5476
+ return { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence };
5477
+ const p = plane(ctx2);
5478
+ const content = stageContent(ctx2.manifest.profile);
5479
+ const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
5480
+ const clickSteps = ctx2.manifest.steps.filter((s) => CLICK_KINDS2.has(s.kind) && s.targetRectVp);
5481
+ let sampled = 0;
5482
+ let present = 0;
5483
+ for (const step of clickSteps.slice(0, 2)) {
5484
+ const t = step.outBeat;
5485
+ const pos = sampleCursor(cursorPlan, t);
5486
+ const boxVp = { x: pos.x - 16, y: pos.y - 16, w: 32, h: 32 };
5487
+ const cam = sampleCamera(ctx2.manifest.camera, t);
5488
+ const proj = projectRect(boxVp, cam, p);
5489
+ const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
5490
+ const { srcAt } = sampleSource(ctx2.manifest.segments, t);
5491
+ if (srcAt === null) continue;
5492
+ const outPng = await extractFrameAt(ctx2.videoPath, t).catch(() => null);
5493
+ if (!outPng) continue;
5494
+ const [outRegion, srcRegion] = await Promise.all([
5495
+ regionGray48(outPng, projFrame),
5496
+ regionGray48(framePath(ctx2.bundle, frameIndexForTime(ctx2.bundle.frames, srcAt)), {
5497
+ x: boxVp.x * frameScale,
5498
+ y: boxVp.y * frameScale,
5499
+ w: boxVp.w * frameScale,
5500
+ h: boxVp.h * frameScale
5501
+ })
5502
+ ]);
5503
+ if (!outRegion || !srcRegion) continue;
5504
+ sampled += 1;
5505
+ if (meanAbsDiff(outRegion, srcRegion) > 6) present += 1;
5506
+ }
5507
+ if (sampled > 0 && present === 0) {
5508
+ return {
5509
+ id: "cursor-on-target",
5510
+ status: "fail",
5511
+ 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`
5512
+ };
5513
+ }
5514
+ const renderedNote = sampled > 0 ? `; sprite verified on film at ${present}/${sampled} sampled beat(s)` : "";
5515
+ return { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target${renderedNote}` };
4712
5516
  }
4713
5517
  async function checkActionEffect(ctx2) {
4714
5518
  const evidence = [];
@@ -4766,12 +5570,14 @@ async function checkActionEffect(ctx2) {
4766
5570
  }
4767
5571
  const maskedNote = skippedMasked > 0 ? ` (${skippedMasked} masked skipped)` : "";
4768
5572
  const inconcNote = inconclusive.length > 0 ? `, ${inconclusive.length} inconclusive (capture gap)` : "";
5573
+ const coverage = { verified: checked - evidence.length - inconclusive.length, total: checked + skippedMasked };
4769
5574
  if (evidence.length > 0) {
4770
5575
  return {
4771
5576
  id: "action-effect",
4772
5577
  status: "fail",
4773
5578
  details: `${evidence.length}/${checked} actions show no visible effect${maskedNote}${inconcNote}`,
4774
- evidence: [...evidence, ...inconclusive]
5579
+ evidence: [...evidence, ...inconclusive],
5580
+ coverage
4775
5581
  };
4776
5582
  }
4777
5583
  if (inconclusive.length > 0) {
@@ -4779,10 +5585,11 @@ async function checkActionEffect(ctx2) {
4779
5585
  id: "action-effect",
4780
5586
  status: "warn",
4781
5587
  details: `${checked - inconclusive.length}/${checked} actions visibly effective; ${inconclusive.length} unverified (capture gap)${maskedNote}`,
4782
- evidence: inconclusive
5588
+ evidence: inconclusive,
5589
+ coverage
4783
5590
  };
4784
5591
  }
4785
- return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}` };
5592
+ return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}`, coverage };
4786
5593
  }
4787
5594
  async function checkFrozenBlank(ctx2) {
4788
5595
  const { frames, intervalMs } = await extractGrayFrames(ctx2.videoPath, 2, 320, 180);
@@ -4790,11 +5597,23 @@ async function checkFrozenBlank(ctx2) {
4790
5597
  const evidence = [];
4791
5598
  const dips = ctx2.manifest.overlays.filter((o) => o.kind === "dip");
4792
5599
  const inTransition = (t) => segmentAt(ctx2.manifest.segments, t).kind === "card" || dips.some((d) => t >= d.tStart - 60 && t <= d.tEnd + 60);
5600
+ const centerBlank = [];
5601
+ const W = 320;
5602
+ const H = 180;
4793
5603
  for (let i = 0; i < frames.length; i++) {
4794
5604
  const t = i * intervalMs;
4795
5605
  if (inTransition(t)) continue;
4796
5606
  if (variance(frames[i]) < 2) {
4797
5607
  evidence.push({ t, note: `blank frame at ${(t / 1e3).toFixed(1)}s` });
5608
+ continue;
5609
+ }
5610
+ const f = frames[i];
5611
+ const center = [];
5612
+ for (let y = Math.floor(H * 0.3); y < Math.floor(H * 0.7); y++) {
5613
+ for (let x = Math.floor(W * 0.25); x < Math.floor(W * 0.75); x++) center.push(f[y * W + x]);
5614
+ }
5615
+ if (variance(Buffer.from(center)) < 3) {
5616
+ 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` });
4798
5617
  }
4799
5618
  }
4800
5619
  const maskedWindows = ctx2.manifest.steps.filter((s) => s.masked).map((s) => ({ start: s.outStart, end: s.outEnd }));
@@ -4829,7 +5648,18 @@ async function checkFrozenBlank(ctx2) {
4829
5648
  runStart = i;
4830
5649
  }
4831
5650
  }
4832
- 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` };
5651
+ if (evidence.length > 0) {
5652
+ return { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence: [...evidence, ...centerBlank] };
5653
+ }
5654
+ if (centerBlank.length > 0) {
5655
+ return {
5656
+ id: "frozen-blank",
5657
+ status: "warn",
5658
+ details: `frames live, but ${centerBlank.length} sampled frame(s) have a blank content region (mid-hydration footage?)`,
5659
+ evidence: centerBlank
5660
+ };
5661
+ }
5662
+ return { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
4833
5663
  }
4834
5664
  async function checkCaptions(ctx2) {
4835
5665
  const evidence = [];
@@ -4840,7 +5670,7 @@ async function checkCaptions(ctx2) {
4840
5670
  if (o.kind !== "caption") continue;
4841
5671
  count += 1;
4842
5672
  if (o.truncated) evidence.push({ stepRef: o.stepRef, note: `caption truncated: "${o.text}"` });
4843
- if (o.fontPx < ctx2.manifest.theme.captionMinFontPx)
5673
+ if (o.fontPx < Math.round(ctx2.manifest.theme.captionMinFontPx * ctx2.manifest.profile.uiScale))
4844
5674
  evidence.push({ stepRef: o.stepRef, note: `caption font ${o.fontPx}px below minimum` });
4845
5675
  if (!rectContains(out, o.box, 24)) evidence.push({ stepRef: o.stepRef, note: "caption box outside safe bounds" });
4846
5676
  if (o.shrunk && !o.truncated) warned += 1;
@@ -4848,7 +5678,35 @@ async function checkCaptions(ctx2) {
4848
5678
  if (count === 0) return { id: "captions", status: "skip", details: "no captions" };
4849
5679
  if (evidence.length > 0)
4850
5680
  return { id: "captions", status: "fail", details: `${evidence.length} caption problem(s)`, evidence };
4851
- 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` };
5681
+ const caps = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
5682
+ const stride = Math.max(1, Math.floor(caps.length / 3));
5683
+ let sampled = 0;
5684
+ let drawn = 0;
5685
+ for (let i = 0; i < caps.length && sampled < 3; i += stride) {
5686
+ const c = caps[i];
5687
+ const tOn = (c.tStart + c.tEnd) / 2;
5688
+ const tOff = c.tEnd + 500;
5689
+ const clashes = caps.some((o) => o !== c && tOff >= o.tStart - 100 && tOff <= o.tEnd + 100);
5690
+ if (clashes || tOff >= ctx2.manifest.durationMs - 200) continue;
5691
+ const [onPng, offPng] = await Promise.all([
5692
+ extractFrameAt(ctx2.videoPath, tOn).catch(() => null),
5693
+ extractFrameAt(ctx2.videoPath, tOff).catch(() => null)
5694
+ ]);
5695
+ if (!onPng || !offPng) continue;
5696
+ const [ra, rb] = await Promise.all([regionGray48(onPng, c.box), regionGray48(offPng, c.box)]);
5697
+ if (!ra || !rb) continue;
5698
+ sampled += 1;
5699
+ if (meanAbsDiff(ra, rb) > 4) drawn += 1;
5700
+ }
5701
+ if (sampled > 0 && drawn === 0) {
5702
+ return {
5703
+ id: "captions",
5704
+ status: "fail",
5705
+ details: `captions planned but NOT FOUND in the output pixels (${sampled} sampled boxes identical with and without caption)`
5706
+ };
5707
+ }
5708
+ const renderedNote = sampled > 0 ? `; ${drawn}/${sampled} sampled on film` : "";
5709
+ 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}` };
4852
5710
  }
4853
5711
  async function checkPacing(ctx2) {
4854
5712
  const evidence = [];
@@ -4864,7 +5722,20 @@ async function checkPacing(ctx2) {
4864
5722
  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` };
4865
5723
  }
4866
5724
  async function checkAudio(ctx2) {
4867
- if (!ctx2.manifest.audio?.narrated) return { id: "audio", status: "skip", details: "no narration" };
5725
+ if (ctx2.manifest.audio?.degraded) {
5726
+ return {
5727
+ id: "audio",
5728
+ status: "fail",
5729
+ details: `narration was requested but degraded to silence: ${ctx2.manifest.audio.degraded}`
5730
+ };
5731
+ }
5732
+ if (!ctx2.manifest.audio?.narrated) {
5733
+ return {
5734
+ id: "audio",
5735
+ status: "skip",
5736
+ details: "no narration \u2014 for a voice-over, set output.audio: { narration: tts } and re-compose"
5737
+ };
5738
+ }
4868
5739
  const evidence = [];
4869
5740
  const probe = await runBinary(ffprobePath(), [
4870
5741
  "-v",
@@ -4999,9 +5870,37 @@ async function checkMasks(ctx2) {
4999
5870
  if (evidence.length > 0) {
5000
5871
  return { id: "masks", status: "fail", details: `${evidence.length} mask violation(s)`, evidence };
5001
5872
  }
5873
+ const p = plane(ctx2);
5874
+ const content = stageContent(ctx2.manifest.profile);
5875
+ 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);
5876
+ let outSampled = 0;
5877
+ let outCovered = 0;
5878
+ for (const s of solidSamples) {
5879
+ const tOut = outTimeForSrc(ctx2.manifest.segments, s.t);
5880
+ if (tOut === null || tOut >= ctx2.manifest.durationMs - 100) continue;
5881
+ const cam = sampleCamera(ctx2.manifest.camera, tOut);
5882
+ const rectVp = { x: s.r.x / frameScale, y: s.r.y / frameScale, w: s.r.w / frameScale, h: s.r.h / frameScale };
5883
+ const proj = projectRect(rectVp, cam, p);
5884
+ const projFrame = { x: proj.x + content.x + 4, y: proj.y + content.y + 4, w: proj.w - 8, h: proj.h - 8 };
5885
+ if (projFrame.w < 12 || projFrame.h < 12) continue;
5886
+ const png = await extractFrameAt(ctx2.videoPath, tOut).catch(() => null);
5887
+ if (!png) continue;
5888
+ const region = await regionGray48(png, projFrame);
5889
+ if (!region) continue;
5890
+ outSampled += 1;
5891
+ if (Math.sqrt(variance(region)) < 16) outCovered += 1;
5892
+ }
5893
+ if (outSampled > 0 && outCovered === 0) {
5894
+ return {
5895
+ id: "masks",
5896
+ status: "fail",
5897
+ details: `masked regions verified in SOURCE frames but NOT covered in the delivered video (${outSampled} output sample(s) show content)`
5898
+ };
5899
+ }
5002
5900
  const parts = [];
5003
5901
  if (buckets.size > 0) parts.push(`${buckets.size} solid region(s) verified opaque`);
5004
5902
  if (blurKeys.size > 0) parts.push(`${blurKeys.size} blurred region(s) applied`);
5903
+ if (outSampled > 0) parts.push(`${outCovered}/${outSampled} re-verified in the output video`);
5005
5904
  return { id: "masks", status: "pass", details: `${parts.join(", ")}; log clean` };
5006
5905
  }
5007
5906
  function scaleRect(r, s) {
@@ -5059,7 +5958,7 @@ async function checkOutputPixels(ctx2) {
5059
5958
  }
5060
5959
  }
5061
5960
  if (checked === 0) return { id: "output-pixels", status: "skip", details: "no comparable samples (captions overlapped or frames unavailable)" };
5062
- 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` };
5961
+ 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 } };
5063
5962
  }
5064
5963
  var EFFECT_KINDS, CLICK_KINDS2;
5065
5964
  var init_checks = __esm({
@@ -5242,17 +6141,24 @@ var init_vision = __esm({
5242
6141
  // src/verify/report.ts
5243
6142
  var report_exports = {};
5244
6143
  __export(report_exports, {
6144
+ flowFailureJUnit: () => flowFailureJUnit,
6145
+ keyIdFor: () => keyIdFor,
5245
6146
  signVerdict: () => signVerdict,
5246
6147
  signablePayload: () => signablePayload,
5247
- toJUnit: () => toJUnit
6148
+ toJUnit: () => toJUnit,
6149
+ verifySignature: () => verifySignature
5248
6150
  });
5249
- import { createHmac } from "crypto";
6151
+ import { createHmac, createHash as createHash4, sign as edSign, verify as edVerify, createPrivateKey, createPublicKey } from "crypto";
6152
+ import { readFileSync as readFileSync2 } from "fs";
5250
6153
  function signablePayload(v) {
5251
6154
  return JSON.stringify({
5252
6155
  schema: v.schema,
5253
6156
  verdict: v.verdict,
5254
6157
  videoSha256: v.video.sha256,
6158
+ manifestHash: v.manifestHash,
6159
+ contactSheetSha256: v.contactSheetSha256,
5255
6160
  bundleHashes: { events: v.bundleHashes.events, framesIndex: v.bundleHashes.framesIndex },
6161
+ coverage: v.coverage,
5256
6162
  provenance: {
5257
6163
  playheadVersion: v.provenance.playheadVersion,
5258
6164
  specHash: v.provenance.specHash,
@@ -5261,35 +6167,102 @@ function signablePayload(v) {
5261
6167
  verifiedAt: v.provenance.verifiedAt,
5262
6168
  checkSuiteVersion: v.provenance.checkSuiteVersion,
5263
6169
  host: v.provenance.host,
5264
- partial: v.provenance.partial ?? false
6170
+ partial: v.provenance.partial ?? false,
6171
+ failure: v.provenance.failure ?? null
5265
6172
  },
5266
- checks: v.checks.map((c) => ({ id: c.id, status: c.status }))
6173
+ checks: v.checks.map((c) => ({
6174
+ id: c.id,
6175
+ status: c.status,
6176
+ details: c.details,
6177
+ evidence: (c.evidence ?? []).map((e) => ({ stepRef: e.stepRef ?? null, t: e.t ?? null, note: e.note }))
6178
+ })),
6179
+ vision: v.vision
5267
6180
  });
5268
6181
  }
5269
- function signVerdict(v, key = process.env.PLAYHEAD_SIGNING_KEY) {
5270
- if (!key) return null;
5271
- return createHmac("sha256", key).update(signablePayload(v)).digest("hex");
6182
+ function signVerdict(v, env = {
6183
+ ...process.env.PLAYHEAD_SIGNING_KEY ? { hmacKey: process.env.PLAYHEAD_SIGNING_KEY } : {},
6184
+ ...process.env.PLAYHEAD_SIGNING_KEY_FILE ? { keyFile: process.env.PLAYHEAD_SIGNING_KEY_FILE } : {}
6185
+ }) {
6186
+ const payload = Buffer.from(signablePayload(v));
6187
+ if (env.keyFile) {
6188
+ const privateKey = createPrivateKey(readFileSync2(env.keyFile, "utf8"));
6189
+ const publicKey = createPublicKey(privateKey);
6190
+ const spki = publicKey.export({ type: "spki", format: "der" });
6191
+ return {
6192
+ alg: "Ed25519",
6193
+ keyId: keyIdFor(spki),
6194
+ publicKey: spki.toString("base64"),
6195
+ value: edSign(null, payload, privateKey).toString("base64")
6196
+ };
6197
+ }
6198
+ if (env.hmacKey) {
6199
+ return {
6200
+ alg: "HS256",
6201
+ keyId: keyIdFor(Buffer.from(env.hmacKey)),
6202
+ value: createHmac("sha256", env.hmacKey).update(payload).digest("hex")
6203
+ };
6204
+ }
6205
+ return null;
6206
+ }
6207
+ function verifySignature(v, opts = {}) {
6208
+ if (!v.signature) return { valid: false, reason: "verdict is unsigned" };
6209
+ const payload = Buffer.from(signablePayload(stripSignature(v)));
6210
+ if (v.signature.alg === "Ed25519") {
6211
+ if (!v.signature.publicKey) return { valid: false, reason: "Ed25519 signature missing its public key" };
6212
+ const key = createPublicKey({
6213
+ key: Buffer.from(v.signature.publicKey, "base64"),
6214
+ type: "spki",
6215
+ format: "der"
6216
+ });
6217
+ const ok = edVerify(null, payload, key, Buffer.from(v.signature.value, "base64"));
6218
+ return ok ? { valid: true, reason: `Ed25519 signature valid for key ${v.signature.keyId} \u2014 pin this key id to trust the producer` } : { valid: false, reason: "Ed25519 SIGNATURE MISMATCH \u2014 the verdict was modified after signing" };
6219
+ }
6220
+ if (v.signature.alg === "HS256") {
6221
+ const key = opts.hmacKey ?? process.env.PLAYHEAD_SIGNING_KEY;
6222
+ if (!key) return { valid: false, reason: "HS256 verdict but PLAYHEAD_SIGNING_KEY is not set \u2014 cannot check" };
6223
+ const expect = createHmac("sha256", key).update(payload).digest("hex");
6224
+ return expect === v.signature.value ? { valid: true, reason: `HS256 signature valid (key ${v.signature.keyId}) \u2014 note: symmetric, proves integrity within the key's trust domain only` } : { valid: false, reason: "HS256 SIGNATURE MISMATCH \u2014 the verdict was modified after signing (or a different key)" };
6225
+ }
6226
+ return { valid: false, reason: `unknown signature algorithm ${v.signature.alg}` };
6227
+ }
6228
+ function stripSignature(v) {
6229
+ const { signature: _sig, ...rest } = v;
6230
+ return rest;
6231
+ }
6232
+ function keyIdFor(material) {
6233
+ return createHash4("sha256").update(material).digest("hex").slice(0, 16);
5272
6234
  }
5273
6235
  function toJUnit(v, suiteName = "playhead") {
5274
6236
  const failures = v.checks.filter((c) => c.status === "fail").length;
5275
6237
  const skipped = v.checks.filter((c) => c.status === "skip").length;
5276
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5277
6238
  const cases = v.checks.map((c) => {
5278
6239
  const body = c.status === "fail" ? `
5279
- <failure message="${esc(c.details)}">${esc(
6240
+ <failure message="${escXml(c.details)}">${escXml(
5280
6241
  (c.evidence ?? []).map((e) => `${e.stepRef ?? ""}${e.t !== void 0 ? ` @${(e.t / 1e3).toFixed(1)}s` : ""} \u2014 ${e.note}`).join("\n")
5281
6242
  )}</failure>
5282
6243
  ` : c.status === "skip" ? `
5283
- <skipped message="${esc(c.details)}"/>
6244
+ <skipped message="${escXml(c.details)}"/>
5284
6245
  ` : "";
5285
- return ` <testcase classname="${esc(suiteName)}" name="${esc(c.id)}">${body}</testcase>`;
6246
+ return ` <testcase classname="${escXml(suiteName)}" name="${escXml(c.id)}">${body}</testcase>`;
5286
6247
  }).join("\n");
5287
6248
  return `<?xml version="1.0" encoding="UTF-8"?>
5288
- <testsuite name="${esc(suiteName)}" tests="${v.checks.length}" failures="${failures}" skipped="${skipped}">
6249
+ <testsuite name="${escXml(suiteName)}" tests="${v.checks.length}" failures="${failures}" skipped="${skipped}">
5289
6250
  ${cases}
5290
6251
  </testsuite>
5291
6252
  `;
5292
6253
  }
6254
+ function flowFailureJUnit(failure, suiteName = "playhead") {
6255
+ return `<?xml version="1.0" encoding="UTF-8"?>
6256
+ <testsuite name="${escXml(suiteName)}" tests="1" failures="1" skipped="0">
6257
+ <testcase classname="${escXml(suiteName)}" name="flow">
6258
+ <failure message="${escXml(`flow failed at ${failure.stepRef}`)}">${escXml(failure.message)}</failure>
6259
+ </testcase>
6260
+ </testsuite>
6261
+ `;
6262
+ }
6263
+ function escXml(s) {
6264
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6265
+ }
5293
6266
  var init_report = __esm({
5294
6267
  "src/verify/report.ts"() {
5295
6268
  "use strict";
@@ -5297,12 +6270,12 @@ var init_report = __esm({
5297
6270
  });
5298
6271
 
5299
6272
  // src/verify/runner.ts
5300
- import { join as join8 } from "path";
6273
+ import { join as join9 } from "path";
5301
6274
  import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile4 } from "fs/promises";
5302
6275
  import { platform, arch } from "os";
5303
6276
  import pc2 from "picocolors";
5304
6277
  async function verify(bundle, manifest, videoPath, opts) {
5305
- const verifyDir = join8(opts.outDir, "verify");
6278
+ const verifyDir = join9(opts.outDir, "verify");
5306
6279
  await mkdir4(verifyDir, { recursive: true });
5307
6280
  const ctx2 = { bundle, manifest, videoPath, outDir: verifyDir };
5308
6281
  log.info("verifying output");
@@ -5326,7 +6299,7 @@ async function verify(bundle, manifest, videoPath, opts) {
5326
6299
  checks.push(result);
5327
6300
  report(result);
5328
6301
  }
5329
- const contactSheet = join8(verifyDir, "contact-sheet.png");
6302
+ const contactSheet = join9(verifyDir, "contact-sheet.png");
5330
6303
  await renderContactSheet(manifest, videoPath, contactSheet);
5331
6304
  log.ok(`contact sheet \u2192 ${contactSheet}`);
5332
6305
  let vision = null;
@@ -5337,11 +6310,28 @@ async function verify(bundle, manifest, videoPath, opts) {
5337
6310
  vision = review;
5338
6311
  }
5339
6312
  const failed = checks.some((c) => c.status === "fail");
6313
+ const effectCoverage = checks.find((c) => c.id === "action-effect")?.coverage;
6314
+ const pixelCoverage = checks.find((c) => c.id === "output-pixels")?.coverage;
6315
+ const coverage = {
6316
+ actionsVerified: effectCoverage?.verified ?? 0,
6317
+ actionsTotal: effectCoverage?.total ?? 0,
6318
+ outputSamplesVerified: pixelCoverage?.verified ?? 0,
6319
+ checksSkipped: checks.filter((c) => c.status === "skip").length,
6320
+ checksWarned: checks.filter((c) => c.status === "warn").length
6321
+ };
5340
6322
  const unsigned = {
5341
- schema: "playhead/verdict@2",
6323
+ schema: "playhead/verdict@3",
5342
6324
  verdict: failed ? "not-publishable" : "publishable",
5343
- video: { path: videoPath, sha256: manifest.videoSha256 ?? await sha256File(videoPath) },
6325
+ // ALWAYS hash the file the checks actually ran against — never trust the manifest's claim
6326
+ // (an unauthenticated JSON from disk). Round-2 audit: a doctored manifest could bind the
6327
+ // signed verdict to a video that was never verified.
6328
+ video: { path: videoPath, sha256: await sha256File(videoPath) },
5344
6329
  bundleHashes: manifest.bundleHashes,
6330
+ // The compose manifest is the oracle for the plan-side checks — hash it into the verdict
6331
+ // so a doctored manifest can no longer mint passes undetected.
6332
+ manifestHash: sha256Json(manifest),
6333
+ contactSheetSha256: await sha256File(contactSheet),
6334
+ coverage,
5345
6335
  provenance: {
5346
6336
  playheadVersion: PLAYHEAD_VERSION,
5347
6337
  specHash: bundle.manifest.specHash,
@@ -5359,9 +6349,10 @@ async function verify(bundle, manifest, videoPath, opts) {
5359
6349
  };
5360
6350
  const verdict = { ...unsigned, signature: signVerdict(unsigned) };
5361
6351
  if (!verdict.signature) log.debug("verdict unsigned \u2014 set PLAYHEAD_SIGNING_KEY to sign");
5362
- await writeFile5(join8(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
5363
- if (failed) log.error(`verdict: NOT PUBLISHABLE`);
5364
- else log.ok(`verdict: publishable`);
6352
+ await writeFile5(join9(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
6353
+ const cov = coverage.actionsTotal > 0 ? ` (coverage: ${coverage.actionsVerified}/${coverage.actionsTotal} actions pixel-verified, ${coverage.outputSamplesVerified} output samples)` : " (coverage: no effectful actions)";
6354
+ if (failed) log.error(`verdict: NOT PUBLISHABLE${cov}`);
6355
+ else log.ok(`verdict: publishable${cov}`);
5365
6356
  return verdict;
5366
6357
  }
5367
6358
  async function loadComposeManifest(path) {
@@ -5389,7 +6380,7 @@ var init_runner = __esm({
5389
6380
  init_report();
5390
6381
  init_version();
5391
6382
  init_log();
5392
- CHECK_SUITE_VERSION = "playhead/checks@2";
6383
+ CHECK_SUITE_VERSION = "playhead/checks@3";
5393
6384
  }
5394
6385
  });
5395
6386
 
@@ -5398,14 +6389,14 @@ var beforeafter_exports = {};
5398
6389
  __export(beforeafter_exports, {
5399
6390
  renderBeforeAfter: () => renderBeforeAfter
5400
6391
  });
5401
- import { join as join9 } from "path";
6392
+ import { join as join10 } from "path";
5402
6393
  import { mkdir as mkdir5 } from "fs/promises";
5403
6394
  async function renderBeforeAfter(spec, opts) {
5404
6395
  if (!spec.app.compareUrl) {
5405
6396
  throw new Error('before-after needs app.compareUrl (the "before" version) alongside app.url (the "after")');
5406
6397
  }
5407
6398
  const side = async (label, url) => {
5408
- const dir = join9(opts.outDir, label);
6399
+ const dir = join10(opts.outDir, label);
5409
6400
  await mkdir5(dir, { recursive: true });
5410
6401
  const sideSpec = {
5411
6402
  ...spec,
@@ -5421,7 +6412,7 @@ async function renderBeforeAfter(spec, opts) {
5421
6412
  };
5422
6413
  const before = await side("before", spec.app.compareUrl);
5423
6414
  const after = await side("after", spec.app.url);
5424
- const videoPath = join9(opts.outDir, "before-after.mp4");
6415
+ const videoPath = join10(opts.outDir, "before-after.mp4");
5425
6416
  await concatVideos([before.videoPath, after.videoPath], videoPath);
5426
6417
  const publishable = before.verdict.verdict === "publishable" && after.verdict.verdict === "publishable";
5427
6418
  log.info(`before/after \u2192 ${videoPath}`);
@@ -5456,8 +6447,8 @@ __export(jira_exports, {
5456
6447
  reportToJira: () => reportToJira
5457
6448
  });
5458
6449
  import { readFile as readFile5, stat } from "fs/promises";
5459
- import { join as join10, basename } from "path";
5460
- import { existsSync as existsSync4 } from "fs";
6450
+ import { join as join11, basename } from "path";
6451
+ import { existsSync as existsSync5 } from "fs";
5461
6452
  function jiraConfigFromEnv() {
5462
6453
  const baseUrl = process.env.JIRA_BASE_URL;
5463
6454
  const email = process.env.JIRA_EMAIL;
@@ -5521,17 +6512,17 @@ async function addComment(cfg, issueKey, adfBody) {
5521
6512
  }
5522
6513
  async function collectEvidence(outDir) {
5523
6514
  const candidates = [
5524
- join10(outDir, "out.mp4"),
5525
- join10(outDir, "failure.mp4"),
5526
- join10(outDir, "verify", "verdict.json"),
5527
- join10(outDir, "verify", "contact-sheet.png"),
5528
- join10(outDir, "junit.xml"),
5529
- join10(outDir, "capture", "failure.json"),
5530
- join10(outDir, "capture", "console.json")
6515
+ join11(outDir, "out.mp4"),
6516
+ join11(outDir, "failure.mp4"),
6517
+ join11(outDir, "verify", "verdict.json"),
6518
+ join11(outDir, "verify", "contact-sheet.png"),
6519
+ join11(outDir, "junit.xml"),
6520
+ join11(outDir, "capture", "failure.json"),
6521
+ join11(outDir, "capture", "console.json")
5531
6522
  ];
5532
6523
  const files = [];
5533
6524
  for (const f of candidates) {
5534
- if (!existsSync4(f)) continue;
6525
+ if (!existsSync5(f)) continue;
5535
6526
  const s = await stat(f);
5536
6527
  if (s.size > 95 * 1024 * 1024) {
5537
6528
  log.warn(`skipping ${basename(f)} (${(s.size / 1e6).toFixed(0)}MB \u2014 larger than Jira's usual attachment ceiling)`);
@@ -5543,11 +6534,11 @@ async function collectEvidence(outDir) {
5543
6534
  throw new InfraError(`no Playhead artifacts found under ${outDir} \u2014 expected out.mp4/failure.mp4, verify/verdict.json, \u2026`);
5544
6535
  }
5545
6536
  let verdict;
5546
- const verdictPath = join10(outDir, "verify", "verdict.json");
5547
- if (existsSync4(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
6537
+ const verdictPath = join11(outDir, "verify", "verdict.json");
6538
+ if (existsSync5(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
5548
6539
  let failure;
5549
- const failurePath = join10(outDir, "capture", "failure.json");
5550
- if (existsSync4(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
6540
+ const failurePath = join11(outDir, "capture", "failure.json");
6541
+ if (existsSync5(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
5551
6542
  const outcome = failure ? "flow-failed" : verdict?.verdict ?? "unknown";
5552
6543
  return { outcome, ...verdict ? { verdict } : {}, ...failure ? { failure } : {}, files };
5553
6544
  }
@@ -5647,7 +6638,7 @@ function kindOf(el) {
5647
6638
  function quote(s) {
5648
6639
  const clean = s.replace(/\s+/g, " ").trim();
5649
6640
  if (clean.includes('"')) {
5650
- return `/${escapeRegex(clean).replace(/\//g, "\\/")}/`;
6641
+ return `/${escapeRegex(clean).replace(/\//g, "\\/").replace(/>/g, "\\x3e")}/`;
5651
6642
  }
5652
6643
  return `"${clean}"`;
5653
6644
  }
@@ -5686,7 +6677,12 @@ async function snapshotPage(driver) {
5686
6677
  let unique = false;
5687
6678
  let ambiguous = null;
5688
6679
  for (const cand of candidates) {
5689
- const count = await driver.countMatches(parseLocator(cand)).catch(() => 0);
6680
+ let count = 0;
6681
+ try {
6682
+ count = await driver.countMatches(parseLocator(cand)).catch(() => 0);
6683
+ } catch {
6684
+ continue;
6685
+ }
5690
6686
  if (count === 1) {
5691
6687
  chosen = cand;
5692
6688
  unique = true;
@@ -5784,13 +6780,54 @@ async function validateLive(spec, opts) {
5784
6780
  viewport: resolveViewport(spec),
5785
6781
  dpr: 1,
5786
6782
  headless: opts?.headless ?? true,
5787
- ...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {}
6783
+ ...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {},
6784
+ ...spec.app.environment ? { environment: spec.app.environment } : {},
6785
+ ...spec.app.network ? { network: spec.app.network } : {},
6786
+ ...spec.app.dialogs ? { dialogs: spec.app.dialogs } : {}
5788
6787
  });
5789
6788
  }
6789
+ const settleCfg = { idleMs: spec.app.settle?.idleMs ?? 300, capMs: spec.app.settle?.capMs ?? 5e3 };
6790
+ const vp = resolveViewport(spec);
6791
+ const res = resolveResolution(spec);
6792
+ const plane2 = { vpW: vp.w, vpH: vp.h, outW: res.w, outH: res.h };
5790
6793
  try {
5791
6794
  await driver.goto(spec.app.url);
5792
- await driver.settle({ idleMs: 300, capMs: 5e3 });
5793
- for (const a of flattenSteps(spec)) {
6795
+ await driver.settle(settleCfg);
6796
+ if (spec.app.assertLoggedIn) {
6797
+ const n = await driver.countMatches(parseLocator(spec.app.assertLoggedIn)).catch(() => 0);
6798
+ if (n === 0) {
6799
+ issues.push({
6800
+ stepRef: "(setup)",
6801
+ step: "assertLoggedIn",
6802
+ locator: spec.app.assertLoggedIn,
6803
+ severity: "error",
6804
+ message: "assertLoggedIn matches nothing \u2014 storageState stale? Re-run: playhead login",
6805
+ suggestions: []
6806
+ });
6807
+ }
6808
+ }
6809
+ for (const m of spec.masking) {
6810
+ checked += 1;
6811
+ const n = await driver.countMatches(parseLocator(m.target)).catch(() => 0);
6812
+ if (n === 0) {
6813
+ issues.push({
6814
+ stepRef: "(masking)",
6815
+ step: "mask",
6816
+ locator: m.target,
6817
+ severity: "warn",
6818
+ message: "masking rule matches nothing on the initial screen (fine if the element appears later \u2014 verify the render)",
6819
+ suggestions: []
6820
+ });
6821
+ }
6822
+ }
6823
+ const setupSteps = spec.setup.map((step, i) => ({
6824
+ sceneId: "setup",
6825
+ sceneIndex: -1,
6826
+ stepIndex: i,
6827
+ ordinal: 0,
6828
+ step
6829
+ }));
6830
+ for (const a of [...setupSteps, ...flattenSteps(spec)]) {
5794
6831
  const stepRef = `${a.sceneId}/${a.stepIndex}`;
5795
6832
  const step = a.step;
5796
6833
  const push = async (locator, severity, message) => {
@@ -5805,6 +6842,7 @@ async function validateLive(spec, opts) {
5805
6842
  };
5806
6843
  const locators = [];
5807
6844
  if ("target" in step && step.target) locators.push({ value: step.target, kind: "target" });
6845
+ if (step.action === "drag") locators.push({ value: step.to, kind: "target" });
5808
6846
  if (step.action === "wait" && step.for) locators.push({ value: step.for, kind: "wait" });
5809
6847
  if (step.focus && step.focus !== "target" && step.focus !== "wide") {
5810
6848
  locators.push({ value: step.focus, kind: "focus" });
@@ -5820,6 +6858,14 @@ async function validateLive(spec, opts) {
5820
6858
  await push(l.value, "warn", `ambiguous: ${count} matches \u2014 add '>> nth=N' to pick one`);
5821
6859
  }
5822
6860
  }
6861
+ if (a.sceneIndex >= 0 && "target" in step && step.target && FRAMED_ACTIONS.has(step.action)) {
6862
+ try {
6863
+ const t = await driver.resolveTarget(parseLocator(step.target), 3e3);
6864
+ const violation = framingViolation(t.bbox, plane2);
6865
+ if (violation) await push(step.target, "warn", violation);
6866
+ } catch {
6867
+ }
6868
+ }
5823
6869
  try {
5824
6870
  await fastExecute(driver, step);
5825
6871
  } catch (e) {
@@ -5841,6 +6887,12 @@ async function validateLive(spec, opts) {
5841
6887
  if (n > 0) {
5842
6888
  const idx = issues.findIndex((i) => i.stepRef === stepRef && i.locator === focus && i.severity === "warn");
5843
6889
  if (idx >= 0) issues.splice(idx, 1);
6890
+ try {
6891
+ const t = await driver.resolveTarget(loc, 3e3);
6892
+ const violation = framingViolation(t.bbox, plane2);
6893
+ if (violation) await push(focus, "warn", violation);
6894
+ } catch {
6895
+ }
5844
6896
  }
5845
6897
  }
5846
6898
  }
@@ -5849,6 +6901,16 @@ async function validateLive(spec, opts) {
5849
6901
  }
5850
6902
  return { ok: !issues.some((i) => i.severity === "error"), checked, issues };
5851
6903
  }
6904
+ function framingViolation(bbox, plane2) {
6905
+ const cam = clampCamera(
6906
+ { cx: bbox.x + bbox.w / 2, cy: bbox.y + bbox.h / 2, zoom: minZoom(plane2) },
6907
+ plane2
6908
+ );
6909
+ const proj = projectRect(bbox, cam, plane2);
6910
+ if (rectContains({ x: 0, y: 0, w: plane2.outW, h: plane2.outH }, proj, 2)) return null;
6911
+ const fullBleed = bbox.x <= 2 || bbox.y <= 2 || bbox.x + bbox.w >= plane2.vpW - 2 || bbox.y + bbox.h >= plane2.vpH - 2;
6912
+ return fullBleed ? "full-bleed target (touches the viewport edge) \u2014 no camera can frame it with the 2px margin verification requires; target a smaller element inside it, or make this a scroll/wait step" : "target is larger than the widest camera view \u2014 verification's target-in-frame check will fail; use focus: on a smaller payoff element";
6913
+ }
5852
6914
  async function fastExecute(driver, step) {
5853
6915
  const FAST_TIMEOUT = step.timeout ?? 8e3;
5854
6916
  switch (step.action) {
@@ -5883,13 +6945,28 @@ async function fastExecute(driver, step) {
5883
6945
  else if (step.by) await driver.scrollBy(step.by);
5884
6946
  await driver.waitForScrollSettle(1500);
5885
6947
  break;
6948
+ case "rightclick": {
6949
+ await driver.resolveTarget(parseLocator(step.target), FAST_TIMEOUT);
6950
+ await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: FAST_TIMEOUT });
6951
+ break;
6952
+ }
6953
+ case "upload":
6954
+ await driver.setInputFiles(parseLocator(step.target), step.file);
6955
+ break;
6956
+ case "drag":
6957
+ await driver.dragTo(parseLocator(step.target), parseLocator(step.to), FAST_TIMEOUT);
6958
+ break;
5886
6959
  case "expect":
5887
6960
  await driver.expectState(
5888
- parseLocator(step.target),
6961
+ step.target ? parseLocator(step.target) : null,
5889
6962
  {
5890
6963
  ...step.visible !== void 0 ? { visible: step.visible } : {},
5891
6964
  ...step.text !== void 0 ? { text: step.text } : {},
5892
- ...step.count !== void 0 ? { count: step.count } : {}
6965
+ ...step.count !== void 0 ? { count: step.count } : {},
6966
+ ...step.url !== void 0 ? { url: step.url } : {},
6967
+ ...step.value !== void 0 ? { value: step.value } : {},
6968
+ ...step.disabled !== void 0 ? { disabled: step.disabled } : {},
6969
+ ...step.checked !== void 0 ? { checked: step.checked } : {}
5893
6970
  },
5894
6971
  FAST_TIMEOUT
5895
6972
  );
@@ -5937,6 +7014,7 @@ function formatValidateResult(res) {
5937
7014
  );
5938
7015
  return lines.join("\n");
5939
7016
  }
7017
+ var FRAMED_ACTIONS;
5940
7018
  var init_validate = __esm({
5941
7019
  "src/authoring/validate.ts"() {
5942
7020
  "use strict";
@@ -5946,6 +7024,7 @@ var init_validate = __esm({
5946
7024
  init_schema();
5947
7025
  init_explore();
5948
7026
  init_geometry();
7027
+ FRAMED_ACTIONS = /* @__PURE__ */ new Set(["click", "dblclick", "hover", "type", "select", "expect"]);
5949
7028
  }
5950
7029
  });
5951
7030
 
@@ -6018,25 +7097,46 @@ async function authorSpec(opts) {
6018
7097
  const owned = !opts.driver;
6019
7098
  const steps = [];
6020
7099
  log.info(`authoring "${opts.goal}" against ${opts.url}`);
6021
- if (owned) await driver.launch({ viewport: opts.viewport, dpr: 2, headless: opts.headless });
7100
+ if (owned)
7101
+ await driver.launch({
7102
+ viewport: opts.viewport,
7103
+ dpr: 2,
7104
+ headless: opts.headless,
7105
+ ...opts.storageStatePath ? { storageStatePath: opts.storageStatePath } : {}
7106
+ });
6022
7107
  try {
6023
7108
  await driver.goto(opts.url);
6024
7109
  await driver.settle(SETTLE2);
7110
+ let feedback;
7111
+ let consecutiveFailures = 0;
6025
7112
  for (let i = 0; i < opts.maxSteps; i++) {
6026
7113
  const snap = await snapshotPage(driver);
6027
- const decision = await decide(opts.goal, snap, steps);
7114
+ const decision = await decide(opts.goal, snap, steps, feedback);
6028
7115
  if (decision.done || !decision.action) {
6029
- log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
7116
+ if (decision.done) log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
7117
+ else log.warn(`agent returned no action (${decision.reason ?? "no reason"}) \u2014 stopping`);
6030
7118
  break;
6031
7119
  }
7120
+ if (decision.locator && !snap.catalog.some((e) => e.locator === decision.locator)) {
7121
+ feedback = `Your locator ${decision.locator} is NOT in the catalog \u2014 copy one verbatim from the list.`;
7122
+ consecutiveFailures += 1;
7123
+ log.warn(`agent invented a locator (${decision.locator}); asking it to pick from the catalog`);
7124
+ if (consecutiveFailures >= 3) throw new Error("authoring stuck: 3 consecutive invalid steps \u2014 the goal may not be reachable from this screen");
7125
+ continue;
7126
+ }
6032
7127
  const step = toAuthoredStep(decision);
6033
7128
  log.step(`${steps.length + 1}. ${describe(step)}${decision.reason ? ` \u2014 ${decision.reason}` : ""}`);
6034
7129
  try {
6035
7130
  await perform(driver, step);
6036
7131
  await driver.settle(SETTLE2);
6037
7132
  steps.push(step);
7133
+ feedback = void 0;
7134
+ consecutiveFailures = 0;
6038
7135
  } catch (e) {
6039
- log.warn(`step failed (${e.message}); asking the agent to adjust`);
7136
+ feedback = `Your last step FAILED: ${describe(step)} \u2014 ${e.message.split("\n")[0]}. Choose a different step.`;
7137
+ consecutiveFailures += 1;
7138
+ log.warn(`step failed (${e.message.split("\n")[0]}); feeding the failure back to the agent`);
7139
+ if (consecutiveFailures >= 3) throw new Error(`authoring stuck: 3 consecutive step failures (last: ${describe(step)})`);
6040
7140
  }
6041
7141
  }
6042
7142
  if (steps.length === 0) throw new Error("authoring produced no steps \u2014 the goal may not be reachable from this URL");
@@ -6044,11 +7144,20 @@ async function authorSpec(opts) {
6044
7144
  title: opts.title ?? capitalize(opts.goal),
6045
7145
  url: opts.url,
6046
7146
  viewport: opts.viewport,
6047
- kind: "walkthrough",
7147
+ kind: opts.kind ?? "walkthrough",
6048
7148
  steps
6049
7149
  };
6050
- await writeFile6(opts.outPath, serializeSpec(spec));
6051
- log.ok(`wrote ${steps.length}-step spec \u2192 ${opts.outPath}`);
7150
+ const yaml = serializeSpec(spec);
7151
+ const { parseSpec: parseSpec2 } = await Promise.resolve().then(() => (init_parse(), parse_exports));
7152
+ try {
7153
+ parseSpec2(yaml, opts.outPath);
7154
+ } catch (e) {
7155
+ await writeFile6(opts.outPath, yaml);
7156
+ throw new Error(`authored spec failed validation \u2014 written to ${opts.outPath} for inspection:
7157
+ ${e.message}`);
7158
+ }
7159
+ await writeFile6(opts.outPath, yaml);
7160
+ log.ok(`wrote ${steps.length}-step spec \u2192 ${opts.outPath} (validated)`);
6052
7161
  log.info(`next: playhead render ${opts.outPath}`);
6053
7162
  return { specPath: opts.outPath, steps };
6054
7163
  } finally {
@@ -6065,9 +7174,16 @@ function claudeDecider() {
6065
7174
  if (!clientPromise) clientPromise = import("@anthropic-ai/sdk").then((m) => new m.default());
6066
7175
  return clientPromise;
6067
7176
  };
6068
- return async (goal, snap, soFar) => {
7177
+ return async (goal, snap, soFar, feedback) => {
6069
7178
  const client = await getClient();
6070
- const catalog = snap.catalog.map((e) => ` ${e.locator}${e.options ? ` (options: ${e.options.join(" | ")})` : ""}${e.unique ? "" : " [ambiguous]"}`).join("\n");
7179
+ let catalog = snap.catalog.map((e) => ` ${e.locator}${e.options ? ` (options: ${e.options.join(" | ")})` : ""}${e.unique ? "" : " [ambiguous]"}`).join("\n");
7180
+ const CATALOG_CAP = 12e3;
7181
+ if (catalog.length > CATALOG_CAP) {
7182
+ const kept = catalog.slice(0, CATALOG_CAP);
7183
+ const dropped = catalog.slice(CATALOG_CAP).split("\n").length;
7184
+ catalog = kept + `
7185
+ \u2026 (catalog truncated \u2014 ${dropped} more elements not shown)`;
7186
+ }
6071
7187
  const history = soFar.length ? soFar.map((s, i) => ` ${i + 1}. ${describe(s)}`).join("\n") : " (none yet)";
6072
7188
  const userMsg = [
6073
7189
  `GOAL: ${goal}`,
@@ -6078,6 +7194,7 @@ function claudeDecider() {
6078
7194
  ``,
6079
7195
  `STEPS SO FAR:`,
6080
7196
  history,
7197
+ ...feedback ? [``, `IMPORTANT \u2014 PREVIOUS ATTEMPT: ${feedback}`] : [],
6081
7198
  ``,
6082
7199
  `Emit the next step (or done=true if the goal is fully shown).`
6083
7200
  ].join("\n");
@@ -6209,7 +7326,7 @@ __export(server_exports, {
6209
7326
  });
6210
7327
  import { mkdtemp, writeFile as writeFile7, mkdir as mkdir6, readFile as readFile6 } from "fs/promises";
6211
7328
  import { tmpdir } from "os";
6212
- import { join as join11, resolve, isAbsolute } from "path";
7329
+ import { join as join12, resolve, isAbsolute } from "path";
6213
7330
  import { z as z2 } from "zod";
6214
7331
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6215
7332
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -6232,11 +7349,16 @@ function buildServer() {
6232
7349
  description: "Launch the running web app and return a grounded catalog of addressable elements \u2014 each line is a Playhead locator validated to resolve uniquely against the real DOM. Use these locators verbatim when writing a spec. Only shows the CURRENT screen; call again after a navigation to see later screens.",
6233
7350
  inputSchema: {
6234
7351
  url: z2.string().describe("the running app URL, e.g. http://localhost:3000"),
6235
- viewport: dims.describe("viewport WxH")
7352
+ viewport: dims.describe("viewport WxH"),
7353
+ storageState: z2.string().optional().describe("session file from `playhead login` \u2014 REQUIRED for authenticated apps")
6236
7354
  }
6237
7355
  },
6238
- async ({ url, viewport }) => {
6239
- const snap = await exploreUrl(url, { viewport: parseDims(viewport), headless: true });
7356
+ async ({ url, viewport, storageState }) => {
7357
+ const snap = await exploreUrl(url, {
7358
+ viewport: parseDims(viewport),
7359
+ headless: true,
7360
+ ...storageState ? { storageStatePath: resolve(storageState) } : {}
7361
+ });
6240
7362
  return { content: [{ type: "text", text: formatCatalog(snap) }] };
6241
7363
  }
6242
7364
  );
@@ -6249,10 +7371,11 @@ function buildServer() {
6249
7371
  url: z2.string(),
6250
7372
  goal: z2.string().describe('what flow to demo, e.g. "create a new deal for Globex"'),
6251
7373
  outPath: z2.string().default("playhead.yaml").describe("where to write the spec"),
6252
- viewport: dims
7374
+ viewport: dims,
7375
+ storageState: z2.string().optional().describe("session file from `playhead login` for authenticated apps")
6253
7376
  }
6254
7377
  },
6255
- async ({ url, goal, outPath, viewport }) => {
7378
+ async ({ url, goal, outPath, viewport, storageState }) => {
6256
7379
  const path = isAbsolute(outPath) ? outPath : resolve(outPath);
6257
7380
  const { steps } = await authorSpec({
6258
7381
  url,
@@ -6260,7 +7383,8 @@ function buildServer() {
6260
7383
  outPath: path,
6261
7384
  viewport: parseDims(viewport),
6262
7385
  maxSteps: 40,
6263
- headless: true
7386
+ headless: true,
7387
+ ...storageState ? { storageStatePath: resolve(storageState) } : {}
6264
7388
  });
6265
7389
  const yaml = await readFile6(path, "utf8");
6266
7390
  return { content: [{ type: "text", text: `Wrote ${steps.length}-step spec to ${path}
@@ -6316,21 +7440,48 @@ ${formatValidateResult2(res)}` }],
6316
7440
  await mkdir6(out, { recursive: true });
6317
7441
  let parsedSpec;
6318
7442
  if (spec) {
6319
- const tmp = await mkdtemp(join11(tmpdir(), "playhead-spec-"));
6320
- const p = join11(tmp, "spec.yaml");
7443
+ const tmp = await mkdtemp(join12(tmpdir(), "playhead-spec-"));
7444
+ const p = join12(tmp, "spec.yaml");
6321
7445
  await writeFile7(p, spec);
6322
7446
  parsedSpec = parseSpec(spec);
6323
7447
  } else {
6324
7448
  parsedSpec = await loadSpec(resolve(specPath));
6325
7449
  }
6326
- const { bundleDir } = await capture(parsedSpec, { outDir: out, headless: true });
6327
- const bundle = await openBundle(bundleDir);
6328
- const composed = await compose(bundle, parsedSpec, { outDir: out });
6329
- const verdict = await verify(bundle, composed.manifest, composed.videoPath, { outDir: out, vision });
6330
- return {
6331
- content: [{ type: "text", text: renderVerdictReport(verdict, composed.videoPath, composed.manifestPath) }],
6332
- isError: verdict.verdict !== "publishable"
6333
- };
7450
+ try {
7451
+ const { bundleDir } = await capture(parsedSpec, { outDir: out, headless: true });
7452
+ const bundle = await openBundle(bundleDir);
7453
+ const composed = await compose(bundle, parsedSpec, { outDir: out });
7454
+ const verdict = await verify(bundle, composed.manifest, composed.videoPath, { outDir: out, vision });
7455
+ return {
7456
+ content: [{ type: "text", text: renderVerdictReport(verdict, composed.videoPath, composed.manifestPath) }],
7457
+ isError: verdict.verdict !== "publishable"
7458
+ };
7459
+ } catch (e) {
7460
+ const { FlowError: FlowError2 } = await Promise.resolve().then(() => (init_exit(), exit_exports));
7461
+ if (e instanceof FlowError2) {
7462
+ let clipLine = "";
7463
+ try {
7464
+ const bundle = await openBundle(join12(out, "capture"));
7465
+ const clip = await compose(bundle, parsedSpec, { outDir: out, fileName: "failure.mp4" });
7466
+ clipLine = `
7467
+ failure clip: ${clip.videoPath} (watch the flow up to the break)`;
7468
+ } catch {
7469
+ }
7470
+ const failure = await readFile6(join12(out, "capture", "failure.json"), "utf8").catch(() => null);
7471
+ return {
7472
+ content: [
7473
+ {
7474
+ type: "text",
7475
+ text: `FLOW FAILED \u2014 the app broke the scripted flow.
7476
+ ${failure ?? e.message}${clipLine}
7477
+ Fix the spec (or the app) and render again.`
7478
+ }
7479
+ ],
7480
+ isError: true
7481
+ };
7482
+ }
7483
+ throw e;
7484
+ }
6334
7485
  }
6335
7486
  );
6336
7487
  server.registerTool(
@@ -6414,9 +7565,35 @@ init_hash();
6414
7565
  init_exit();
6415
7566
  init_version();
6416
7567
  import { Command } from "commander";
6417
- import { mkdir as mkdir7, writeFile as writeFile8, access } from "fs/promises";
6418
- import { join as join12, resolve as resolve2 } from "path";
7568
+ import { mkdir as mkdir7, writeFile as writeFile8, access as access2 } from "fs/promises";
7569
+ import { join as join13, resolve as resolve2 } from "path";
6419
7570
  import pc3 from "picocolors";
7571
+ async function readFailureJson(bundleDir) {
7572
+ return readJsonIfExists(join13(bundleDir, "failure.json"));
7573
+ }
7574
+ async function readJsonIfExists(path) {
7575
+ try {
7576
+ const { readFile: readFile7 } = await import("fs/promises");
7577
+ return JSON.parse(await readFile7(path, "utf8"));
7578
+ } catch {
7579
+ return null;
7580
+ }
7581
+ }
7582
+ async function exportGif(videoPath, width) {
7583
+ const gifPath = videoPath.replace(/\.mp4$/i, ".gif");
7584
+ const fps = 12;
7585
+ await runBinary(ffmpegPath(), [
7586
+ "-y",
7587
+ "-loglevel",
7588
+ "error",
7589
+ "-i",
7590
+ videoPath,
7591
+ "-filter_complex",
7592
+ `[0:v]fps=${fps},scale=${width}:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle`,
7593
+ gifPath
7594
+ ]);
7595
+ return gifPath;
7596
+ }
6420
7597
  async function emitReports(verdict, opts) {
6421
7598
  if (opts.json) console.log(JSON.stringify(verdict, null, 2));
6422
7599
  if (opts.junit) {
@@ -6430,13 +7607,96 @@ program.name("playhead").description("Product video as a build artifact: spec +
6430
7607
  program.hook("preAction", (cmd) => {
6431
7608
  if (cmd.opts().verbose) setVerbose(true);
6432
7609
  });
6433
- program.command("render").description("capture \u2192 compose \u2192 verify; exit 0 only if every spec is publishable").argument("<specs...>", "spec YAML path(s) \u2014 multiple files render as a pool").option("-o, --out <dir>", "output directory (per-spec subdirs when rendering several)", "out").option("-w, --workers <n>", "concurrent renders when given multiple specs", "2").option("--vision", "run the Claude vision review (needs ANTHROPIC_API_KEY)").option("--json", "print the verdict JSON to stdout (human logs go to stderr)").option("--junit <path>", "write a JUnit XML report (one testcase per check)").option("--jira <issueKey>", "report the outcome (evidence + verdict comment) to an existing Jira issue \u2014 success OR flow failure").option("--headed", "show the browser while capturing").action(async (specPaths, opts) => {
6434
- const renderOne = async (specPath, outDir) => {
7610
+ program.addHelpText(
7611
+ "after",
7612
+ `
7613
+ Get started (first run):
7614
+ npx playwright install chromium one-time browser download (~100MB)
7615
+ playhead explore <your-app-url> see what's addressable \u2014 real locators to paste into a spec
7616
+ playhead init demo.yaml scaffold a spec, then paste those locators in
7617
+ playhead validate demo.yaml check every locator against the live app
7618
+ playhead render demo.yaml -o out \u2192 out/out.mp4 + out/verify/verdict.json
7619
+
7620
+ Run ${pc3.bold("playhead guide")} for the full workflow, grouped by task, with examples.`
7621
+ );
7622
+ program.command("guide").description("the commands grouped by workflow \u2014 what to run, in what order, and why").action(() => {
7623
+ const g = (s) => pc3.bold(pc3.cyan(s));
7624
+ console.log(`
7625
+ ${pc3.bold("Playhead \u2014 from install to a verified video")}
7626
+
7627
+ The loop: ${pc3.bold("explore \u2192 spec \u2192 validate \u2192 render")}. Playhead films your real app, so every
7628
+ locator in a spec must name a real element \u2014 ${pc3.bold("explore")} is how you get those, not guesswork.
7629
+
7630
+ ${g("One-time setup")}
7631
+ npx playwright install chromium download the capture browser
7632
+ playhead doctor check ffmpeg, browser, and fonts are ready
7633
+
7634
+ ${g("Author a spec")}
7635
+ playhead explore <url> START HERE \u2014 every addressable element on the current
7636
+ screen, each with a validated, ready-to-paste locator
7637
+ playhead init [path] scaffold a starter spec to paste those locators into
7638
+ playhead author <url> <goal> or let an agent walk the app and write the spec for you
7639
+ (needs ANTHROPIC_API_KEY)
7640
+
7641
+ ${g("Check before rendering")}
7642
+ playhead validate <spec> dry-run every locator against the live app in one pass,
7643
+ with nearest-match suggestions \u2014 before any frame is filmed
7644
+
7645
+ ${g("Keep noise off the film")}
7646
+ setup: steps (top-level in the spec) run BEFORE recording starts \u2014 dismiss the cookie-consent
7647
+ banner, close a first-run tour \u2014 so the first filmed frame is already presentable.
7648
+ Slow SPA? Give steps wait: { for: <locator> } so loading spinners never get filmed
7649
+ (verify warns when a frame's content region is blank).
7650
+
7651
+ ${g("Make the video")}
7652
+ playhead render <spec> -o out capture \u2192 compose \u2192 verify; exit 0 only when publishable
7653
+ playhead compose <bundle> re-render from a capture with new text/theme/audio \u2014
7654
+ no live app needed
7655
+
7656
+ ${g("Optional: add a voice")}
7657
+ Videos are silent by default \u2014 usually right for a demo. To narrate, set
7658
+ output.audio: { narration: tts } and give steps a narration: line, then re-run
7659
+ ${pc3.bold("compose")} on the existing capture \u2014 a voice change never needs re-recording.
7660
+ Providers: say (macOS built-in, zero setup) or kokoro (natural, offline,
7661
+ cross-platform \u2014 if you want the quality, it's a one-time npm i -g kokoro-js, ~400MB).
7662
+
7663
+ ${g("Trust the output")}
7664
+ playhead verify <bundle> <mp4> re-run the verification suite on a rendered video
7665
+ playhead attest <verify-dir> prove a verdict describes exactly these files, unedited
7666
+ playhead jira report <KEY> attach the evidence to an existing Jira issue
7667
+
7668
+ ${g("Authed apps")}
7669
+ playhead login <url> log in yourself in a headed browser; the saved session
7670
+ drives app.storageState (Playhead never sees credentials)
7671
+
7672
+ ${g("A complete first run")}
7673
+ playhead explore http://localhost:3000
7674
+ playhead init demo.yaml ${pc3.dim("# edit: set app.url, paste explore's locators")}
7675
+ playhead validate demo.yaml
7676
+ playhead render demo.yaml -o out ${pc3.dim("# \u2192 out/out.mp4, out/verify/verdict.json")}
7677
+ `);
7678
+ });
7679
+ program.command("render").description("capture \u2192 compose \u2192 verify; exit 0 only if every spec is publishable").argument("<specs...>", "spec YAML path(s) \u2014 multiple files render as a pool").option("-o, --out <dir>", "output directory (per-spec subdirs when rendering several)", "out").option("-w, --workers <n>", "concurrent renders when given multiple specs", "2").option("--vision", "run the Claude vision review (needs ANTHROPIC_API_KEY)").option("--json", "print the verdict JSON to stdout (human logs go to stderr)").option("--junit <path>", "write a JUnit XML report (one testcase per check)").option("--jira <issueKey>", "report the outcome (evidence + verdict comment) to an existing Jira issue \u2014 success OR flow failure").option("--gif [width]", "also export an embeddable GIF next to the MP4 (default width 960)").option("--headed", "show the browser while capturing").addHelpText(
7680
+ "after",
7681
+ `
7682
+ Examples:
7683
+ playhead render demo.yaml -o out one spec \u2192 out/out.mp4 + out/verify/verdict.json
7684
+ playhead render specs/*.yaml -o out -w 4 a pool, four at a time, per-spec subdirs
7685
+ playhead render demo.yaml --junit report.xml CI: one testcase per verification check
7686
+ playhead render demo.yaml --jira PROJ-123 attach the evidence to a Jira issue either way`
7687
+ ).action(async (specPaths, opts) => {
7688
+ const renderOne = async (specPath, outDir, reportOpts) => {
6435
7689
  const spec = await loadSpec(specPath);
6436
7690
  await mkdir7(outDir, { recursive: true });
6437
7691
  if (spec.output.kind === "before-after") {
6438
7692
  const { renderBeforeAfter: renderBeforeAfter2 } = await Promise.resolve().then(() => (init_beforeafter(), beforeafter_exports));
6439
7693
  const res = await renderBeforeAfter2(spec, { outDir, headless: !opts.headed, vision: opts.vision ?? false });
7694
+ if (reportOpts.json) console.log(JSON.stringify({ schema: "playhead/before-after@1", ...res }, null, 2));
7695
+ if (reportOpts.junit && res.after) {
7696
+ const { toJUnit: toJUnit2 } = await Promise.resolve().then(() => (init_report(), report_exports));
7697
+ await writeFile8(resolve2(reportOpts.junit), toJUnit2(res.after.verdict, "playhead-before-after"));
7698
+ log.ok(`junit \u2192 ${reportOpts.junit}`);
7699
+ }
6440
7700
  return res.verdict === "publishable" ? EXIT.OK : EXIT.QUALITY;
6441
7701
  }
6442
7702
  try {
@@ -6447,25 +7707,43 @@ program.command("render").description("capture \u2192 compose \u2192 verify; exi
6447
7707
  outDir,
6448
7708
  vision: opts.vision ?? false
6449
7709
  });
6450
- await emitReports(verdict, opts);
7710
+ await emitReports(verdict, reportOpts);
7711
+ if (opts.gif) {
7712
+ const gifW = typeof opts.gif === "string" ? Number(opts.gif) || 960 : 960;
7713
+ log.ok(`gif \u2192 ${await exportGif(result.videoPath, gifW)}`);
7714
+ }
6451
7715
  return verdict.verdict === "publishable" ? EXIT.OK : EXIT.QUALITY;
6452
7716
  } catch (e) {
6453
7717
  if (e instanceof FlowError) {
6454
7718
  log.error(e.message);
7719
+ let failureClip;
6455
7720
  try {
6456
- const bundle = await openBundle(join12(outDir, "capture"));
7721
+ const bundle = await openBundle(join13(outDir, "capture"));
6457
7722
  const result = await compose(bundle, spec, { outDir, fileName: "failure.mp4" });
7723
+ failureClip = result.videoPath;
6458
7724
  log.ok(`failure clip \u2192 ${result.videoPath}`);
6459
7725
  } catch (clipErr) {
6460
7726
  log.warn(`could not render the failure clip: ${clipErr.message}`);
6461
7727
  }
7728
+ const failure = await readFailureJson(join13(outDir, "capture")) ?? {
7729
+ stepRef: "(unknown)",
7730
+ message: e.message.split("\n")[0] ?? "flow failed"
7731
+ };
7732
+ if (reportOpts.json) {
7733
+ console.log(JSON.stringify({ schema: "playhead/flow-failure@1", outcome: "flow-failed", failure, failureClip: failureClip ?? null, bundleDir: join13(outDir, "capture") }, null, 2));
7734
+ }
7735
+ if (reportOpts.junit) {
7736
+ const { flowFailureJUnit: flowFailureJUnit2 } = await Promise.resolve().then(() => (init_report(), report_exports));
7737
+ await writeFile8(resolve2(reportOpts.junit), flowFailureJUnit2(failure));
7738
+ log.ok(`junit \u2192 ${reportOpts.junit}`);
7739
+ }
6462
7740
  return EXIT.FLOW;
6463
7741
  }
6464
7742
  throw e;
6465
7743
  }
6466
7744
  };
6467
7745
  if (specPaths.length === 1) {
6468
- const code = await renderOne(specPaths[0], resolve2(opts.out));
7746
+ const code = await renderOne(specPaths[0], resolve2(opts.out), opts);
6469
7747
  if (opts.jira && code !== EXIT.INFRA && code !== EXIT.USAGE) {
6470
7748
  try {
6471
7749
  const { reportToJira: reportToJira2 } = await Promise.resolve().then(() => (init_jira(), jira_exports));
@@ -6480,20 +7758,38 @@ program.command("render").description("capture \u2192 compose \u2192 verify; exi
6480
7758
  if (opts.jira) log.warn("--jira is single-spec only for now \u2014 skipping the report for this multi-spec pool");
6481
7759
  const workers = Math.max(1, Number(opts.workers) || 2);
6482
7760
  log.info(`rendering ${specPaths.length} specs with ${workers} worker(s)`);
7761
+ const names = /* @__PURE__ */ new Map();
7762
+ const seen = /* @__PURE__ */ new Set();
7763
+ for (const path of specPaths) {
7764
+ let name = (path.split(/[\\/]/).pop() ?? path).replace(/\.ya?ml$/, "");
7765
+ let unique = name;
7766
+ for (let i = 2; seen.has(unique); i++) unique = `${name}-${i}`;
7767
+ seen.add(unique);
7768
+ names.set(path, unique);
7769
+ }
6483
7770
  const queue = [...specPaths];
6484
7771
  const results = /* @__PURE__ */ new Map();
7772
+ const jsonDocs = [];
6485
7773
  await Promise.all(
6486
7774
  Array.from({ length: Math.min(workers, queue.length) }, async () => {
6487
7775
  for (let path = queue.shift(); path !== void 0; path = queue.shift()) {
6488
- const name = path.replace(/\.ya?ml$/, "").split("/").pop();
7776
+ const name = names.get(path);
7777
+ const junit = opts.junit ? opts.junit.replace(/\.xml$/, "") + `-${name}.xml` : void 0;
6489
7778
  try {
6490
- results.set(path, await renderOne(path, resolve2(join12(opts.out, name))));
7779
+ const code = await renderOne(path, resolve2(join13(opts.out, name)), { ...junit ? { junit } : {} });
7780
+ results.set(path, code);
7781
+ if (opts.json) {
7782
+ const v = await readJsonIfExists(join13(resolve2(opts.out), name, "verify", "verdict.json"));
7783
+ jsonDocs.push({ spec: path, exitCode: code, verdict: v ?? null });
7784
+ }
6491
7785
  } catch (e) {
6492
7786
  results.set(path, e);
7787
+ if (opts.json) jsonDocs.push({ spec: path, exitCode: exitCodeFor(e), error: e.message.split("\n")[0] });
6493
7788
  }
6494
7789
  }
6495
7790
  })
6496
7791
  );
7792
+ if (opts.json) console.log(JSON.stringify(jsonDocs, null, 2));
6497
7793
  let worst = EXIT.OK;
6498
7794
  for (const [path, res] of results) {
6499
7795
  if (res === EXIT.OK) log.ok(path);
@@ -6515,17 +7811,17 @@ program.command("capture").description("capture a live app into a re-renderable
6515
7811
  program.command("compose").description("re-render video from a capture bundle \u2014 no live app needed").argument("<bundle>", "path to a capture bundle directory").option("-o, --out <dir>", "output directory (default: bundle parent)").option(
6516
7812
  "--spec <file>",
6517
7813
  "compose with a LIVE spec file instead of the bundled snapshot \u2014 captions, narration, theme, audio, pacing all come from it, so text edits are a re-compose, never a re-capture"
6518
- ).option("--expect-sha <hex>", "assert the rendered MP4 hashes to this sha256 (determinism check: same bundle + spec \u2192 same video)").action(async (bundleDir, opts) => {
7814
+ ).option("--expect-sha <hex>", "assert the rendered MP4 hashes to this sha256 (determinism check: same bundle + spec \u2192 same video)").option("--aspects <list>", 'ALSO render these aspects from the same bundle, e.g. "9:16,1:1" \u2014 footage becomes a correctly-proportioned card on each canvas (out-9x16.mp4, \u2026)').option("--gif [width]", "also export an embeddable GIF (default width 960) \u2014 the PR-comment format").action(async (bundleDir, opts) => {
6519
7815
  const bundle = await openBundle(resolve2(bundleDir));
6520
7816
  const spec = opts.spec ? await loadSpec(resolve2(opts.spec)) : JSON.parse(
6521
- await (await import("fs/promises")).readFile(join12(resolve2(bundleDir), "spec.resolved.json"), "utf8")
7817
+ await (await import("fs/promises")).readFile(join13(resolve2(bundleDir), "spec.resolved.json"), "utf8")
6522
7818
  );
6523
7819
  if (opts.spec && sha256Json(spec.scenes.map((s) => s.id)) !== sha256Json(
6524
- JSON.parse(await (await import("fs/promises")).readFile(join12(resolve2(bundleDir), "spec.resolved.json"), "utf8")).scenes.map((s) => s.id)
7820
+ JSON.parse(await (await import("fs/promises")).readFile(join13(resolve2(bundleDir), "spec.resolved.json"), "utf8")).scenes.map((s) => s.id)
6525
7821
  )) {
6526
7822
  log.warn("--spec scene ids differ from the captured spec \u2014 steps that moved will keep their captured footage positions");
6527
7823
  }
6528
- const outDir = resolve2(opts.out ?? join12(resolve2(bundleDir), ".."));
7824
+ const outDir = resolve2(opts.out ?? join13(resolve2(bundleDir), ".."));
6529
7825
  await mkdir7(outDir, { recursive: true });
6530
7826
  const result = await compose(bundle, spec, { outDir, theme: resolveTheme(spec) });
6531
7827
  if (opts.expectSha) {
@@ -6537,8 +7833,33 @@ program.command("compose").description("re-render video from a capture bundle \u
6537
7833
  log.ok(`determinism check: sha256 matches (${got.slice(0, 16)}\u2026)`);
6538
7834
  }
6539
7835
  }
7836
+ if (opts.aspects) {
7837
+ const { profileForAspect: profileForAspect2, withStage: withStage2 } = await Promise.resolve().then(() => (init_types(), types_exports));
7838
+ const { ASPECTS: ASPECTS2 } = await Promise.resolve().then(() => (init_schema(), schema_exports));
7839
+ const va = bundle.manifest.viewport.w / bundle.manifest.viewport.h;
7840
+ for (const a of opts.aspects.split(",").map((x) => x.trim())) {
7841
+ if (a !== "16:9" && a !== "9:16" && a !== "1:1") {
7842
+ log.warn(`unknown aspect "${a}" \u2014 expected 16:9, 9:16, or 1:1`);
7843
+ continue;
7844
+ }
7845
+ const prof = withStage2(profileForAspect2(a, ASPECTS2[a].resolution, spec.output.fps ?? 30), true, va);
7846
+ const fileName = `out-${a.replace(":", "x")}.mp4`;
7847
+ await compose(bundle, spec, { outDir, theme: resolveTheme(spec), profile: prof, fileName });
7848
+ log.ok(`${a} \u2192 ${join13(outDir, fileName)}`);
7849
+ }
7850
+ }
7851
+ if (opts.gif) {
7852
+ const gifW = typeof opts.gif === "string" ? Number(opts.gif) || 960 : 960;
7853
+ const gifPath = await exportGif(result.videoPath, gifW);
7854
+ log.ok(`gif \u2192 ${gifPath}`);
7855
+ }
6540
7856
  });
6541
- program.command("validate").description("preflight a spec against the LIVE app: every locator checked in one pass, all failures reported with suggestions \u2014 before a single frame is captured").argument("<spec>", "path to spec YAML").option("--headed", "show the browser").action(async (specPath, opts) => {
7857
+ program.command("validate").description("preflight a spec against the LIVE app: every locator checked in one pass, all failures reported with suggestions \u2014 before a single frame is captured").argument("<spec>", "path to spec YAML").option("--headed", "show the browser").addHelpText(
7858
+ "after",
7859
+ `
7860
+ Example:
7861
+ playhead validate demo.yaml seconds, not a render \u2014 run it after every spec edit`
7862
+ ).action(async (specPath, opts) => {
6542
7863
  const spec = await loadSpec(specPath);
6543
7864
  const { validateLive: validateLive2, formatValidateResult: formatValidateResult2 } = await Promise.resolve().then(() => (init_validate(), validate_exports));
6544
7865
  log.info(`validating "${spec.title}" against ${spec.app.url}`);
@@ -6572,26 +7893,48 @@ program.command("jira").description("report render evidence to an EXISTING Jira
6572
7893
  });
6573
7894
  program.command("verify").description("run the verification suite against a rendered video").argument("<bundle>", "path to the capture bundle directory").argument("<video>", "path to the rendered MP4").option("-m, --manifest <path>", "compose manifest path (default: next to the video)").option("--vision", "run the Claude vision review").option("--json", "print the verdict JSON to stdout").option("--junit <path>", "write a JUnit XML report").action(async (bundleDir, videoPath, opts) => {
6574
7895
  const bundle = await openBundle(resolve2(bundleDir));
6575
- const manifestPath = opts.manifest ?? join12(resolve2(videoPath), "..", "compose-manifest.json");
7896
+ const manifestPath = opts.manifest ?? join13(resolve2(videoPath), "..", "compose-manifest.json");
6576
7897
  const manifest = await loadComposeManifest(manifestPath);
6577
7898
  const verdict = await verify(bundle, manifest, resolve2(videoPath), {
6578
- outDir: join12(resolve2(videoPath), ".."),
7899
+ outDir: join13(resolve2(videoPath), ".."),
6579
7900
  vision: opts.vision ?? false
6580
7901
  });
6581
7902
  await emitReports(verdict, opts);
6582
7903
  process.exitCode = verdict.verdict === "publishable" ? EXIT.OK : EXIT.QUALITY;
6583
7904
  });
6584
- program.command("attest").description("re-verify a verdict.json: artifact hashes recomputed, signature checked (PLAYHEAD_SIGNING_KEY) \u2014 proves the verdict describes THESE files and was not edited").argument("<verdict>", "path to verdict.json (or the verify/ directory containing it)").option("--bundle <dir>", "also re-hash the capture bundle against the verdict").action(async (verdictPath, opts) => {
7905
+ program.command("attest").description("re-verify a verdict.json: video/manifest/contact-sheet/bundle hashes recomputed (frame BYTES included), signature checked (Ed25519 via keygen, or HMAC via PLAYHEAD_SIGNING_KEY)").argument("<verdict>", "path to verdict.json (or the verify/ directory containing it)").option("--bundle <dir>", "also re-hash the capture bundle \u2014 including every frame image").option("--allow-unsigned", "treat an unsigned verdict as acceptable (hash checks only)").action(async (verdictPath, opts) => {
6585
7906
  const { readFile: readFile7 } = await import("fs/promises");
6586
- const p = verdictPath.endsWith(".json") ? resolve2(verdictPath) : join12(resolve2(verdictPath), "verdict.json");
7907
+ const p = verdictPath.endsWith(".json") ? resolve2(verdictPath) : join13(resolve2(verdictPath), "verdict.json");
7908
+ const verifyDir = join13(p, "..");
6587
7909
  const verdict = JSON.parse(await readFile7(p, "utf8"));
6588
7910
  let ok = true;
7911
+ if (verdict.schema !== "playhead/verdict@3") {
7912
+ log.warn(`legacy verdict schema ${verdict.schema} \u2014 evidence text, manifest, and contact sheet are NOT covered by its signature; re-verify with a current playhead for full coverage`);
7913
+ }
6589
7914
  const videoSha = await sha256File(verdict.video.path).catch(() => null);
6590
7915
  if (videoSha === verdict.video.sha256) log.ok(`video sha256 matches (${videoSha.slice(0, 12)}\u2026)`);
6591
7916
  else {
6592
7917
  ok = false;
6593
7918
  log.error(`video hash mismatch: verdict says ${verdict.video.sha256.slice(0, 12)}\u2026, file is ${videoSha ? videoSha.slice(0, 12) + "\u2026" : "missing"}`);
6594
7919
  }
7920
+ if (verdict.contactSheetSha256) {
7921
+ const sheetSha = await sha256File(join13(verifyDir, "contact-sheet.png")).catch(() => null);
7922
+ if (sheetSha === verdict.contactSheetSha256) log.ok("contact sheet matches");
7923
+ else if (sheetSha === null) log.warn("contact-sheet.png not found next to the verdict \u2014 hash not checkable");
7924
+ else {
7925
+ ok = false;
7926
+ log.error("contact sheet hash mismatch \u2014 the sheet was replaced after verification");
7927
+ }
7928
+ }
7929
+ if (verdict.manifestHash) {
7930
+ const raw = await readFile7(join13(verifyDir, "..", "compose-manifest.json"), "utf8").catch(() => null);
7931
+ if (raw === null) log.warn("compose-manifest.json not found \u2014 manifest hash not checkable");
7932
+ else if (sha256Json(JSON.parse(raw)) === verdict.manifestHash) log.ok("compose manifest matches");
7933
+ else {
7934
+ ok = false;
7935
+ log.error("compose manifest hash mismatch \u2014 the plan was modified after verification");
7936
+ }
7937
+ }
6595
7938
  if (opts.bundle) {
6596
7939
  const bundle = await openBundle(resolve2(opts.bundle));
6597
7940
  const events = sha256Json(bundle.events);
@@ -6602,35 +7945,129 @@ program.command("attest").description("re-verify a verdict.json: artifact hashes
6602
7945
  ok = false;
6603
7946
  log.error("bundle hash mismatch \u2014 the bundle is not the one this verdict describes");
6604
7947
  }
7948
+ const withDigests = bundle.frames.filter((f) => f.h);
7949
+ if (withDigests.length === 0) {
7950
+ log.warn("bundle predates per-frame hashing \u2014 frame image bytes are NOT covered");
7951
+ } else {
7952
+ let bad = 0;
7953
+ for (const f of withDigests) {
7954
+ const actual = await sha256File(join13(resolve2(opts.bundle), "frames", f.f)).catch(() => null);
7955
+ if (actual !== f.h) bad += 1;
7956
+ }
7957
+ if (bad === 0) log.ok(`frame bytes match (${withDigests.length} frames re-hashed)`);
7958
+ else {
7959
+ ok = false;
7960
+ log.error(`${bad}/${withDigests.length} frame images do NOT match their recorded digests \u2014 footage was altered`);
7961
+ }
7962
+ }
6605
7963
  }
6606
- const { signVerdict: signVerdict2, signablePayload: signablePayload2 } = await Promise.resolve().then(() => (init_report(), report_exports));
7964
+ const { verifySignature: verifySignature2 } = await Promise.resolve().then(() => (init_report(), report_exports));
6607
7965
  if (!verdict.signature) {
6608
- log.warn("verdict is unsigned (no PLAYHEAD_SIGNING_KEY at verify time) \u2014 content integrity not attestable");
6609
- } else if (!process.env.PLAYHEAD_SIGNING_KEY) {
6610
- ok = false;
6611
- log.error("verdict is signed but PLAYHEAD_SIGNING_KEY is not set \u2014 cannot check the signature");
7966
+ if (opts.allowUnsigned) {
7967
+ log.warn("verdict is unsigned \u2014 accepted because --allow-unsigned was passed (hash checks only)");
7968
+ } else {
7969
+ ok = false;
7970
+ log.error("verdict is UNSIGNED \u2014 a hand-written verdict.json is indistinguishable from a real one. Sign at verify time (playhead keygen + PLAYHEAD_SIGNING_KEY_FILE, or PLAYHEAD_SIGNING_KEY) or pass --allow-unsigned.");
7971
+ }
6612
7972
  } else {
6613
- const { signature: _sig, ...unsigned } = verdict;
6614
- void signablePayload2;
6615
- const expect = signVerdict2(unsigned);
6616
- if (expect === verdict.signature) log.ok("signature valid \u2014 verdict content is untampered");
7973
+ const res = verifySignature2(verdict);
7974
+ if (res.valid) log.ok(res.reason);
6617
7975
  else {
6618
7976
  ok = false;
6619
- log.error("SIGNATURE MISMATCH \u2014 the verdict was modified after signing");
7977
+ log.error(res.reason);
6620
7978
  }
6621
7979
  }
7980
+ if (verdict.coverage) {
7981
+ log.info(
7982
+ `coverage: ${verdict.coverage.actionsVerified}/${verdict.coverage.actionsTotal} actions pixel-verified, ${verdict.coverage.outputSamplesVerified} output samples, ${verdict.coverage.checksSkipped} check(s) skipped`
7983
+ );
7984
+ }
6622
7985
  log.info(
6623
7986
  `provenance: playhead ${verdict.provenance.playheadVersion}, spec ${verdict.provenance.specHash.slice(0, 12)}\u2026, captured ${verdict.provenance.capturedAt}, ${verdict.provenance.host}`
6624
7987
  );
6625
7988
  process.exitCode = ok ? EXIT.OK : EXIT.QUALITY;
6626
7989
  });
6627
- program.command("explore").description("inspect a live app and print a grounded catalog of addressable elements").argument("<url>", "the running app URL to explore").option("--viewport <wxh>", "viewport, e.g. 1280x720", "1280x720").option("--storage-state <file>", 'session file from "playhead login" for authed screens').option("--headed", "show the browser").action(async (url, opts) => {
7990
+ program.command("compare").description("compare two render outputs (baseline vs current): verdict drift, newly failing checks, coverage and per-step timing deltas \u2014 the regression story for CI").argument("<baseline>", "output dir of the BASELINE run (holds verify/verdict.json + compose-manifest.json)").argument("<current>", "output dir of the CURRENT run").action(async (baseDir, curDir) => {
7991
+ const load = async (dir) => ({
7992
+ verdict: await readJsonIfExists(join13(resolve2(dir), "verify", "verdict.json")),
7993
+ manifest: await readJsonIfExists(join13(resolve2(dir), "compose-manifest.json"))
7994
+ });
7995
+ const a = await load(baseDir);
7996
+ const b = await load(curDir);
7997
+ if (!a.verdict || !b.verdict) {
7998
+ log.error("both directories need verify/verdict.json (render them first)");
7999
+ process.exitCode = EXIT.USAGE;
8000
+ return;
8001
+ }
8002
+ let regressed = false;
8003
+ log.info(`verdict: ${a.verdict.verdict} \u2192 ${b.verdict.verdict}`);
8004
+ if (a.verdict.verdict === "publishable" && b.verdict.verdict !== "publishable") regressed = true;
8005
+ const aFail = new Set(a.verdict.checks.filter((c) => c.status === "fail").map((c) => c.id));
8006
+ for (const c of b.verdict.checks) {
8007
+ if (c.status === "fail" && !aFail.has(c.id)) {
8008
+ regressed = true;
8009
+ log.error(`NEW failure: ${c.id} \u2014 ${c.details}`);
8010
+ }
8011
+ if (c.status !== "fail" && aFail.has(c.id)) log.ok(`fixed: ${c.id}`);
8012
+ }
8013
+ if (a.verdict.coverage && b.verdict.coverage) {
8014
+ const ca = a.verdict.coverage;
8015
+ const cb = b.verdict.coverage;
8016
+ if (cb.actionsVerified < ca.actionsVerified) {
8017
+ log.warn(`coverage dropped: ${ca.actionsVerified}/${ca.actionsTotal} \u2192 ${cb.actionsVerified}/${cb.actionsTotal} actions pixel-verified`);
8018
+ } else {
8019
+ log.info(`coverage: ${ca.actionsVerified}/${ca.actionsTotal} \u2192 ${cb.actionsVerified}/${cb.actionsTotal} actions pixel-verified`);
8020
+ }
8021
+ }
8022
+ if (a.manifest && b.manifest) {
8023
+ const dDur = b.manifest.durationMs - a.manifest.durationMs;
8024
+ log.info(`duration: ${(a.manifest.durationMs / 1e3).toFixed(1)}s \u2192 ${(b.manifest.durationMs / 1e3).toFixed(1)}s (${dDur >= 0 ? "+" : ""}${(dDur / 1e3).toFixed(1)}s)`);
8025
+ const aSteps = new Map(a.manifest.steps.map((s) => [s.stepRef, s.outEnd - s.outStart]));
8026
+ for (const s of b.manifest.steps) {
8027
+ const was = aSteps.get(s.stepRef);
8028
+ if (was === void 0) {
8029
+ log.info(`step ${s.stepRef}: new (not in baseline)`);
8030
+ continue;
8031
+ }
8032
+ const now = s.outEnd - s.outStart;
8033
+ if (now > was * 1.5 && now - was > 800) log.warn(`step ${s.stepRef} slowed: ${(was / 1e3).toFixed(1)}s \u2192 ${(now / 1e3).toFixed(1)}s`);
8034
+ }
8035
+ for (const ref of aSteps.keys()) {
8036
+ if (!b.manifest.steps.some((s) => s.stepRef === ref)) log.warn(`step ${ref}: missing from current run`);
8037
+ }
8038
+ }
8039
+ if (a.verdict.video.sha256 === b.verdict.video.sha256) log.info("video byte-identical to baseline");
8040
+ process.exitCode = regressed ? EXIT.QUALITY : EXIT.OK;
8041
+ });
8042
+ program.command("keygen").description("generate an Ed25519 signing keypair: verdicts become third-party verifiable (consumers pin your key id) \u2014 stronger than the symmetric PLAYHEAD_SIGNING_KEY").option("-o, --out <file>", "private key output path (PEM)", "playhead-signing.pem").action(async (opts) => {
8043
+ const { generateKeyPairSync } = await import("crypto");
8044
+ const { writeFile: writeFile9 } = await import("fs/promises");
8045
+ const { keyIdFor: keyIdFor2 } = await Promise.resolve().then(() => (init_report(), report_exports));
8046
+ const outPath = resolve2(opts.out);
8047
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
8048
+ await writeFile9(outPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 384 });
8049
+ const spki = publicKey.export({ type: "spki", format: "der" });
8050
+ log.ok(`private key \u2192 ${outPath} (mode 600 \u2014 keep it out of git)`);
8051
+ log.info(`key id: ${keyIdFor2(spki)} (consumers pin this)`);
8052
+ log.info(`public key (base64 SPKI): ${spki.toString("base64")}`);
8053
+ log.info(`use it: export PLAYHEAD_SIGNING_KEY_FILE=${outPath}`);
8054
+ });
8055
+ program.command("explore").description("inspect a live app and print a grounded catalog of addressable elements").argument("<url>", "the running app URL to explore").option("--viewport <wxh>", "viewport, e.g. 1280x720", "1280x720").option("--storage-state <file>", 'session file from "playhead login" for authed screens').option("--headed", "show the browser").addHelpText(
8056
+ "after",
8057
+ `
8058
+ Examples:
8059
+ playhead explore http://localhost:3000 what can a spec address here?
8060
+ playhead explore https://app.dev --storage-state session.json an authed screen (see: playhead login)
8061
+
8062
+ Each line of the catalog is a locator to paste into a spec verbatim. Only the CURRENT screen is
8063
+ shown \u2014 explore again after a navigation.`
8064
+ ).action(async (url, opts) => {
6628
8065
  const { exploreUrl: exploreUrl2, formatCatalog: formatCatalog2 } = await Promise.resolve().then(() => (init_explore(), explore_exports));
6629
8066
  const [w, h] = opts.viewport.split("x").map(Number);
6630
8067
  const snap = await exploreUrl2(url, { viewport: { w, h }, headless: !opts.headed, ...opts.storageState ? { storageStatePath: resolve2(opts.storageState) } : {} });
6631
8068
  console.log(formatCatalog2(snap));
6632
8069
  });
6633
- program.command("author").description("agent-author a grounded, validated spec by exploring the app toward a goal").argument("<url>", "the running app URL").argument("<goal>", "natural-language description of the flow to demo").option("-o, --out <file>", "where to write the spec", "playhead.yaml").option("--title <title>", "video title (defaults to the goal)").option("--kind <kind>", "output kind", "walkthrough").option("--viewport <wxh>", "viewport", "1280x720").option("--max-steps <n>", "safety cap on authored steps", "40").option("--headed", "show the browser").action(async (url, goal, opts) => {
8070
+ program.command("author").description("agent-author a grounded, validated spec by exploring the app toward a goal").argument("<url>", "the running app URL").argument("<goal>", "natural-language description of the flow to demo").option("-o, --out <file>", "where to write the spec", "playhead.yaml").option("--title <title>", "video title (defaults to the goal)").option("--kind <kind>", "output kind", "walkthrough").option("--viewport <wxh>", "viewport", "1280x720").option("--max-steps <n>", "safety cap on authored steps", "40").option("--storage-state <file>", 'session file from "playhead login" for authed apps').option("--headed", "show the browser").action(async (url, goal, opts) => {
6634
8071
  const { authorSpec: authorSpec2 } = await Promise.resolve().then(() => (init_author(), author_exports));
6635
8072
  const [w, h] = opts.viewport.split("x").map(Number);
6636
8073
  await authorSpec2({
@@ -6640,19 +8077,25 @@ program.command("author").description("agent-author a grounded, validated spec b
6640
8077
  ...opts.title ? { title: opts.title } : {},
6641
8078
  viewport: { w, h },
6642
8079
  maxSteps: Number(opts.maxSteps),
6643
- headless: !opts.headed
8080
+ headless: !opts.headed,
8081
+ // --kind was accepted and silently ignored (round-2 audit) — now threaded through.
8082
+ kind: opts.kind,
8083
+ ...opts.storageState ? { storageStatePath: resolve2(opts.storageState) } : {}
6644
8084
  });
6645
8085
  });
6646
8086
  program.command("init").description("scaffold a starter spec").argument("[path]", "where to write it", "playhead.yaml").action(async (path) => {
6647
8087
  try {
6648
- await access(path);
8088
+ await access2(path);
6649
8089
  log.error(`${path} already exists`);
6650
8090
  process.exitCode = 1;
6651
8091
  return;
6652
8092
  } catch {
6653
8093
  }
6654
8094
  await writeFile8(path, STARTER_SPEC);
6655
- log.ok(`wrote ${path} \u2014 edit it, then run: playhead render ${path}`);
8095
+ log.ok(`wrote ${path}`);
8096
+ console.error(` next: playhead explore <your app url> # real locators to paste into ${path}`);
8097
+ console.error(` playhead validate ${path} # check every locator against the live app`);
8098
+ console.error(` playhead render ${path} -o out`);
6656
8099
  });
6657
8100
  program.command("mcp").description("run Playhead as an MCP server (stdio) so a coding agent can drive it").action(async () => {
6658
8101
  const { startMcpServer: startMcpServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
@@ -6660,16 +8103,18 @@ program.command("mcp").description("run Playhead as an MCP server (stdio) so a c
6660
8103
  });
6661
8104
  program.command("doctor").description("check that ffmpeg, the browser, and fonts are ready").option("--ci", "also check CI/container prerequisites (TTS, sandbox, shm)").action(async (opts) => {
6662
8105
  let ok = true;
6663
- if (opts.ci) {
8106
+ {
6664
8107
  const { execFile: execFile3 } = await import("child_process");
6665
8108
  const { promisify: promisify3 } = await import("util");
6666
8109
  const which = promisify3(execFile3);
6667
8110
  const hasSay = await which("which", ["say"]).then(() => true).catch(() => false);
6668
- if (hasSay) log.ok("tts (say): macOS `say` available");
8111
+ if (hasSay) log.ok("tts (say): macOS `say` available \u2014 output.audio: { narration: tts } speaks your steps");
6669
8112
  else log.info("tts (say): no `say` binary here \u2014 the `say` provider is macOS-only");
6670
8113
  const hasKokoro = await import("kokoro-js").then(() => true).catch(() => false);
6671
8114
  if (hasKokoro) log.ok("tts (kokoro): kokoro-js installed \u2014 the natural offline voice is available");
6672
- else log.warn("tts (kokoro): kokoro-js not installed \u2014 `provider: kokoro` needs `npm i -g kokoro-js` (~400MB, one time)");
8115
+ else log.info("tts (kokoro): not installed (optional) \u2014 the natural voice needs `npm i -g kokoro-js` (~400MB, one time; your call)");
8116
+ }
8117
+ if (opts.ci) {
6673
8118
  if (process.platform === "linux") {
6674
8119
  log.info("linux: run chromium with a base image that provides fonts + sandbox deps (see Dockerfile), and give /dev/shm \u2265 512MB or pass --disable-dev-shm-usage");
6675
8120
  }
@@ -6693,7 +8138,7 @@ program.command("doctor").description("check that ffmpeg, the browser, and fonts
6693
8138
  try {
6694
8139
  const { chromium: chromium2 } = await import("playwright");
6695
8140
  const path = chromium2.executablePath();
6696
- await access(path);
8141
+ await access2(path);
6697
8142
  log.ok(`chromium: ${path}`);
6698
8143
  } catch {
6699
8144
  ok = false;
@@ -6713,14 +8158,24 @@ program.command("doctor").description("check that ffmpeg, the browser, and fonts
6713
8158
  var STARTER_SPEC = `playhead: 1
6714
8159
  title: "My product walkthrough"
6715
8160
  app:
8161
+ # Point this at your RUNNING app.
6716
8162
  url: http://localhost:3000
6717
8163
  viewport: 1280x720
6718
8164
  output:
6719
8165
  kind: walkthrough
6720
8166
  pacing: normal
8167
+ # Optional voice-over \u2014 most demos don't need one, so it's off by default. Uncomment to
8168
+ # speak each step's narration (or caption) line. Providers:
8169
+ # say \u2014 macOS built-in voice, zero setup (robotic)
8170
+ # kokoro \u2014 natural neural voice, offline, cross-platform; YOUR call whether it's worth
8171
+ # the one-time "npm i -g kokoro-js" (~400MB + ~90MB model on first use)
8172
+ # audio: { narration: tts, provider: say }
6721
8173
  scenes:
6722
8174
  - id: first-flow
6723
8175
  title: "Do the thing"
8176
+ # These steps are PLACEHOLDERS \u2014 they name elements your app almost certainly doesn't have.
8177
+ # Get real locators from your live app: playhead explore <url>
8178
+ # ...paste them here, then check them with: playhead validate <this file>
6724
8179
  steps:
6725
8180
  - click: 'role=button[name="Get started"]'
6726
8181
  - type: { target: 'label=Name', text: "Ada Lovelace" }