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