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.
- package/Dockerfile +9 -6
- package/README.md +43 -7
- package/action.yml +56 -2
- package/dist/audio/mux.d.ts +2 -0
- package/dist/audio/mux.d.ts.map +1 -1
- package/dist/authoring/author.d.ts +8 -2
- package/dist/authoring/author.d.ts.map +1 -1
- package/dist/authoring/catalog.d.ts.map +1 -1
- package/dist/authoring/explore.d.ts.map +1 -1
- package/dist/authoring/validate.d.ts.map +1 -1
- package/dist/bundle/types.d.ts +2 -0
- package/dist/bundle/types.d.ts.map +1 -1
- package/dist/bundle/writer.d.ts +6 -1
- package/dist/bundle/writer.d.ts.map +1 -1
- package/dist/capture/driver-api.d.ts +14 -1
- package/dist/capture/driver-api.d.ts.map +1 -1
- package/dist/capture/events.d.ts +1 -1
- package/dist/capture/events.d.ts.map +1 -1
- package/dist/capture/executor.d.ts.map +1 -1
- package/dist/capture/playwright-driver.d.ts +22 -2
- package/dist/capture/playwright-driver.d.ts.map +1 -1
- package/dist/cli/index.js +1969 -514
- package/dist/cli/index.js.map +1 -1
- package/dist/compose/camera/interpolate.d.ts +16 -3
- package/dist/compose/camera/interpolate.d.ts.map +1 -1
- package/dist/compose/camera/planner.d.ts.map +1 -1
- package/dist/compose/captions.d.ts.map +1 -1
- package/dist/compose/index.d.ts.map +1 -1
- package/dist/compose/overlays/build.d.ts.map +1 -1
- package/dist/compose/overlays/draw.d.ts.map +1 -1
- package/dist/compose/render/encoder.d.ts.map +1 -1
- package/dist/compose/render/frameStore.d.ts.map +1 -1
- package/dist/compose/render/renderer.d.ts.map +1 -1
- package/dist/compose/timeline/pacing.d.ts +4 -0
- package/dist/compose/timeline/pacing.d.ts.map +1 -1
- package/dist/compose/types.d.ts +14 -3
- package/dist/compose/types.d.ts.map +1 -1
- package/dist/index.js +1299 -382
- package/dist/index.js.map +1 -1
- package/dist/mcp/bin.js +1833 -726
- package/dist/mcp/bin.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/shared/version.d.ts +0 -2
- package/dist/shared/version.d.ts.map +1 -1
- package/dist/spec/locators.d.ts.map +1 -1
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/schema.d.ts +263 -2
- package/dist/spec/schema.d.ts.map +1 -1
- package/dist/verify/checks.d.ts.map +1 -1
- package/dist/verify/report.d.ts +33 -6
- package/dist/verify/report.d.ts.map +1 -1
- package/dist/verify/runner.d.ts +1 -1
- package/dist/verify/runner.d.ts.map +1 -1
- package/dist/verify/types.d.ts +36 -4
- package/dist/verify/types.d.ts.map +1 -1
- 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
|
|
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
|
-
).
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1699
|
-
writeFile(
|
|
1700
|
-
writeFile(
|
|
1701
|
-
writeFile(
|
|
1702
|
-
writeFile(
|
|
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
|
|
2194
|
+
import { join as join3 } from "path";
|
|
1804
2195
|
async function capture(spec, opts) {
|
|
1805
|
-
const bundleDir =
|
|
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
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
fps:
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1998
|
-
await driver.
|
|
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
|
-
|
|
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(
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
2255
|
-
readFile2(
|
|
2256
|
-
readFile2(
|
|
2257
|
-
readFile2(
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
2325
|
-
if (
|
|
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
|
|
2361
|
-
const
|
|
2362
|
-
|
|
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 =
|
|
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 *
|
|
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
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
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
|
|
2646
|
-
const
|
|
2647
|
-
return {
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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) -
|
|
3188
|
-
const wrap = wrapText(
|
|
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 =
|
|
3193
|
-
const cardH =
|
|
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
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
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
|
-
|
|
3205
|
-
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
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 =
|
|
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,
|
|
4081
|
+
roundRect(ctx2, box.x, box.y, box.w, box.h, theme.radius);
|
|
3450
4082
|
ctx2.fill();
|
|
3451
|
-
ctx2.
|
|
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 =
|
|
4090
|
+
const bx = box.x + 26;
|
|
3454
4091
|
if (o.showBadge) {
|
|
3455
4092
|
const badgeText = `${o.ordinal}/${o.totalSteps}`;
|
|
3456
|
-
const
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
const
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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:
|
|
4309
|
+
const { join: join14 } = await import("path");
|
|
3668
4310
|
const { tmpdir: tmpdir2 } = await import("os");
|
|
3669
|
-
const dir = await mkdtemp2(
|
|
3670
|
-
const listPath =
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 +
|
|
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/
|
|
4038
|
-
import
|
|
4039
|
-
|
|
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
|
|
4042
|
-
import { join as
|
|
4043
|
-
import { createHash as
|
|
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 =
|
|
4088
|
-
const file =
|
|
4089
|
-
if (!
|
|
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
|
|
4156
|
-
import { join as
|
|
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 =
|
|
4246
|
-
if (
|
|
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
|
|
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 =
|
|
4336
|
-
|
|
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 ${
|
|
5294
|
+
log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${voiceLabel})`);
|
|
4410
5295
|
try {
|
|
4411
|
-
narration = await synthesizeNarration(lines, audioCfg,
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
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
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
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
|
|
4627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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) => ({
|
|
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,
|
|
5270
|
-
|
|
5271
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5277
6238
|
const cases = v.checks.map((c) => {
|
|
5278
6239
|
const body = c.status === "fail" ? `
|
|
5279
|
-
<failure message="${
|
|
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="${
|
|
6244
|
+
<skipped message="${escXml(c.details)}"/>
|
|
5284
6245
|
` : "";
|
|
5285
|
-
return ` <testcase classname="${
|
|
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="${
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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 =
|
|
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 =
|
|
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@
|
|
6323
|
+
schema: "playhead/verdict@3",
|
|
5342
6324
|
verdict: failed ? "not-publishable" : "publishable",
|
|
5343
|
-
|
|
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(
|
|
5363
|
-
|
|
5364
|
-
|
|
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@
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
5460
|
-
import { existsSync as
|
|
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
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
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 (!
|
|
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 =
|
|
5547
|
-
if (
|
|
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 =
|
|
5550
|
-
if (
|
|
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
|
-
|
|
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(
|
|
5793
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
6051
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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(
|
|
6320
|
-
const p =
|
|
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
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
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
|
|
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.
|
|
6434
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
7776
|
+
const name = names.get(path);
|
|
7777
|
+
const junit = opts.junit ? opts.junit.replace(/\.xml$/, "") + `-${name}.xml` : void 0;
|
|
6489
7778
|
try {
|
|
6490
|
-
|
|
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(
|
|
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(
|
|
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 ??
|
|
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").
|
|
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 ??
|
|
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:
|
|
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:
|
|
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) :
|
|
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 {
|
|
7964
|
+
const { verifySignature: verifySignature2 } = await Promise.resolve().then(() => (init_report(), report_exports));
|
|
6607
7965
|
if (!verdict.signature) {
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
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
|
|
6614
|
-
|
|
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(
|
|
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("
|
|
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
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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
|
|
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" }
|