playhead-cli 0.1.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/action.yml +71 -0
- package/dist/audio/mux.d.ts +29 -0
- package/dist/audio/mux.d.ts.map +1 -0
- package/dist/audio/timing.d.ts +6 -0
- package/dist/audio/timing.d.ts.map +1 -0
- package/dist/audio/tts.d.ts +23 -0
- package/dist/audio/tts.d.ts.map +1 -0
- package/dist/authoring/author.d.ts +40 -0
- package/dist/authoring/author.d.ts.map +1 -0
- package/dist/authoring/catalog.d.ts +31 -0
- package/dist/authoring/catalog.d.ts.map +1 -0
- package/dist/authoring/explore.d.ts +30 -0
- package/dist/authoring/explore.d.ts.map +1 -0
- package/dist/authoring/serialize.d.ts +26 -0
- package/dist/authoring/serialize.d.ts.map +1 -0
- package/dist/authoring/validate.d.ts +31 -0
- package/dist/authoring/validate.d.ts.map +1 -0
- package/dist/beforeafter.d.ts +25 -0
- package/dist/beforeafter.d.ts.map +1 -0
- package/dist/bundle/hash.d.ts +3 -0
- package/dist/bundle/hash.d.ts.map +1 -0
- package/dist/bundle/reader.d.ts +6 -0
- package/dist/bundle/reader.d.ts.map +1 -0
- package/dist/bundle/types.d.ts +54 -0
- package/dist/bundle/types.d.ts.map +1 -0
- package/dist/bundle/writer.d.ts +35 -0
- package/dist/bundle/writer.d.ts.map +1 -0
- package/dist/capture/driver-api.d.ts +190 -0
- package/dist/capture/driver-api.d.ts.map +1 -0
- package/dist/capture/events.d.ts +78 -0
- package/dist/capture/events.d.ts.map +1 -0
- package/dist/capture/executor.d.ts +19 -0
- package/dist/capture/executor.d.ts.map +1 -0
- package/dist/capture/playwright-driver.d.ts +122 -0
- package/dist/capture/playwright-driver.d.ts.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +6648 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/compose/camera/interpolate.d.ts +9 -0
- package/dist/compose/camera/interpolate.d.ts.map +1 -0
- package/dist/compose/camera/planner.d.ts +19 -0
- package/dist/compose/camera/planner.d.ts.map +1 -0
- package/dist/compose/captions.d.ts +22 -0
- package/dist/compose/captions.d.ts.map +1 -0
- package/dist/compose/cursor/path.d.ts +17 -0
- package/dist/compose/cursor/path.d.ts.map +1 -0
- package/dist/compose/cursor/render.d.ts +10 -0
- package/dist/compose/cursor/render.d.ts.map +1 -0
- package/dist/compose/index.d.ts +25 -0
- package/dist/compose/index.d.ts.map +1 -0
- package/dist/compose/kinds.d.ts +27 -0
- package/dist/compose/kinds.d.ts.map +1 -0
- package/dist/compose/overlays/build.d.ts +7 -0
- package/dist/compose/overlays/build.d.ts.map +1 -0
- package/dist/compose/overlays/draw.d.ts +12 -0
- package/dist/compose/overlays/draw.d.ts.map +1 -0
- package/dist/compose/overlays/text.d.ts +18 -0
- package/dist/compose/overlays/text.d.ts.map +1 -0
- package/dist/compose/render/encoder.d.ts +13 -0
- package/dist/compose/render/encoder.d.ts.map +1 -0
- package/dist/compose/render/frameStore.d.ts +27 -0
- package/dist/compose/render/frameStore.d.ts.map +1 -0
- package/dist/compose/render/renderer.d.ts +5 -0
- package/dist/compose/render/renderer.d.ts.map +1 -0
- package/dist/compose/timeline/pacing.d.ts +36 -0
- package/dist/compose/timeline/pacing.d.ts.map +1 -0
- package/dist/compose/types.d.ts +236 -0
- package/dist/compose/types.d.ts.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4963 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/jira.d.ts +44 -0
- package/dist/integrations/jira.d.ts.map +1 -0
- package/dist/mcp/bin.d.ts +3 -0
- package/dist/mcp/bin.d.ts.map +1 -0
- package/dist/mcp/bin.js +5749 -0
- package/dist/mcp/bin.js.map +1 -0
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/shared/easing.d.ts +9 -0
- package/dist/shared/easing.d.ts.map +1 -0
- package/dist/shared/exit.d.ts +32 -0
- package/dist/shared/exit.d.ts.map +1 -0
- package/dist/shared/ffmpeg.d.ts +19 -0
- package/dist/shared/ffmpeg.d.ts.map +1 -0
- package/dist/shared/geometry.d.ts +47 -0
- package/dist/shared/geometry.d.ts.map +1 -0
- package/dist/shared/log.d.ts +10 -0
- package/dist/shared/log.d.ts.map +1 -0
- package/dist/shared/version.d.ts +4 -0
- package/dist/shared/version.d.ts.map +1 -0
- package/dist/spec/locators.d.ts +62 -0
- package/dist/spec/locators.d.ts.map +1 -0
- package/dist/spec/parse.d.ts +16 -0
- package/dist/spec/parse.d.ts.map +1 -0
- package/dist/spec/schema.d.ts +365 -0
- package/dist/spec/schema.d.ts.map +1 -0
- package/dist/theme/index.d.ts +23 -0
- package/dist/theme/index.d.ts.map +1 -0
- package/dist/verify/checks.d.ts +27 -0
- package/dist/verify/checks.d.ts.map +1 -0
- package/dist/verify/contact-sheet.d.ts +7 -0
- package/dist/verify/contact-sheet.d.ts.map +1 -0
- package/dist/verify/media.d.ts +42 -0
- package/dist/verify/media.d.ts.map +1 -0
- package/dist/verify/report.d.ts +12 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/runner.d.ts +13 -0
- package/dist/verify/runner.d.ts.map +1 -0
- package/dist/verify/types.d.ts +63 -0
- package/dist/verify/types.d.ts.map +1 -0
- package/dist/verify/vision.d.ts +19 -0
- package/dist/verify/vision.d.ts.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4963 @@
|
|
|
1
|
+
// src/spec/parse.ts
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { dirname, resolve as resolvePath } from "path";
|
|
4
|
+
import { parseDocument, LineCounter } from "yaml";
|
|
5
|
+
|
|
6
|
+
// src/spec/schema.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/spec/locators.ts
|
|
10
|
+
var LocatorParseError = class extends Error {
|
|
11
|
+
};
|
|
12
|
+
function parseLocator(input) {
|
|
13
|
+
const raw = input.trim();
|
|
14
|
+
let nth;
|
|
15
|
+
const parts = raw.split(">>").map((p) => p.trim());
|
|
16
|
+
const frames = [];
|
|
17
|
+
while (parts.length > 0 && parts[0].startsWith("frame=")) {
|
|
18
|
+
const sel = unquote(parts.shift().slice("frame=".length).trim());
|
|
19
|
+
if (!sel) throw new LocatorParseError(`Empty frame selector in "${raw}"`);
|
|
20
|
+
frames.push(sel);
|
|
21
|
+
}
|
|
22
|
+
if (parts.length === 0) {
|
|
23
|
+
throw new LocatorParseError(`Locator "${raw}" has frames but no element \u2014 add a base like role=... after the frame chain`);
|
|
24
|
+
}
|
|
25
|
+
const body = parts.shift();
|
|
26
|
+
if (parts.length > 0) {
|
|
27
|
+
const tail = parts.join(" >> ");
|
|
28
|
+
const m = /^nth=(\d+)$/.exec(tail);
|
|
29
|
+
if (!m) throw new LocatorParseError(`Unsupported locator suffix "${tail}" in "${raw}" (only "nth=N" is supported)`);
|
|
30
|
+
nth = Number(m[1]);
|
|
31
|
+
}
|
|
32
|
+
const withFrames = (p) => frames.length > 0 ? { ...p, frames } : p;
|
|
33
|
+
const eq = body.indexOf("=");
|
|
34
|
+
if (eq === -1) {
|
|
35
|
+
throw new LocatorParseError(
|
|
36
|
+
`Locator "${raw}" is missing an engine prefix \u2014 expected role=, label=, text=, placeholder=, testid= or css=`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const engine = body.slice(0, eq).trim();
|
|
40
|
+
const rest = body.slice(eq + 1).trim();
|
|
41
|
+
switch (engine) {
|
|
42
|
+
case "role": {
|
|
43
|
+
const m = /^([\w-]+)(?:\[name=(.+)\])?$/.exec(rest);
|
|
44
|
+
if (!m) throw new LocatorParseError(`Malformed role locator "${raw}" \u2014 expected role=button[name="..."]`);
|
|
45
|
+
const base = { kind: "role", role: m[1] };
|
|
46
|
+
if (m[2] !== void 0) base.name = parseTextMatch(m[2], raw);
|
|
47
|
+
return withFrames({ base, nth, raw });
|
|
48
|
+
}
|
|
49
|
+
case "label":
|
|
50
|
+
return withFrames({ base: { kind: "label", match: parseTextMatch(rest, raw) }, nth, raw });
|
|
51
|
+
case "text":
|
|
52
|
+
return withFrames({ base: { kind: "text", match: parseTextMatch(rest, raw) }, nth, raw });
|
|
53
|
+
case "placeholder":
|
|
54
|
+
return withFrames({ base: { kind: "placeholder", match: parseTextMatch(rest, raw) }, nth, raw });
|
|
55
|
+
case "testid": {
|
|
56
|
+
if (!rest) throw new LocatorParseError(`Empty testid in locator "${raw}"`);
|
|
57
|
+
return withFrames({ base: { kind: "testid", value: unquote(rest) }, nth, raw });
|
|
58
|
+
}
|
|
59
|
+
case "css": {
|
|
60
|
+
if (!rest) throw new LocatorParseError(`Empty css selector in locator "${raw}"`);
|
|
61
|
+
return withFrames({ base: { kind: "css", selector: rest }, nth, raw });
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
throw new LocatorParseError(
|
|
65
|
+
`Unknown locator engine "${engine}" in "${raw}" \u2014 expected role, label, text, placeholder, testid or css`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function parseTextMatch(s, context) {
|
|
70
|
+
const v = s.trim();
|
|
71
|
+
if (v.startsWith("/")) {
|
|
72
|
+
const m = /^\/(.+)\/([a-z]*)$/.exec(v);
|
|
73
|
+
if (!m) throw new LocatorParseError(`Malformed regex "${v}" in locator "${context}"`);
|
|
74
|
+
try {
|
|
75
|
+
new RegExp(m[1], m[2]);
|
|
76
|
+
} catch {
|
|
77
|
+
throw new LocatorParseError(`Invalid regex "${v}" in locator "${context}"`);
|
|
78
|
+
}
|
|
79
|
+
return { regex: { source: m[1], flags: m[2] } };
|
|
80
|
+
}
|
|
81
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2 || v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
82
|
+
return { exact: v.slice(1, -1) };
|
|
83
|
+
}
|
|
84
|
+
if (!v) throw new LocatorParseError(`Empty text match in locator "${context}"`);
|
|
85
|
+
return { substring: v };
|
|
86
|
+
}
|
|
87
|
+
function unquote(s) {
|
|
88
|
+
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
|
|
89
|
+
return s;
|
|
90
|
+
}
|
|
91
|
+
function locatorDisplayName(loc) {
|
|
92
|
+
const b = loc.base;
|
|
93
|
+
switch (b.kind) {
|
|
94
|
+
case "role":
|
|
95
|
+
return b.name ? textMatchDisplay(b.name) : b.role;
|
|
96
|
+
case "label":
|
|
97
|
+
case "text":
|
|
98
|
+
case "placeholder":
|
|
99
|
+
return textMatchDisplay(b.match);
|
|
100
|
+
case "testid":
|
|
101
|
+
return b.value;
|
|
102
|
+
case "css":
|
|
103
|
+
return b.selector;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function textMatchDisplay(m) {
|
|
107
|
+
if ("exact" in m) return m.exact;
|
|
108
|
+
if ("substring" in m) return m.substring;
|
|
109
|
+
return m.regex.source;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/spec/schema.ts
|
|
113
|
+
var dimensions = z.string().regex(/^\d+x\d+$/, "expected WIDTHxHEIGHT, e.g. 1280x800").transform((s) => {
|
|
114
|
+
const [w, h] = s.split("x").map(Number);
|
|
115
|
+
return { w, h };
|
|
116
|
+
});
|
|
117
|
+
var locatorString = z.string().min(1).superRefine((val, ctx2) => {
|
|
118
|
+
try {
|
|
119
|
+
parseLocator(val);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
ctx2.addIssue({ code: "custom", message: e.message });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
var focusHint = z.union([z.literal("target"), z.literal("wide"), locatorString]);
|
|
125
|
+
var EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout"];
|
|
126
|
+
var stepExtras = {
|
|
127
|
+
caption: z.string().optional(),
|
|
128
|
+
/** The SPOKEN line for TTS narration — unconstrained by the caption card's size. Resolution:
|
|
129
|
+
* spoken = narration ?? caption ?? auto; on-screen card = caption ?? auto. */
|
|
130
|
+
narration: z.string().optional(),
|
|
131
|
+
focus: focusHint.optional(),
|
|
132
|
+
/** Camera-shot boundary control: 'cut' forces this step into its own shot; 'continue' merges
|
|
133
|
+
* it into the previous shot even when the heuristic would split. Decouples camera cuts from
|
|
134
|
+
* scene structure (scenes stay narrative). */
|
|
135
|
+
shot: z.enum(["cut", "continue"]).optional(),
|
|
136
|
+
/** Per-step budget override (ms) for finding the target / meeting the expectation. */
|
|
137
|
+
timeout: z.number().int().positive().optional()
|
|
138
|
+
};
|
|
139
|
+
var targetOrShorthand = z.union([
|
|
140
|
+
locatorString,
|
|
141
|
+
z.object({ target: locatorString, ...stepExtras })
|
|
142
|
+
]);
|
|
143
|
+
var gotoStep = z.object({ action: z.literal("goto"), url: z.string(), ...stepExtras }).strict();
|
|
144
|
+
var pointerStep = z.object({
|
|
145
|
+
action: z.enum(["click", "dblclick", "hover"]),
|
|
146
|
+
target: locatorString,
|
|
147
|
+
...stepExtras
|
|
148
|
+
}).strict();
|
|
149
|
+
var typeStep = z.object({
|
|
150
|
+
action: z.literal("type"),
|
|
151
|
+
target: locatorString,
|
|
152
|
+
text: z.string(),
|
|
153
|
+
mask: z.boolean().default(false),
|
|
154
|
+
...stepExtras
|
|
155
|
+
}).strict();
|
|
156
|
+
var pressStep = z.object({ action: z.literal("press"), keys: z.string(), ...stepExtras }).strict();
|
|
157
|
+
var selectStep = z.object({
|
|
158
|
+
action: z.literal("select"),
|
|
159
|
+
target: locatorString,
|
|
160
|
+
value: z.string(),
|
|
161
|
+
...stepExtras
|
|
162
|
+
}).strict();
|
|
163
|
+
var scrollStep = z.object({
|
|
164
|
+
action: z.literal("scroll"),
|
|
165
|
+
target: locatorString.optional(),
|
|
166
|
+
by: z.object({ x: z.number().default(0), y: z.number() }).strict().optional(),
|
|
167
|
+
...stepExtras
|
|
168
|
+
}).strict();
|
|
169
|
+
var expectStep = z.object({
|
|
170
|
+
action: z.literal("expect"),
|
|
171
|
+
target: locatorString,
|
|
172
|
+
visible: z.boolean().optional(),
|
|
173
|
+
text: z.string().optional(),
|
|
174
|
+
/** Exact number of matching elements (e.g. rows in a filtered table). */
|
|
175
|
+
count: z.number().int().min(0).optional(),
|
|
176
|
+
...stepExtras
|
|
177
|
+
}).strict();
|
|
178
|
+
var waitStep = z.object({
|
|
179
|
+
action: z.literal("wait"),
|
|
180
|
+
ms: z.number().int().positive().optional(),
|
|
181
|
+
for: locatorString.optional(),
|
|
182
|
+
state: z.enum(["visible", "hidden"]).default("visible"),
|
|
183
|
+
...stepExtras
|
|
184
|
+
}).strict().refine((s) => s.ms !== void 0 !== (s.for !== void 0), {
|
|
185
|
+
message: "wait needs exactly one of `ms` (blind sleep, discouraged) or `for` (an element to wait on)"
|
|
186
|
+
});
|
|
187
|
+
var stepSchema = z.discriminatedUnion("action", [
|
|
188
|
+
gotoStep,
|
|
189
|
+
pointerStep,
|
|
190
|
+
typeStep,
|
|
191
|
+
pressStep,
|
|
192
|
+
selectStep,
|
|
193
|
+
scrollStep,
|
|
194
|
+
expectStep,
|
|
195
|
+
waitStep
|
|
196
|
+
]);
|
|
197
|
+
var ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "select", "scroll", "expect", "wait"];
|
|
198
|
+
var authoredStep = z.record(z.string(), z.unknown()).superRefine((obj, ctx2) => {
|
|
199
|
+
const actions = ACTION_KEYS.filter((k) => k in obj);
|
|
200
|
+
if (actions.length !== 1) {
|
|
201
|
+
const unknown = Object.keys(obj).filter((k) => !EXTRA_KEYS.includes(k));
|
|
202
|
+
const hints = unknown.map((k) => ({ k, near: nearestAction(k) })).filter((h) => h.near !== null).map((h) => ` \u2014 did you mean "${h.near}" instead of "${h.k}"?`).join("");
|
|
203
|
+
ctx2.addIssue({
|
|
204
|
+
code: "custom",
|
|
205
|
+
message: actions.length === 0 ? `Step has no action (found: ${unknown.join(", ") || "nothing"})${hints || ` \u2014 expected one of: ${ACTION_KEYS.join(", ")}`}` : `Step has multiple actions (${actions.join(", ")}) \u2014 exactly one is allowed`
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const known = /* @__PURE__ */ new Set([...ACTION_KEYS, ...EXTRA_KEYS]);
|
|
210
|
+
for (const key of Object.keys(obj)) {
|
|
211
|
+
if (!known.has(key)) {
|
|
212
|
+
ctx2.addIssue({ code: "custom", message: `Unknown step field "${key}"` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}).transform((obj) => normalizeStep(obj));
|
|
216
|
+
function nearestAction(key) {
|
|
217
|
+
let best = null;
|
|
218
|
+
let bestD = 3;
|
|
219
|
+
for (const a of ACTION_KEYS) {
|
|
220
|
+
const d = editDistance(key.toLowerCase(), a);
|
|
221
|
+
if (d < bestD) {
|
|
222
|
+
bestD = d;
|
|
223
|
+
best = a;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return best;
|
|
227
|
+
}
|
|
228
|
+
function editDistance(a, b) {
|
|
229
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
230
|
+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
|
|
231
|
+
for (let i = 1; i <= a.length; i++) {
|
|
232
|
+
for (let j = 1; j <= b.length; j++) {
|
|
233
|
+
dp[i][j] = Math.min(
|
|
234
|
+
dp[i - 1][j] + 1,
|
|
235
|
+
dp[i][j - 1] + 1,
|
|
236
|
+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return dp[a.length][b.length];
|
|
241
|
+
}
|
|
242
|
+
function normalizeStep(obj) {
|
|
243
|
+
const action = ACTION_KEYS.find((k) => k in obj);
|
|
244
|
+
const payload = obj[action];
|
|
245
|
+
const extras = {};
|
|
246
|
+
for (const k of EXTRA_KEYS) {
|
|
247
|
+
if (k in obj) extras[k] = obj[k];
|
|
248
|
+
}
|
|
249
|
+
if (typeof payload === "string") {
|
|
250
|
+
if (action === "goto") return { action, url: payload, ...extras };
|
|
251
|
+
if (action === "press") return { action, keys: payload, ...extras };
|
|
252
|
+
return { action, target: payload, ...extras };
|
|
253
|
+
}
|
|
254
|
+
if (typeof payload === "number" && action === "wait") {
|
|
255
|
+
return { action, ms: payload, ...extras };
|
|
256
|
+
}
|
|
257
|
+
if (payload !== null && typeof payload === "object") {
|
|
258
|
+
return { action, ...payload, ...extras };
|
|
259
|
+
}
|
|
260
|
+
return { action, value: payload, ...extras };
|
|
261
|
+
}
|
|
262
|
+
var specSchema = z.object({
|
|
263
|
+
playhead: z.literal(1),
|
|
264
|
+
title: z.string().min(1),
|
|
265
|
+
/** Title-card subtitle. Defaults to the app's host — set this for anything shareable. */
|
|
266
|
+
subtitle: z.string().optional(),
|
|
267
|
+
app: z.object({
|
|
268
|
+
url: z.string().url(),
|
|
269
|
+
/** Capture viewport. When omitted it is derived from output.aspect so portrait/square
|
|
270
|
+
* outputs are captured at a matching viewport (the app reflows natively, no cropping). */
|
|
271
|
+
viewport: dimensions.optional(),
|
|
272
|
+
storageState: z.string().optional(),
|
|
273
|
+
/** Fail fast when auth is stale: this locator must match after the initial load, or the
|
|
274
|
+
* capture aborts — instead of confidently filming the login page. */
|
|
275
|
+
assertLoggedIn: locatorString.optional(),
|
|
276
|
+
/** For before/after: a second app to capture the same flow against (the "before"). */
|
|
277
|
+
compareUrl: z.string().url().optional(),
|
|
278
|
+
/** Determinism controls: same spec → same pixels regardless of where/when it runs. */
|
|
279
|
+
environment: z.object({
|
|
280
|
+
timezone: z.string().optional(),
|
|
281
|
+
locale: z.string().optional(),
|
|
282
|
+
colorScheme: z.enum(["light", "dark"]).optional(),
|
|
283
|
+
/** Freeze Date at this ISO timestamp — "3 minutes ago" stops drifting between runs. */
|
|
284
|
+
fixedTime: z.string().optional(),
|
|
285
|
+
reducedMotion: z.boolean().optional()
|
|
286
|
+
}).strict().optional(),
|
|
287
|
+
/** Request interception: block third-party noise, stub responses, replay a HAR. */
|
|
288
|
+
network: z.object({
|
|
289
|
+
block: z.array(z.string()).optional(),
|
|
290
|
+
stub: z.array(
|
|
291
|
+
z.object({
|
|
292
|
+
url: z.string(),
|
|
293
|
+
status: z.number().int().optional(),
|
|
294
|
+
body: z.string().optional(),
|
|
295
|
+
contentType: z.string().optional()
|
|
296
|
+
}).strict()
|
|
297
|
+
).optional(),
|
|
298
|
+
har: z.string().optional()
|
|
299
|
+
}).strict().optional(),
|
|
300
|
+
/** window.confirm()/alert() policy. Default accept — the silent auto-dismiss films the
|
|
301
|
+
* cancel branch of confirm-guarded flows. */
|
|
302
|
+
dialogs: z.enum(["accept", "dismiss"]).optional(),
|
|
303
|
+
/** Settle tuning for slow backends / chatty apps. */
|
|
304
|
+
settle: z.object({
|
|
305
|
+
idleMs: z.number().int().positive().optional(),
|
|
306
|
+
capMs: z.number().int().positive().optional()
|
|
307
|
+
}).strict().optional()
|
|
308
|
+
}).strict(),
|
|
309
|
+
output: z.object({
|
|
310
|
+
kind: z.enum(["walkthrough", "demo", "bug-repro", "before-after", "release", "social"]).default("walkthrough"),
|
|
311
|
+
aspect: z.enum(["16:9", "9:16", "1:1"]).default("16:9"),
|
|
312
|
+
/** Output resolution; when omitted it is derived from the aspect. */
|
|
313
|
+
resolution: dimensions.optional(),
|
|
314
|
+
fps: z.number().int().min(24).max(60).default(30),
|
|
315
|
+
pacing: z.enum(["relaxed", "normal", "brisk"]).optional(),
|
|
316
|
+
/**
|
|
317
|
+
* Multiplier on per-step dwell (min-step duration, post-action holds, outro).
|
|
318
|
+
* 1 = the pacing preset as-is. Raise it for long-form teaching walkthroughs
|
|
319
|
+
* where each screen should linger so a viewer can read and absorb it.
|
|
320
|
+
*/
|
|
321
|
+
dwellScale: z.number().min(0.5).max(4).optional(),
|
|
322
|
+
/**
|
|
323
|
+
* Audio. `narration: tts` speaks each step's caption (pacing grows so every line finishes
|
|
324
|
+
* before its step ends). `sfx` adds a soft synthesized click at each click beat. `music`
|
|
325
|
+
* lays a user-supplied bed under everything, ducked beneath narration. Any audio work
|
|
326
|
+
* finishes with loudness normalization (EBU R128, I=-16).
|
|
327
|
+
*/
|
|
328
|
+
audio: z.object({
|
|
329
|
+
narration: z.enum(["none", "tts"]).default("none"),
|
|
330
|
+
provider: z.enum(["say"]).default("say"),
|
|
331
|
+
voice: z.string().optional(),
|
|
332
|
+
rate: z.number().int().min(80).max(400).optional(),
|
|
333
|
+
/** Click sound effects at click/select beats. Defaults on when this block is present. */
|
|
334
|
+
sfx: z.boolean().default(true),
|
|
335
|
+
/** Music bed from a local audio file (no asset is bundled — bring your own licensed track). */
|
|
336
|
+
music: z.object({
|
|
337
|
+
file: z.string(),
|
|
338
|
+
gainDb: z.number().min(-40).max(6).default(-19)
|
|
339
|
+
}).strict().optional()
|
|
340
|
+
}).strict().optional(),
|
|
341
|
+
/** Closing card. When set, the video ends on a title-card-styled end card. */
|
|
342
|
+
endCard: z.object({
|
|
343
|
+
title: z.string().min(1),
|
|
344
|
+
subtitle: z.string().optional()
|
|
345
|
+
}).strict().optional()
|
|
346
|
+
}).strict().prefault({}),
|
|
347
|
+
// No `name` field: exactly one theme exists today, and a knob that is accepted but ignored is
|
|
348
|
+
// a lie. It returns when a theme registry does.
|
|
349
|
+
theme: z.object({
|
|
350
|
+
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, "expected a #rrggbb hex color").optional(),
|
|
351
|
+
captions: z.enum(["bottom", "top", "off"]).default("bottom")
|
|
352
|
+
}).strict().prefault({}),
|
|
353
|
+
masking: z.array(
|
|
354
|
+
z.object({
|
|
355
|
+
target: locatorString,
|
|
356
|
+
style: z.enum(["solid", "blur"]).default("solid")
|
|
357
|
+
}).strict()
|
|
358
|
+
).default([]),
|
|
359
|
+
scenes: z.array(
|
|
360
|
+
z.object({
|
|
361
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scene ids are lowercase kebab-case"),
|
|
362
|
+
title: z.string().optional(),
|
|
363
|
+
steps: z.array(authoredStep.pipe(stepSchema)).min(1)
|
|
364
|
+
}).strict()
|
|
365
|
+
).min(1)
|
|
366
|
+
}).strict();
|
|
367
|
+
var ASPECTS = {
|
|
368
|
+
"16:9": { viewport: { w: 1280, h: 720 }, resolution: { w: 1920, h: 1080 } },
|
|
369
|
+
"9:16": { viewport: { w: 720, h: 1280 }, resolution: { w: 1080, h: 1920 } },
|
|
370
|
+
"1:1": { viewport: { w: 900, h: 900 }, resolution: { w: 1080, h: 1080 } }
|
|
371
|
+
};
|
|
372
|
+
function resolveViewport(spec) {
|
|
373
|
+
return spec.app.viewport ?? ASPECTS[spec.output.aspect].viewport;
|
|
374
|
+
}
|
|
375
|
+
function resolveResolution(spec) {
|
|
376
|
+
return spec.output.resolution ?? ASPECTS[spec.output.aspect].resolution;
|
|
377
|
+
}
|
|
378
|
+
function flattenSteps(spec) {
|
|
379
|
+
const out = [];
|
|
380
|
+
let ordinal = 0;
|
|
381
|
+
spec.scenes.forEach((scene, sceneIndex) => {
|
|
382
|
+
scene.steps.forEach((step, stepIndex) => {
|
|
383
|
+
ordinal += 1;
|
|
384
|
+
out.push({ sceneId: scene.id, sceneIndex, stepIndex, ordinal, step });
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/spec/parse.ts
|
|
391
|
+
var SpecError = class extends Error {
|
|
392
|
+
constructor(message, issues = []) {
|
|
393
|
+
super(message);
|
|
394
|
+
this.issues = issues;
|
|
395
|
+
}
|
|
396
|
+
issues;
|
|
397
|
+
};
|
|
398
|
+
var KNOWN_KEYS = [
|
|
399
|
+
"playhead",
|
|
400
|
+
"title",
|
|
401
|
+
"subtitle",
|
|
402
|
+
"app",
|
|
403
|
+
"output",
|
|
404
|
+
"theme",
|
|
405
|
+
"masking",
|
|
406
|
+
"scenes",
|
|
407
|
+
"vars",
|
|
408
|
+
"extends",
|
|
409
|
+
"url",
|
|
410
|
+
"viewport",
|
|
411
|
+
"storageState",
|
|
412
|
+
"compareUrl",
|
|
413
|
+
"assertLoggedIn",
|
|
414
|
+
"environment",
|
|
415
|
+
"network",
|
|
416
|
+
"dialogs",
|
|
417
|
+
"settle",
|
|
418
|
+
"timezone",
|
|
419
|
+
"locale",
|
|
420
|
+
"colorScheme",
|
|
421
|
+
"fixedTime",
|
|
422
|
+
"reducedMotion",
|
|
423
|
+
"block",
|
|
424
|
+
"stub",
|
|
425
|
+
"har",
|
|
426
|
+
"status",
|
|
427
|
+
"body",
|
|
428
|
+
"contentType",
|
|
429
|
+
"idleMs",
|
|
430
|
+
"capMs",
|
|
431
|
+
"kind",
|
|
432
|
+
"aspect",
|
|
433
|
+
"resolution",
|
|
434
|
+
"fps",
|
|
435
|
+
"pacing",
|
|
436
|
+
"dwellScale",
|
|
437
|
+
"audio",
|
|
438
|
+
"endCard",
|
|
439
|
+
"narration",
|
|
440
|
+
"provider",
|
|
441
|
+
"voice",
|
|
442
|
+
"rate",
|
|
443
|
+
"sfx",
|
|
444
|
+
"music",
|
|
445
|
+
"file",
|
|
446
|
+
"gainDb",
|
|
447
|
+
"accent",
|
|
448
|
+
"captions",
|
|
449
|
+
"target",
|
|
450
|
+
"style",
|
|
451
|
+
"id",
|
|
452
|
+
"steps",
|
|
453
|
+
"shot",
|
|
454
|
+
"timeout",
|
|
455
|
+
"goto",
|
|
456
|
+
"click",
|
|
457
|
+
"dblclick",
|
|
458
|
+
"hover",
|
|
459
|
+
"type",
|
|
460
|
+
"press",
|
|
461
|
+
"select",
|
|
462
|
+
"scroll",
|
|
463
|
+
"expect",
|
|
464
|
+
"wait",
|
|
465
|
+
"caption",
|
|
466
|
+
"focus",
|
|
467
|
+
"mask",
|
|
468
|
+
"text",
|
|
469
|
+
"value",
|
|
470
|
+
"keys",
|
|
471
|
+
"ms",
|
|
472
|
+
"by",
|
|
473
|
+
"visible",
|
|
474
|
+
"hidden",
|
|
475
|
+
"count",
|
|
476
|
+
"for",
|
|
477
|
+
"state",
|
|
478
|
+
"x",
|
|
479
|
+
"y"
|
|
480
|
+
];
|
|
481
|
+
function suggest(key) {
|
|
482
|
+
let best = null;
|
|
483
|
+
let bestD = 3;
|
|
484
|
+
for (const k of KNOWN_KEYS) {
|
|
485
|
+
const d = editDistance(key.toLowerCase(), k.toLowerCase());
|
|
486
|
+
if (d > 0 && d < bestD) {
|
|
487
|
+
bestD = d;
|
|
488
|
+
best = k;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return best;
|
|
492
|
+
}
|
|
493
|
+
function parseSpec(yamlText, sourcePath = "<inline>") {
|
|
494
|
+
const lineCounter = new LineCounter();
|
|
495
|
+
const doc = parseDocument(yamlText, { lineCounter, keepSourceTokens: true });
|
|
496
|
+
if (doc.errors.length > 0) {
|
|
497
|
+
const first = doc.errors[0];
|
|
498
|
+
throw new SpecError(`${sourcePath}: not valid YAML \u2014 ${first.message}`);
|
|
499
|
+
}
|
|
500
|
+
const raw = doc.toJS();
|
|
501
|
+
if (raw && typeof raw === "object" && "extends" in raw) {
|
|
502
|
+
throw new SpecError(`${sourcePath}: "extends" needs a file on disk \u2014 load the spec with loadSpec/the CLI, not inline text`);
|
|
503
|
+
}
|
|
504
|
+
return validateResolved(interpolate(raw, sourcePath), sourcePath, doc, lineCounter);
|
|
505
|
+
}
|
|
506
|
+
async function loadSpec(path) {
|
|
507
|
+
const merged = await loadRaw(resolvePath(path), 0);
|
|
508
|
+
const text = await readFile(resolvePath(path), "utf8");
|
|
509
|
+
const lineCounter = new LineCounter();
|
|
510
|
+
const doc = parseDocument(text, { lineCounter });
|
|
511
|
+
return validateResolved(interpolate(merged, path), path, doc, lineCounter);
|
|
512
|
+
}
|
|
513
|
+
async function loadRaw(path, depth) {
|
|
514
|
+
if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
|
|
515
|
+
const text = await readFile(path, "utf8");
|
|
516
|
+
const doc = parseDocument(text);
|
|
517
|
+
if (doc.errors.length > 0) throw new SpecError(`${path}: not valid YAML \u2014 ${doc.errors[0].message}`);
|
|
518
|
+
const raw = doc.toJS() ?? {};
|
|
519
|
+
const base = raw.extends;
|
|
520
|
+
delete raw.extends;
|
|
521
|
+
if (base === void 0) return raw;
|
|
522
|
+
if (typeof base !== "string") throw new SpecError(`${path}: "extends" must be a path string`);
|
|
523
|
+
const baseObj = await loadRaw(resolvePath(dirname(path), base), depth + 1);
|
|
524
|
+
return mergeSpecs(baseObj, raw);
|
|
525
|
+
}
|
|
526
|
+
function mergeSpecs(base, child) {
|
|
527
|
+
const out = { ...base };
|
|
528
|
+
for (const [k, v] of Object.entries(child)) {
|
|
529
|
+
const b = out[k];
|
|
530
|
+
if (Array.isArray(b) && Array.isArray(v) && (k === "masking" || k === "scenes")) {
|
|
531
|
+
out[k] = [...b, ...v];
|
|
532
|
+
} else if (isPlainObject(b) && isPlainObject(v)) {
|
|
533
|
+
out[k] = mergeSpecs(b, v);
|
|
534
|
+
} else {
|
|
535
|
+
out[k] = v;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
function isPlainObject(v) {
|
|
541
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
542
|
+
}
|
|
543
|
+
function interpolate(raw, sourcePath) {
|
|
544
|
+
if (!isPlainObject(raw)) return raw;
|
|
545
|
+
const varsIn = isPlainObject(raw.vars) ? raw.vars : {};
|
|
546
|
+
const vars = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const [name, value] of Object.entries(varsIn)) {
|
|
548
|
+
vars.set(name, resolveEnv(String(value), sourcePath));
|
|
549
|
+
}
|
|
550
|
+
const doc = { ...raw };
|
|
551
|
+
delete doc.vars;
|
|
552
|
+
const seen = (s) => {
|
|
553
|
+
const withEnv = resolveEnv(s, sourcePath);
|
|
554
|
+
return withEnv.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, name) => {
|
|
555
|
+
const v = vars.get(name);
|
|
556
|
+
if (v === void 0) {
|
|
557
|
+
const near = suggest(name) ?? [...vars.keys()].find((k) => editDistance(k, name) <= 2);
|
|
558
|
+
throw new SpecError(
|
|
559
|
+
`${sourcePath}: unknown variable {{${name}}}${near ? ` \u2014 did you mean {{${near}}}?` : ""} (declared vars: ${[...vars.keys()].join(", ") || "none"})`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return v;
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
const walk = (v) => {
|
|
566
|
+
if (typeof v === "string") return seen(v);
|
|
567
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
568
|
+
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, walk(val)]));
|
|
569
|
+
return v;
|
|
570
|
+
};
|
|
571
|
+
return walk(doc);
|
|
572
|
+
}
|
|
573
|
+
function resolveEnv(s, sourcePath) {
|
|
574
|
+
return s.replace(/\$\{env\.([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, dflt) => {
|
|
575
|
+
const v = process.env[name];
|
|
576
|
+
if (v !== void 0) return v;
|
|
577
|
+
if (dflt !== void 0) return dflt;
|
|
578
|
+
throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
function validateResolved(resolved, sourcePath, doc, lineCounter) {
|
|
582
|
+
const result = specSchema.safeParse(resolved);
|
|
583
|
+
if (!result.success) {
|
|
584
|
+
const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter));
|
|
585
|
+
throw new SpecError(`${sourcePath}: invalid spec
|
|
586
|
+
- ${issues.join("\n - ")}`, issues);
|
|
587
|
+
}
|
|
588
|
+
return result.data;
|
|
589
|
+
}
|
|
590
|
+
function formatIssue(iss, sourcePath, doc, lineCounter) {
|
|
591
|
+
const where = iss.path.length ? humanPath(iss.path) : "spec";
|
|
592
|
+
const pos = positionOf(iss.path, doc, lineCounter);
|
|
593
|
+
const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
|
|
594
|
+
if (iss.code === "unrecognized_keys") {
|
|
595
|
+
const keys = iss.keys;
|
|
596
|
+
const hints = keys.map((k) => ({ k, near: suggest(k) })).map((h) => h.near ? `"${h.k}" (did you mean "${h.near}"?)` : `"${h.k}"`).join(", ");
|
|
597
|
+
return `${at}${where}: unknown key${keys.length > 1 ? "s" : ""} ${hints}`;
|
|
598
|
+
}
|
|
599
|
+
return `${at}${where}: ${iss.message}`;
|
|
600
|
+
}
|
|
601
|
+
function positionOf(path, doc, lineCounter) {
|
|
602
|
+
for (let depth = path.length; depth > 0; depth--) {
|
|
603
|
+
try {
|
|
604
|
+
const node = doc.getIn(path.slice(0, depth), true);
|
|
605
|
+
if (node?.range) {
|
|
606
|
+
const pos = lineCounter.linePos(node.range[0]);
|
|
607
|
+
return { line: pos.line, col: pos.col };
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
function humanPath(path) {
|
|
615
|
+
return path.map((seg, i) => typeof seg === "number" ? `[${seg}]` : i === 0 ? String(seg) : `.${String(seg)}`).join("");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/capture/executor.ts
|
|
619
|
+
import { join as join2 } from "path";
|
|
620
|
+
|
|
621
|
+
// src/capture/playwright-driver.ts
|
|
622
|
+
import { chromium } from "playwright";
|
|
623
|
+
|
|
624
|
+
// src/shared/easing.ts
|
|
625
|
+
var EASE_ZOOM = [0.4, 0, 0.2, 1];
|
|
626
|
+
var EASE_PAN = [0.3, 0, 0.15, 1];
|
|
627
|
+
function cubicBezier(p, t) {
|
|
628
|
+
if (t <= 0) return 0;
|
|
629
|
+
if (t >= 1) return 1;
|
|
630
|
+
const [x1, y1, x2, y2] = p;
|
|
631
|
+
let u = t;
|
|
632
|
+
for (let i = 0; i < 8; i++) {
|
|
633
|
+
const x = bez(u, x1, x2) - t;
|
|
634
|
+
if (Math.abs(x) < 1e-6) break;
|
|
635
|
+
const dx = dbez(u, x1, x2);
|
|
636
|
+
if (Math.abs(dx) < 1e-6) break;
|
|
637
|
+
u -= x / dx;
|
|
638
|
+
}
|
|
639
|
+
return bez(Math.max(0, Math.min(1, u)), y1, y2);
|
|
640
|
+
}
|
|
641
|
+
function bez(u, a, b) {
|
|
642
|
+
const v = 1 - u;
|
|
643
|
+
return 3 * v * v * u * a + 3 * v * u * u * b + u * u * u;
|
|
644
|
+
}
|
|
645
|
+
function dbez(u, a, b) {
|
|
646
|
+
const v = 1 - u;
|
|
647
|
+
return 3 * v * v * a + 6 * v * u * (b - a) + 3 * u * u * (1 - b);
|
|
648
|
+
}
|
|
649
|
+
function minJerk(t) {
|
|
650
|
+
if (t <= 0) return 0;
|
|
651
|
+
if (t >= 1) return 1;
|
|
652
|
+
return t * t * t * (10 - 15 * t + 6 * t * t);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/shared/log.ts
|
|
656
|
+
import pc from "picocolors";
|
|
657
|
+
var verbose = false;
|
|
658
|
+
var log = {
|
|
659
|
+
info(msg) {
|
|
660
|
+
console.error(pc.dim("[playhead]") + " " + msg);
|
|
661
|
+
},
|
|
662
|
+
step(msg) {
|
|
663
|
+
console.error(pc.cyan(" \u25B8 ") + msg);
|
|
664
|
+
},
|
|
665
|
+
ok(msg) {
|
|
666
|
+
console.error(pc.green(" \u2713 ") + msg);
|
|
667
|
+
},
|
|
668
|
+
warn(msg) {
|
|
669
|
+
console.error(pc.yellow(" \u26A0 ") + msg);
|
|
670
|
+
},
|
|
671
|
+
error(msg) {
|
|
672
|
+
console.error(pc.red(" \u2717 ") + msg);
|
|
673
|
+
},
|
|
674
|
+
debug(msg) {
|
|
675
|
+
if (verbose) console.error(pc.dim(" \xB7 " + msg));
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/capture/playwright-driver.ts
|
|
680
|
+
var INPUT_TIMEOUT_MS = 5e3;
|
|
681
|
+
var MASK_INIT_SCRIPT = `
|
|
682
|
+
(() => {
|
|
683
|
+
if (window.__playheadMaskInstalled) return;
|
|
684
|
+
window.__playheadMaskInstalled = true;
|
|
685
|
+
// Each masked element gets its own overlay box parented into that element's own DOM subtree.
|
|
686
|
+
// This is what makes masking survive the top layer: an element inside a <dialog> modal (or a
|
|
687
|
+
// popover) paints above a document-level overlay, so the box must be a descendant of the same
|
|
688
|
+
// top-layer subtree to cover it. A position:fixed box stays out of flow, so parenting it into a
|
|
689
|
+
// flex/grid container does not disturb layout.
|
|
690
|
+
const boxes = new Map();
|
|
691
|
+
const base = (box) => {
|
|
692
|
+
box.setAttribute('aria-hidden', 'true');
|
|
693
|
+
box.style.position = 'fixed';
|
|
694
|
+
box.style.pointerEvents = 'none';
|
|
695
|
+
box.style.zIndex = '2147483647';
|
|
696
|
+
box.style.borderRadius = '5px';
|
|
697
|
+
box.style.margin = '0';
|
|
698
|
+
};
|
|
699
|
+
const sync = (box, el, style) => {
|
|
700
|
+
const r = el.getBoundingClientRect();
|
|
701
|
+
const pad = 3; // cover focus rings / borders just outside the element box
|
|
702
|
+
box.style.left = (r.left - pad) + 'px';
|
|
703
|
+
box.style.top = (r.top - pad) + 'px';
|
|
704
|
+
box.style.width = (r.width + pad * 2) + 'px';
|
|
705
|
+
box.style.height = (r.height + pad * 2) + 'px';
|
|
706
|
+
box.style.display = r.width > 0 && r.height > 0 ? 'block' : 'none';
|
|
707
|
+
if (style === 'blur') {
|
|
708
|
+
box.style.backdropFilter = 'blur(18px)';
|
|
709
|
+
box.style.webkitBackdropFilter = 'blur(18px)';
|
|
710
|
+
box.style.background = 'rgba(120,124,140,0.45)';
|
|
711
|
+
} else {
|
|
712
|
+
box.style.backdropFilter = '';
|
|
713
|
+
box.style.background = '#2a2f3a';
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
// In-page rule matching: CSS-expressible mask rules (testid=, css=) are pushed into
|
|
717
|
+
// window.__playheadMaskCssRules and re-scanned continuously, so an element that APPEARS
|
|
718
|
+
// MID-STEP (a token in a modal, an email in a toast, a row from the fetch settle waits on)
|
|
719
|
+
// is tagged the frame it appears \u2014 not seconds later at the next step boundary. Node-side
|
|
720
|
+
// tagging remains the backstop for rules needing Playwright semantics (role/label/text).
|
|
721
|
+
let scanTick = 0;
|
|
722
|
+
const scanRules = () => {
|
|
723
|
+
const rules = window.__playheadMaskCssRules || [];
|
|
724
|
+
for (const r of rules) {
|
|
725
|
+
try {
|
|
726
|
+
for (const el of document.querySelectorAll(r.css)) {
|
|
727
|
+
if (!el.hasAttribute('data-playhead-mask')) el.setAttribute('data-playhead-mask', r.style);
|
|
728
|
+
}
|
|
729
|
+
} catch (e) {}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
const tick = () => {
|
|
733
|
+
try {
|
|
734
|
+
if (scanTick++ % 3 === 0) scanRules(); // every ~3 frames \u2014 cheap, and a 1-frame leak beats a 1-step leak
|
|
735
|
+
const tagged = new Set(document.querySelectorAll('[data-playhead-mask]'));
|
|
736
|
+
for (const [el, box] of boxes) {
|
|
737
|
+
if (!tagged.has(el) || !el.isConnected) { box.remove(); boxes.delete(el); }
|
|
738
|
+
}
|
|
739
|
+
for (const el of tagged) {
|
|
740
|
+
const parent = el.parentElement || document.body;
|
|
741
|
+
let box = boxes.get(el);
|
|
742
|
+
if (!box) {
|
|
743
|
+
box = document.createElement('div');
|
|
744
|
+
base(box);
|
|
745
|
+
parent.appendChild(box);
|
|
746
|
+
boxes.set(el, box);
|
|
747
|
+
} else if (box.parentElement !== parent) {
|
|
748
|
+
parent.appendChild(box);
|
|
749
|
+
}
|
|
750
|
+
sync(box, el, el.getAttribute('data-playhead-mask'));
|
|
751
|
+
}
|
|
752
|
+
} catch (e) {}
|
|
753
|
+
requestAnimationFrame(tick);
|
|
754
|
+
};
|
|
755
|
+
requestAnimationFrame(tick);
|
|
756
|
+
})();
|
|
757
|
+
`;
|
|
758
|
+
var FALLBACK_NAME_FN = `(el) => {
|
|
759
|
+
const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
760
|
+
const label = el.getAttribute('aria-label');
|
|
761
|
+
if (label) return norm(label);
|
|
762
|
+
const labelledBy = el.getAttribute('aria-labelledby');
|
|
763
|
+
if (labelledBy) {
|
|
764
|
+
const parts = labelledBy.split(/\\s+/).map((id) => {
|
|
765
|
+
const ref = document.getElementById(id);
|
|
766
|
+
return ref ? ref.textContent : '';
|
|
767
|
+
});
|
|
768
|
+
const joined = norm(parts.join(' '));
|
|
769
|
+
if (joined) return joined;
|
|
770
|
+
}
|
|
771
|
+
if (el.labels && el.labels.length) return norm(el.labels[0].textContent);
|
|
772
|
+
if (el.alt) return norm(el.alt);
|
|
773
|
+
const text = norm(el.textContent);
|
|
774
|
+
if (text) return text.slice(0, 80);
|
|
775
|
+
if (el.placeholder) return norm(el.placeholder);
|
|
776
|
+
if (el.title) return norm(el.title);
|
|
777
|
+
return null;
|
|
778
|
+
}`;
|
|
779
|
+
var COLLECT_INTERACTABLES_FN = `() => {
|
|
780
|
+
const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
781
|
+
// Accessible text = visible text excluding aria-hidden / hidden subtrees, matching how the
|
|
782
|
+
// ARIA name is computed (so a decorative icon inside a link doesn't leak into the name).
|
|
783
|
+
const accText = (el) => {
|
|
784
|
+
let t = '';
|
|
785
|
+
const walk = (node) => {
|
|
786
|
+
if (node.nodeType === 3) { t += node.textContent; return; }
|
|
787
|
+
if (node.nodeType !== 1) return;
|
|
788
|
+
if (node.getAttribute && (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden'))) return;
|
|
789
|
+
for (const c of node.childNodes) walk(c);
|
|
790
|
+
};
|
|
791
|
+
walk(el);
|
|
792
|
+
return norm(t);
|
|
793
|
+
};
|
|
794
|
+
const IMPLICIT = { A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', SUMMARY: 'button' };
|
|
795
|
+
const inputRole = (el) => {
|
|
796
|
+
const t = (el.getAttribute('type') || 'text').toLowerCase();
|
|
797
|
+
if (t === 'checkbox') return 'checkbox';
|
|
798
|
+
if (t === 'radio') return 'radio';
|
|
799
|
+
if (t === 'range') return 'slider';
|
|
800
|
+
if (t === 'button' || t === 'submit' || t === 'reset') return 'button';
|
|
801
|
+
if (t === 'search') return 'searchbox';
|
|
802
|
+
return 'textbox';
|
|
803
|
+
};
|
|
804
|
+
const accName = (el) => {
|
|
805
|
+
const al = el.getAttribute('aria-label'); if (al) return norm(al);
|
|
806
|
+
const lb = el.getAttribute('aria-labelledby');
|
|
807
|
+
if (lb) { const parts = lb.split(/\\s+/).map((id) => { const r = document.getElementById(id); return r ? r.textContent : ''; }); const j = norm(parts.join(' ')); if (j) return j; }
|
|
808
|
+
if (el.labels && el.labels.length) return accText(el.labels[0]);
|
|
809
|
+
if (el.alt) return norm(el.alt);
|
|
810
|
+
// NEVER walk a <select>'s subtree: that concatenates every <option> into a monster
|
|
811
|
+
// "Choose option...grayrednavy\u2026" pseudo-name that no human would write as a locator.
|
|
812
|
+
if (el.tagName === 'SELECT' || el.tagName === 'DATALIST') {
|
|
813
|
+
const sel = el.selectedOptions && el.selectedOptions[0];
|
|
814
|
+
return sel ? norm(sel.textContent) : null;
|
|
815
|
+
}
|
|
816
|
+
const txt = accText(el); if (txt) return txt.slice(0, 80);
|
|
817
|
+
if (el.placeholder) return norm(el.placeholder);
|
|
818
|
+
if (el.title) return norm(el.title);
|
|
819
|
+
return null;
|
|
820
|
+
};
|
|
821
|
+
// Landmark region for catalog grouping: which part of the page the element lives in.
|
|
822
|
+
const regionOf = (el) => {
|
|
823
|
+
const lm = el.closest('[role=dialog], dialog, nav, [role=navigation], header, [role=banner], aside, [role=complementary], footer, [role=contentinfo], main, [role=main]');
|
|
824
|
+
if (!lm) return 'page';
|
|
825
|
+
const tag = lm.tagName.toLowerCase();
|
|
826
|
+
const role = lm.getAttribute && lm.getAttribute('role');
|
|
827
|
+
if (tag === 'dialog' || role === 'dialog') return 'dialog';
|
|
828
|
+
if (tag === 'nav' || role === 'navigation') return 'nav';
|
|
829
|
+
if (tag === 'header' || role === 'banner') return 'header';
|
|
830
|
+
if (tag === 'aside' || role === 'complementary') return 'sidebar';
|
|
831
|
+
if (tag === 'footer' || role === 'contentinfo') return 'footer';
|
|
832
|
+
return 'main';
|
|
833
|
+
};
|
|
834
|
+
const sel = 'button, a[href], input, select, textarea, summary, [role=button], [role=link], [role=tab], [role=switch], [role=menuitem], [role=checkbox], [contenteditable=""], [contenteditable=true], h1, h2, h3';
|
|
835
|
+
const seen = new Set();
|
|
836
|
+
const out = [];
|
|
837
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
838
|
+
if (seen.has(el)) continue; seen.add(el);
|
|
839
|
+
const r = el.getBoundingClientRect();
|
|
840
|
+
const style = getComputedStyle(el);
|
|
841
|
+
const visible = r.width > 1 && r.height > 1 && style.visibility !== 'hidden' && style.display !== 'none' &&
|
|
842
|
+
r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
|
|
843
|
+
const tag = el.tagName;
|
|
844
|
+
const explicit = el.getAttribute('role');
|
|
845
|
+
const role = explicit || IMPLICIT[tag] || (tag === 'INPUT' ? inputRole(el) : (tag[0] === 'H' && tag.length === 2 ? 'heading' : null));
|
|
846
|
+
let label = null;
|
|
847
|
+
if (el.labels && el.labels.length) label = norm(el.labels[0].textContent);
|
|
848
|
+
const options = tag === 'SELECT' ? Array.from(el.options).map((o) => norm(o.textContent)) : undefined;
|
|
849
|
+
out.push({
|
|
850
|
+
tag, role,
|
|
851
|
+
accessibleName: accName(el),
|
|
852
|
+
label,
|
|
853
|
+
placeholder: el.getAttribute && el.getAttribute('placeholder') ? norm(el.getAttribute('placeholder')) : null,
|
|
854
|
+
testId: el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-test-id')) || null,
|
|
855
|
+
text: accText(el).slice(0, 80) || null,
|
|
856
|
+
bbox: { x: r.x, y: r.y, w: r.width, h: r.height },
|
|
857
|
+
visible,
|
|
858
|
+
inputType: tag === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : (tag === 'SELECT' ? 'select' : null),
|
|
859
|
+
region: regionOf(el),
|
|
860
|
+
...(options ? { options } : {}),
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}`;
|
|
865
|
+
var PlaywrightDriver = class _PlaywrightDriver {
|
|
866
|
+
browser;
|
|
867
|
+
context;
|
|
868
|
+
page;
|
|
869
|
+
cdp;
|
|
870
|
+
/** Dedicated CDP session for the capture loop so its screenshots never contend with the
|
|
871
|
+
* executor's accessibility/geometry lookups on the main session (which could deadlock). */
|
|
872
|
+
captureCdp;
|
|
873
|
+
cursor = { x: 0, y: 0 };
|
|
874
|
+
navCallbacks = [];
|
|
875
|
+
captureActive = false;
|
|
876
|
+
captureLoop = null;
|
|
877
|
+
captureOnFrame = null;
|
|
878
|
+
captureOpts = null;
|
|
879
|
+
inflightGrab = null;
|
|
880
|
+
lastFrameNodeMs = 0;
|
|
881
|
+
clockOffset = 0;
|
|
882
|
+
tagCounter = 0;
|
|
883
|
+
typingMaskLoc = null;
|
|
884
|
+
async launch(opts) {
|
|
885
|
+
const env = opts.environment;
|
|
886
|
+
this.browser = await chromium.launch({ headless: opts.headless ?? true });
|
|
887
|
+
this.context = await this.browser.newContext({
|
|
888
|
+
viewport: { width: opts.viewport.w, height: opts.viewport.h },
|
|
889
|
+
deviceScaleFactor: opts.dpr,
|
|
890
|
+
colorScheme: env?.colorScheme ?? "light",
|
|
891
|
+
...env?.timezone ? { timezoneId: env.timezone } : {},
|
|
892
|
+
...env?.locale ? { locale: env.locale } : {},
|
|
893
|
+
...env?.reducedMotion ? { reducedMotion: "reduce" } : {},
|
|
894
|
+
...opts.storageStatePath ? { storageState: opts.storageStatePath } : {}
|
|
895
|
+
});
|
|
896
|
+
this.context.setDefaultTimeout(1e4);
|
|
897
|
+
this.context.setDefaultNavigationTimeout(2e4);
|
|
898
|
+
const net = opts.network;
|
|
899
|
+
if (net?.har) await this.context.routeFromHAR(net.har, { notFound: "fallback" });
|
|
900
|
+
for (const glob of net?.block ?? []) {
|
|
901
|
+
await this.context.route(glob, (route) => route.fulfill({ status: 204, body: "" }));
|
|
902
|
+
}
|
|
903
|
+
for (const stub of net?.stub ?? []) {
|
|
904
|
+
await this.context.route(
|
|
905
|
+
stub.url,
|
|
906
|
+
(route) => route.fulfill({
|
|
907
|
+
status: stub.status ?? 200,
|
|
908
|
+
body: stub.body ?? "",
|
|
909
|
+
contentType: stub.contentType ?? "application/json"
|
|
910
|
+
})
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
this.page = await this.context.newPage();
|
|
914
|
+
if (env?.fixedTime) await this.page.clock.setFixedTime(new Date(env.fixedTime));
|
|
915
|
+
await this.page.addInitScript(MASK_INIT_SCRIPT);
|
|
916
|
+
this.cdp = await this.context.newCDPSession(this.page);
|
|
917
|
+
this.captureCdp = await this.context.newCDPSession(this.page);
|
|
918
|
+
this.page.on("framenavigated", (frame) => {
|
|
919
|
+
if (frame === this.page.mainFrame()) {
|
|
920
|
+
const t = Date.now();
|
|
921
|
+
for (const cb of this.navCallbacks) cb(frame.url(), t);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
this.page.on("console", (msg) => this.pushConsole(msg.type(), msg.text()));
|
|
925
|
+
this.page.on("pageerror", (err) => this.pushConsole("pageerror", err.message));
|
|
926
|
+
this.page.on("crash", () => this.pushConsole("crash", "page crashed"));
|
|
927
|
+
const dialogPolicy = opts.dialogs ?? "accept";
|
|
928
|
+
this.page.on("dialog", (dialog) => {
|
|
929
|
+
this.pushConsole("dialog", `${dialog.type()}("${dialog.message()}") \u2192 ${dialogPolicy}`);
|
|
930
|
+
void (dialogPolicy === "accept" ? dialog.accept() : dialog.dismiss()).catch(() => {
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
this.context.on("page", (p) => {
|
|
934
|
+
if (p !== this.page) {
|
|
935
|
+
this.pushConsole("popup", `new tab/popup opened: ${p.url()} (not filmed \u2014 capture follows the main page)`);
|
|
936
|
+
log.warn(`popup opened (${p.url()}) \u2014 multi-tab capture is not yet supported; footage stays on the main page`);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
this.cursor = { x: opts.viewport.w / 2, y: opts.viewport.h * 0.6 };
|
|
940
|
+
await this.page.mouse.move(this.cursor.x, this.cursor.y);
|
|
941
|
+
}
|
|
942
|
+
consoleBuf = [];
|
|
943
|
+
pushConsole(type, text) {
|
|
944
|
+
this.consoleBuf.push({ t: Date.now(), type, text: text.slice(0, 500) });
|
|
945
|
+
if (this.consoleBuf.length > 500) this.consoleBuf.shift();
|
|
946
|
+
}
|
|
947
|
+
consoleLog() {
|
|
948
|
+
return [...this.consoleBuf];
|
|
949
|
+
}
|
|
950
|
+
async close() {
|
|
951
|
+
if (!this.browser) return;
|
|
952
|
+
await withTimeout(this.browser.close(), 8e3).catch(() => {
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
async measureClockOffset() {
|
|
956
|
+
const samples = [];
|
|
957
|
+
for (let i = 0; i < 5; i++) {
|
|
958
|
+
const t0 = Date.now();
|
|
959
|
+
const browserNow = await withTimeout(this.page.evaluate(() => Date.now()), 2e3).catch(() => t0);
|
|
960
|
+
const t1 = Date.now();
|
|
961
|
+
samples.push(browserNow - (t0 + t1) / 2);
|
|
962
|
+
}
|
|
963
|
+
samples.sort((a, b) => a - b);
|
|
964
|
+
this.clockOffset = samples[2];
|
|
965
|
+
return this.clockOffset;
|
|
966
|
+
}
|
|
967
|
+
async goto(url) {
|
|
968
|
+
await this.page.goto(url, { waitUntil: "load" });
|
|
969
|
+
await withTimeout(
|
|
970
|
+
this.page.evaluate(
|
|
971
|
+
() => Promise.race([document.fonts?.ready, new Promise((r) => setTimeout(r, 2e3))])
|
|
972
|
+
),
|
|
973
|
+
3e3
|
|
974
|
+
).catch(() => {
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
onNavigation(cb) {
|
|
978
|
+
this.navCallbacks.push(cb);
|
|
979
|
+
}
|
|
980
|
+
currentUrl() {
|
|
981
|
+
return this.page.url();
|
|
982
|
+
}
|
|
983
|
+
async currentTitle() {
|
|
984
|
+
return this.page.title().catch(() => "");
|
|
985
|
+
}
|
|
986
|
+
async ariaSnapshot() {
|
|
987
|
+
return this.page.locator("body").ariaSnapshot().catch(() => "");
|
|
988
|
+
}
|
|
989
|
+
async countMatches(loc) {
|
|
990
|
+
try {
|
|
991
|
+
return await this.toLocator(loc).count();
|
|
992
|
+
} catch {
|
|
993
|
+
return 0;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async collectInteractables() {
|
|
997
|
+
const collect = new Function(`return (${COLLECT_INTERACTABLES_FN})()`);
|
|
998
|
+
const out = await withTimeout(this.page.evaluate(collect), 1e4).catch(() => []);
|
|
999
|
+
const handles = await this.page.$$("iframe").catch(() => []);
|
|
1000
|
+
for (const h of handles) {
|
|
1001
|
+
try {
|
|
1002
|
+
const sel = await h.evaluate((el) => {
|
|
1003
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
1004
|
+
const name = el.getAttribute("name");
|
|
1005
|
+
if (name) return `iframe[name="${name}"]`;
|
|
1006
|
+
const tid = el.getAttribute("data-testid");
|
|
1007
|
+
if (tid) return `iframe[data-testid="${tid}"]`;
|
|
1008
|
+
return null;
|
|
1009
|
+
});
|
|
1010
|
+
if (!sel) continue;
|
|
1011
|
+
const frame = await h.contentFrame();
|
|
1012
|
+
if (!frame) continue;
|
|
1013
|
+
const rect = await h.boundingBox();
|
|
1014
|
+
const inner = await withTimeout(
|
|
1015
|
+
frame.evaluate(collect),
|
|
1016
|
+
8e3
|
|
1017
|
+
).catch(() => []);
|
|
1018
|
+
for (const e of inner) {
|
|
1019
|
+
out.push({
|
|
1020
|
+
...e,
|
|
1021
|
+
framePrefix: `frame=${sel}`,
|
|
1022
|
+
bbox: rect ? { x: e.bbox.x + rect.x, y: e.bbox.y + rect.y, w: e.bbox.w, h: e.bbox.h } : e.bbox
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return out;
|
|
1029
|
+
}
|
|
1030
|
+
// ---------- locating & measuring ----------
|
|
1031
|
+
toLocator(loc) {
|
|
1032
|
+
let scope = this.page;
|
|
1033
|
+
for (const sel of loc.frames ?? []) scope = scope.frameLocator(sel);
|
|
1034
|
+
const b = loc.base;
|
|
1035
|
+
let l;
|
|
1036
|
+
switch (b.kind) {
|
|
1037
|
+
case "role":
|
|
1038
|
+
l = scope.getByRole(b.role, b.name ? { name: toPw(b.name), exact: isExact(b.name) } : {});
|
|
1039
|
+
break;
|
|
1040
|
+
case "label":
|
|
1041
|
+
l = scope.getByLabel(toPw(b.match), { exact: isExact(b.match) });
|
|
1042
|
+
break;
|
|
1043
|
+
case "text":
|
|
1044
|
+
l = scope.getByText(toPw(b.match), { exact: isExact(b.match) });
|
|
1045
|
+
break;
|
|
1046
|
+
case "placeholder":
|
|
1047
|
+
l = scope.getByPlaceholder(toPw(b.match), { exact: isExact(b.match) });
|
|
1048
|
+
break;
|
|
1049
|
+
case "testid":
|
|
1050
|
+
l = scope.getByTestId(b.value);
|
|
1051
|
+
break;
|
|
1052
|
+
case "css":
|
|
1053
|
+
l = scope.locator(b.selector);
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
if (loc.nth !== void 0) l = l.nth(loc.nth);
|
|
1057
|
+
return l;
|
|
1058
|
+
}
|
|
1059
|
+
async resolveTarget(loc, timeoutMs) {
|
|
1060
|
+
const locator = this.toLocator(loc);
|
|
1061
|
+
try {
|
|
1062
|
+
await locator.waitFor({ state: "visible", timeout: timeoutMs });
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
const msg = e.message ?? "";
|
|
1065
|
+
if (/strict mode violation/i.test(msg)) {
|
|
1066
|
+
const count2 = await locator.count().catch(() => -1);
|
|
1067
|
+
throw new Error(
|
|
1068
|
+
`Locator is ambiguous: ${loc.raw} matches ${count2 >= 0 ? count2 : "multiple"} elements \u2014 add '>> nth=N' to pick one`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
const count = await this.countMatches(loc);
|
|
1072
|
+
const reason = count === 0 ? "no matching element on the page" : `${count} match(es) present but none became visible`;
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
`Target not resolvable: ${loc.raw} \u2014 ${reason} within ${timeoutMs}ms on ${this.page.url()} (${firstLine(msg)})`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
await locator.scrollIntoViewIfNeeded().catch(() => {
|
|
1078
|
+
});
|
|
1079
|
+
const deadline = Date.now() + timeoutMs;
|
|
1080
|
+
let prev = null;
|
|
1081
|
+
while (Date.now() < deadline) {
|
|
1082
|
+
const box = await locator.boundingBox();
|
|
1083
|
+
const sig = box ? `${box.x},${box.y},${box.width},${box.height}` : null;
|
|
1084
|
+
if (sig && sig === prev) break;
|
|
1085
|
+
prev = sig;
|
|
1086
|
+
await this.nextFrame();
|
|
1087
|
+
}
|
|
1088
|
+
const info = await this.measure(locator, loc);
|
|
1089
|
+
if (!info) throw new Error(`Target disappeared while measuring: ${loc.raw}`);
|
|
1090
|
+
return info;
|
|
1091
|
+
}
|
|
1092
|
+
/** Wait for the page to paint one frame, bounded — a concurrent screenshot must never let this hang. */
|
|
1093
|
+
async nextFrame() {
|
|
1094
|
+
await withTimeout(
|
|
1095
|
+
this.page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))),
|
|
1096
|
+
1500
|
|
1097
|
+
).catch(() => {
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
async tryMeasure(loc) {
|
|
1101
|
+
const locator = this.toLocator(loc);
|
|
1102
|
+
try {
|
|
1103
|
+
if (!await locator.isVisible()) return null;
|
|
1104
|
+
return await this.measure(locator, loc);
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async measure(locator, loc) {
|
|
1110
|
+
const tag = `ph-${++this.tagCounter}`;
|
|
1111
|
+
const geom = await locator.evaluate((el, t) => {
|
|
1112
|
+
el.setAttribute("data-playhead-t", t);
|
|
1113
|
+
const r = el.getBoundingClientRect();
|
|
1114
|
+
return {
|
|
1115
|
+
bbox: { x: r.x, y: r.y, w: r.width, h: r.height },
|
|
1116
|
+
scroll: { x: window.scrollX, y: window.scrollY }
|
|
1117
|
+
};
|
|
1118
|
+
}, tag).catch(() => null);
|
|
1119
|
+
if (!geom) return null;
|
|
1120
|
+
if (loc.frames && loc.frames.length > 0) {
|
|
1121
|
+
const box = await withTimeout(locator.boundingBox(), 3e3).catch(() => null);
|
|
1122
|
+
if (box) geom.bbox = { x: box.x, y: box.y, w: box.width, h: box.height };
|
|
1123
|
+
}
|
|
1124
|
+
let role = null;
|
|
1125
|
+
let name = null;
|
|
1126
|
+
try {
|
|
1127
|
+
const evalRes = await withTimeout(
|
|
1128
|
+
this.cdp.send("Runtime.evaluate", {
|
|
1129
|
+
expression: `document.querySelector('[data-playhead-t="${tag}"]')`
|
|
1130
|
+
}),
|
|
1131
|
+
4e3
|
|
1132
|
+
);
|
|
1133
|
+
if (evalRes.result.objectId) {
|
|
1134
|
+
const desc = await withTimeout(
|
|
1135
|
+
this.cdp.send("DOM.describeNode", { objectId: evalRes.result.objectId }),
|
|
1136
|
+
4e3
|
|
1137
|
+
);
|
|
1138
|
+
const ax = await withTimeout(
|
|
1139
|
+
this.cdp.send("Accessibility.getPartialAXTree", {
|
|
1140
|
+
backendNodeId: desc.node.backendNodeId,
|
|
1141
|
+
fetchRelatives: false
|
|
1142
|
+
}),
|
|
1143
|
+
4e3
|
|
1144
|
+
);
|
|
1145
|
+
const node = ax.nodes?.[0];
|
|
1146
|
+
if (node && !node.ignored) {
|
|
1147
|
+
role = node.role?.value ?? null;
|
|
1148
|
+
name = node.name?.value ?? null;
|
|
1149
|
+
}
|
|
1150
|
+
await this.cdp.send("Runtime.releaseObject", { objectId: evalRes.result.objectId }).catch(() => {
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
log.debug(`AX lookup failed for ${loc.raw}: ${e.message}`);
|
|
1155
|
+
}
|
|
1156
|
+
if (!name) {
|
|
1157
|
+
name = await locator.evaluate(new Function("el", `return (${FALLBACK_NAME_FN})(el)`)).catch(() => null);
|
|
1158
|
+
}
|
|
1159
|
+
await locator.evaluate((el) => el.removeAttribute("data-playhead-t")).catch(() => {
|
|
1160
|
+
});
|
|
1161
|
+
return { role, accessibleName: name, bbox: geom.bbox, scroll: geom.scroll };
|
|
1162
|
+
}
|
|
1163
|
+
// ---------- input ----------
|
|
1164
|
+
cursorPos() {
|
|
1165
|
+
return { ...this.cursor };
|
|
1166
|
+
}
|
|
1167
|
+
async moveCursor(to, durationMs) {
|
|
1168
|
+
const from = { ...this.cursor };
|
|
1169
|
+
const waypoints = [{ x: from.x, y: from.y, t: Date.now() }];
|
|
1170
|
+
const stepMs = 12;
|
|
1171
|
+
const steps = Math.max(2, Math.round(durationMs / stepMs));
|
|
1172
|
+
for (let i = 1; i <= steps; i++) {
|
|
1173
|
+
const p = minJerk(i / steps);
|
|
1174
|
+
const x = from.x + (to.x - from.x) * p;
|
|
1175
|
+
const y = from.y + (to.y - from.y) * p;
|
|
1176
|
+
await withTimeout(this.page.mouse.move(x, y), INPUT_TIMEOUT_MS);
|
|
1177
|
+
waypoints.push({ x, y, t: Date.now() });
|
|
1178
|
+
if (i < steps) await sleep(stepMs);
|
|
1179
|
+
}
|
|
1180
|
+
this.cursor = { ...to };
|
|
1181
|
+
return waypoints;
|
|
1182
|
+
}
|
|
1183
|
+
async clickAt(p, opts) {
|
|
1184
|
+
await withTimeout(this.page.mouse.move(p.x, p.y), INPUT_TIMEOUT_MS);
|
|
1185
|
+
this.cursor = { ...p };
|
|
1186
|
+
const tDown = Date.now();
|
|
1187
|
+
await withTimeout(this.page.mouse.down(), INPUT_TIMEOUT_MS);
|
|
1188
|
+
await sleep(70);
|
|
1189
|
+
await withTimeout(this.page.mouse.up(), INPUT_TIMEOUT_MS);
|
|
1190
|
+
if (opts?.double) {
|
|
1191
|
+
await sleep(60);
|
|
1192
|
+
await withTimeout(this.page.mouse.down({ clickCount: 2 }), INPUT_TIMEOUT_MS);
|
|
1193
|
+
await sleep(60);
|
|
1194
|
+
await withTimeout(this.page.mouse.up({ clickCount: 2 }), INPUT_TIMEOUT_MS);
|
|
1195
|
+
}
|
|
1196
|
+
return { tDown, tUp: Date.now() };
|
|
1197
|
+
}
|
|
1198
|
+
async typeText(text, delayMs) {
|
|
1199
|
+
const tFirst = Date.now();
|
|
1200
|
+
await withTimeout(this.page.keyboard.type(text, { delay: delayMs }), 1e4 + text.length * delayMs * 2);
|
|
1201
|
+
return { tFirst, tLast: Date.now() };
|
|
1202
|
+
}
|
|
1203
|
+
async press(keys) {
|
|
1204
|
+
const tDown = Date.now();
|
|
1205
|
+
await withTimeout(this.page.keyboard.press(keys), INPUT_TIMEOUT_MS);
|
|
1206
|
+
return { tDown };
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* The REAL click: Playwright's locator.click with actionability + hit-target verification and
|
|
1210
|
+
* retry. The cosmetic cursor has already animated to the target; this dispatches the action
|
|
1211
|
+
* through the checks a raw coordinate click bypasses (a toast/sticky header drifting under
|
|
1212
|
+
* the point between measure and click silently redirects a coordinate click).
|
|
1213
|
+
*/
|
|
1214
|
+
async actClick(loc, opts) {
|
|
1215
|
+
const locator = this.toLocator(loc);
|
|
1216
|
+
const tDown = Date.now();
|
|
1217
|
+
if (opts?.double) await locator.dblclick({ delay: 60, timeout: opts?.timeoutMs ?? 1e4 });
|
|
1218
|
+
else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4 });
|
|
1219
|
+
return { tDown, tUp: Date.now() };
|
|
1220
|
+
}
|
|
1221
|
+
async selectOption(loc, value) {
|
|
1222
|
+
const locator = this.toLocator(loc);
|
|
1223
|
+
try {
|
|
1224
|
+
await locator.selectOption({ label: value });
|
|
1225
|
+
} catch {
|
|
1226
|
+
await locator.selectOption(value);
|
|
1227
|
+
}
|
|
1228
|
+
await withTimeout(this.page.keyboard.press("Escape"), INPUT_TIMEOUT_MS).catch(() => {
|
|
1229
|
+
});
|
|
1230
|
+
await withTimeout(
|
|
1231
|
+
locator.evaluate((el) => el.blur?.()),
|
|
1232
|
+
2e3
|
|
1233
|
+
).catch(() => {
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
async scrollIntoView(loc) {
|
|
1237
|
+
const locator = this.toLocator(loc);
|
|
1238
|
+
await withTimeout(
|
|
1239
|
+
locator.evaluate((el) => el.scrollIntoView({ behavior: "smooth", block: "center" })),
|
|
1240
|
+
5e3
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
async scrollBy(by) {
|
|
1244
|
+
await withTimeout(
|
|
1245
|
+
this.page.evaluate(({ x, y }) => window.scrollBy({ left: x, top: y, behavior: "smooth" }), by),
|
|
1246
|
+
5e3
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
async waitForScrollSettle(timeoutMs) {
|
|
1250
|
+
const deadline = Date.now() + timeoutMs;
|
|
1251
|
+
let prev = Number.NaN;
|
|
1252
|
+
let stable = 0;
|
|
1253
|
+
while (Date.now() < deadline) {
|
|
1254
|
+
const y = await withTimeout(this.page.evaluate(() => window.scrollY), 1500).catch(() => prev);
|
|
1255
|
+
if (y === prev) {
|
|
1256
|
+
if (++stable >= 2) return;
|
|
1257
|
+
} else {
|
|
1258
|
+
stable = 0;
|
|
1259
|
+
prev = y;
|
|
1260
|
+
}
|
|
1261
|
+
await sleep(60);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
async expectState(loc, opts, timeoutMs) {
|
|
1265
|
+
const locator = this.toLocator(loc);
|
|
1266
|
+
const deadline = Date.now() + timeoutMs;
|
|
1267
|
+
let lastErr = "condition not met";
|
|
1268
|
+
while (Date.now() < deadline) {
|
|
1269
|
+
try {
|
|
1270
|
+
if (opts.count !== void 0) {
|
|
1271
|
+
const n = await locator.count();
|
|
1272
|
+
if (n === opts.count) return;
|
|
1273
|
+
lastErr = `expected ${opts.count} match(es), found ${n}`;
|
|
1274
|
+
await sleep(100);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const visible = await locator.first().isVisible();
|
|
1278
|
+
if (opts.visible !== void 0 && visible !== opts.visible) {
|
|
1279
|
+
lastErr = `expected visible=${opts.visible}, got ${visible}`;
|
|
1280
|
+
} else if (opts.text !== void 0) {
|
|
1281
|
+
const content = visible ? await locator.first().innerText() : "";
|
|
1282
|
+
if (!content.includes(opts.text)) {
|
|
1283
|
+
lastErr = `expected text to include "${opts.text}"`;
|
|
1284
|
+
} else {
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
} else {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
lastErr = e.message;
|
|
1292
|
+
}
|
|
1293
|
+
await sleep(100);
|
|
1294
|
+
}
|
|
1295
|
+
throw new Error(`expect failed for ${loc.raw}: ${lastErr}`);
|
|
1296
|
+
}
|
|
1297
|
+
// ---------- settling ----------
|
|
1298
|
+
/** Open requests → their start time. Long-lived streams (HMR sockets, SSE, long-polls) stop
|
|
1299
|
+
* counting after LONG_REQUEST_MS so a dev server can't pin the settle at its cap forever. */
|
|
1300
|
+
pending = /* @__PURE__ */ new Map();
|
|
1301
|
+
lastNetActivity = 0;
|
|
1302
|
+
netTrackingInstalled = false;
|
|
1303
|
+
installNetTracking() {
|
|
1304
|
+
if (this.netTrackingInstalled) return;
|
|
1305
|
+
this.netTrackingInstalled = true;
|
|
1306
|
+
this.lastNetActivity = Date.now();
|
|
1307
|
+
const IGNORED_TYPES = /* @__PURE__ */ new Set(["eventsource", "websocket", "ping"]);
|
|
1308
|
+
this.page.on("request", (req) => {
|
|
1309
|
+
if (IGNORED_TYPES.has(req.resourceType())) return;
|
|
1310
|
+
this.pending.set(req, Date.now());
|
|
1311
|
+
this.lastNetActivity = Date.now();
|
|
1312
|
+
});
|
|
1313
|
+
const done = (req) => {
|
|
1314
|
+
if (this.pending.delete(req)) this.lastNetActivity = Date.now();
|
|
1315
|
+
};
|
|
1316
|
+
this.page.on("requestfinished", done);
|
|
1317
|
+
this.page.on("requestfailed", done);
|
|
1318
|
+
}
|
|
1319
|
+
static LONG_REQUEST_MS = 2e3;
|
|
1320
|
+
async settle(opts) {
|
|
1321
|
+
this.installNetTracking();
|
|
1322
|
+
const start = Date.now();
|
|
1323
|
+
let capped = true;
|
|
1324
|
+
while (Date.now() - start < opts.capMs) {
|
|
1325
|
+
const now = Date.now();
|
|
1326
|
+
let inflight = 0;
|
|
1327
|
+
for (const t of this.pending.values()) {
|
|
1328
|
+
if (now - t < _PlaywrightDriver.LONG_REQUEST_MS) inflight += 1;
|
|
1329
|
+
}
|
|
1330
|
+
if (inflight === 0 && now - this.lastNetActivity >= opts.idleMs) {
|
|
1331
|
+
capped = false;
|
|
1332
|
+
break;
|
|
1333
|
+
}
|
|
1334
|
+
await sleep(30);
|
|
1335
|
+
}
|
|
1336
|
+
if (capped) log.debug(`settle hit its ${opts.capMs}ms cap \u2014 footage may include in-flight loading`);
|
|
1337
|
+
await withTimeout(
|
|
1338
|
+
this.page.evaluate(
|
|
1339
|
+
() => new Promise((resolve) => {
|
|
1340
|
+
const done = () => {
|
|
1341
|
+
obs.disconnect();
|
|
1342
|
+
resolve();
|
|
1343
|
+
};
|
|
1344
|
+
let timer;
|
|
1345
|
+
const arm = () => {
|
|
1346
|
+
clearTimeout(timer);
|
|
1347
|
+
timer = setTimeout(() => {
|
|
1348
|
+
const running = (document.getAnimations?.() ?? []).filter((a) => {
|
|
1349
|
+
const t = a.effect?.getTiming?.();
|
|
1350
|
+
return a.playState === "running" && t && t.iterations !== Infinity;
|
|
1351
|
+
});
|
|
1352
|
+
if (running.length === 0) done();
|
|
1353
|
+
else arm();
|
|
1354
|
+
}, 250);
|
|
1355
|
+
};
|
|
1356
|
+
const obs = new MutationObserver(() => arm());
|
|
1357
|
+
obs.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
1358
|
+
arm();
|
|
1359
|
+
setTimeout(done, 1500);
|
|
1360
|
+
})
|
|
1361
|
+
),
|
|
1362
|
+
1800
|
|
1363
|
+
).catch(() => {
|
|
1364
|
+
});
|
|
1365
|
+
await this.nextFrame();
|
|
1366
|
+
}
|
|
1367
|
+
async saveStorageState(path) {
|
|
1368
|
+
await this.context.storageState({ path });
|
|
1369
|
+
}
|
|
1370
|
+
// ---------- masking ----------
|
|
1371
|
+
/**
|
|
1372
|
+
* Install CSS-expressible mask rules for continuous in-page tagging (see MASK_INIT_SCRIPT).
|
|
1373
|
+
* Rules that can't be expressed as CSS (role/label/text) return false and stay Node-tagged.
|
|
1374
|
+
*/
|
|
1375
|
+
async installMaskRules(rules) {
|
|
1376
|
+
const cssRules = rules.map((r) => ({ css: maskRuleToCss(r.loc), style: r.style })).filter((r) => r.css !== null);
|
|
1377
|
+
if (cssRules.length === 0) return;
|
|
1378
|
+
const payload = JSON.stringify(cssRules);
|
|
1379
|
+
await this.page.addInitScript(`window.__playheadMaskCssRules = ${payload};`);
|
|
1380
|
+
await withTimeout(
|
|
1381
|
+
this.page.evaluate((json) => window.__playheadMaskCssRules = JSON.parse(json), payload),
|
|
1382
|
+
3e3
|
|
1383
|
+
).catch(() => {
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
async applyMasks(rules) {
|
|
1387
|
+
const tagged = [];
|
|
1388
|
+
for (const rule of rules) {
|
|
1389
|
+
const locator = this.toLocator(rule.loc);
|
|
1390
|
+
const all = await locator.all().catch(() => []);
|
|
1391
|
+
for (const el of all) {
|
|
1392
|
+
const box = await el.evaluate((node, style) => {
|
|
1393
|
+
node.setAttribute("data-playhead-mask", style);
|
|
1394
|
+
const r = node.getBoundingClientRect();
|
|
1395
|
+
return { x: r.x, y: r.y, w: r.width, h: r.height };
|
|
1396
|
+
}, rule.style).catch(() => null);
|
|
1397
|
+
if (box && box.w > 0 && box.h > 0) tagged.push({ style: rule.style, ...box });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return tagged;
|
|
1401
|
+
}
|
|
1402
|
+
async setTypingMask(loc) {
|
|
1403
|
+
if (loc) {
|
|
1404
|
+
this.typingMaskLoc = loc;
|
|
1405
|
+
const locator = this.toLocator(loc);
|
|
1406
|
+
await locator.evaluate((el) => el.setAttribute("data-playhead-mask", "solid")).catch(() => {
|
|
1407
|
+
});
|
|
1408
|
+
} else if (this.typingMaskLoc) {
|
|
1409
|
+
const locator = this.toLocator(this.typingMaskLoc);
|
|
1410
|
+
await locator.evaluate((el) => el.removeAttribute("data-playhead-mask")).catch(() => {
|
|
1411
|
+
});
|
|
1412
|
+
this.typingMaskLoc = null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// ---------- frame capture ----------
|
|
1416
|
+
//
|
|
1417
|
+
// Headless Chromium's CDP screencast is hard-locked to CSS-pixel resolution and ignores
|
|
1418
|
+
// deviceScaleFactor, so a paced Page.captureScreenshot loop with clip.scale is what actually
|
|
1419
|
+
// yields 2x frames (the zoom headroom the camera planner needs).
|
|
1420
|
+
async startCapture(onFrame, opts) {
|
|
1421
|
+
this.captureOnFrame = onFrame;
|
|
1422
|
+
this.captureOpts = opts;
|
|
1423
|
+
this.captureActive = true;
|
|
1424
|
+
const intervalMs = 1e3 / opts.fps;
|
|
1425
|
+
this.captureLoop = (async () => {
|
|
1426
|
+
while (this.captureActive) {
|
|
1427
|
+
const started = Date.now();
|
|
1428
|
+
await this.grabFrame();
|
|
1429
|
+
const elapsed = Date.now() - started;
|
|
1430
|
+
if (elapsed < intervalMs) await sleep(intervalMs - elapsed);
|
|
1431
|
+
}
|
|
1432
|
+
})();
|
|
1433
|
+
}
|
|
1434
|
+
/** Grab a frame, coalescing on any in-flight grab (so callers can await the current one). */
|
|
1435
|
+
grabFrame() {
|
|
1436
|
+
if (this.inflightGrab) return this.inflightGrab;
|
|
1437
|
+
this.inflightGrab = this.doGrab().finally(() => {
|
|
1438
|
+
this.inflightGrab = null;
|
|
1439
|
+
});
|
|
1440
|
+
return this.inflightGrab;
|
|
1441
|
+
}
|
|
1442
|
+
grabFailures = 0;
|
|
1443
|
+
async doGrab() {
|
|
1444
|
+
if (!this.captureOpts || !this.captureOnFrame) return;
|
|
1445
|
+
try {
|
|
1446
|
+
const opts = this.captureOpts;
|
|
1447
|
+
const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
|
|
1448
|
+
const scroll = await this.readScroll();
|
|
1449
|
+
const shot = await withTimeout(
|
|
1450
|
+
this.captureCdp.send("Page.captureScreenshot", {
|
|
1451
|
+
format: opts.format,
|
|
1452
|
+
quality: opts.quality,
|
|
1453
|
+
clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale },
|
|
1454
|
+
captureBeyondViewport: false
|
|
1455
|
+
}),
|
|
1456
|
+
1500
|
|
1457
|
+
);
|
|
1458
|
+
const tNodeMs = Date.now();
|
|
1459
|
+
this.lastFrameNodeMs = tNodeMs;
|
|
1460
|
+
this.grabFailures = 0;
|
|
1461
|
+
this.captureOnFrame({ data: Buffer.from(shot.data, "base64"), tNodeMs });
|
|
1462
|
+
} catch {
|
|
1463
|
+
this.grabFailures += 1;
|
|
1464
|
+
if (this.grabFailures >= 2) {
|
|
1465
|
+
try {
|
|
1466
|
+
await this.captureCdp.detach().catch(() => {
|
|
1467
|
+
});
|
|
1468
|
+
this.captureCdp = await this.context.newCDPSession(this.page);
|
|
1469
|
+
log.warn("capture: recreated CDP session after stalled screenshots");
|
|
1470
|
+
this.grabFailures = 0;
|
|
1471
|
+
} catch {
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
async readScroll() {
|
|
1477
|
+
try {
|
|
1478
|
+
const m = await withTimeout(this.captureCdp.send("Page.getLayoutMetrics"), 1500);
|
|
1479
|
+
const vv = m.cssVisualViewport ?? m.visualViewport;
|
|
1480
|
+
return { x: Math.round(vv?.pageX ?? 0), y: Math.round(vv?.pageY ?? 0) };
|
|
1481
|
+
} catch {
|
|
1482
|
+
return { x: 0, y: 0 };
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async stopCapture() {
|
|
1486
|
+
this.captureActive = false;
|
|
1487
|
+
await this.captureLoop?.catch(() => {
|
|
1488
|
+
});
|
|
1489
|
+
this.captureLoop = null;
|
|
1490
|
+
}
|
|
1491
|
+
lastFrameAt() {
|
|
1492
|
+
return this.lastFrameNodeMs;
|
|
1493
|
+
}
|
|
1494
|
+
async captureNow() {
|
|
1495
|
+
if (this.inflightGrab) await this.inflightGrab.catch(() => {
|
|
1496
|
+
});
|
|
1497
|
+
await this.grabFrame();
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
function toPw(m) {
|
|
1501
|
+
if ("exact" in m) return m.exact;
|
|
1502
|
+
if ("substring" in m) return m.substring;
|
|
1503
|
+
return new RegExp(m.regex.source, m.regex.flags);
|
|
1504
|
+
}
|
|
1505
|
+
function isExact(m) {
|
|
1506
|
+
return "exact" in m;
|
|
1507
|
+
}
|
|
1508
|
+
function sleep(ms) {
|
|
1509
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1510
|
+
}
|
|
1511
|
+
function maskRuleToCss(loc) {
|
|
1512
|
+
const b = loc.base;
|
|
1513
|
+
if (b.kind === "css") return b.selector;
|
|
1514
|
+
if (b.kind === "testid") return `[data-testid=${JSON.stringify(b.value)}]`;
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
function firstLine(s) {
|
|
1518
|
+
const line = s.split("\n")[0] ?? "";
|
|
1519
|
+
return line.length > 160 ? `${line.slice(0, 159)}\u2026` : line;
|
|
1520
|
+
}
|
|
1521
|
+
function withTimeout(p, ms) {
|
|
1522
|
+
return new Promise((resolve, reject) => {
|
|
1523
|
+
const timer = setTimeout(() => reject(new Error(`operation timed out after ${ms}ms`)), ms);
|
|
1524
|
+
p.then(
|
|
1525
|
+
(v) => {
|
|
1526
|
+
clearTimeout(timer);
|
|
1527
|
+
resolve(v);
|
|
1528
|
+
},
|
|
1529
|
+
(e) => {
|
|
1530
|
+
clearTimeout(timer);
|
|
1531
|
+
reject(e);
|
|
1532
|
+
}
|
|
1533
|
+
);
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/bundle/writer.ts
|
|
1538
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
1539
|
+
import { join } from "path";
|
|
1540
|
+
import sharp from "sharp";
|
|
1541
|
+
|
|
1542
|
+
// src/bundle/hash.ts
|
|
1543
|
+
import { createHash } from "crypto";
|
|
1544
|
+
import { createReadStream } from "fs";
|
|
1545
|
+
function sha256Json(value) {
|
|
1546
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1547
|
+
}
|
|
1548
|
+
function sha256File(path) {
|
|
1549
|
+
return new Promise((resolve, reject) => {
|
|
1550
|
+
const hash = createHash("sha256");
|
|
1551
|
+
createReadStream(path).on("data", (d) => hash.update(d)).on("end", () => resolve(hash.digest("hex"))).on("error", reject);
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/shared/version.ts
|
|
1556
|
+
import { createRequire } from "module";
|
|
1557
|
+
var PLAYHEAD_VERSION = createRequire(import.meta.url)("../../package.json").version;
|
|
1558
|
+
|
|
1559
|
+
// src/bundle/writer.ts
|
|
1560
|
+
var BundleWriter = class {
|
|
1561
|
+
constructor(dir, captureFormat) {
|
|
1562
|
+
this.dir = dir;
|
|
1563
|
+
this.captureFormat = captureFormat;
|
|
1564
|
+
this.ready = mkdir(join(dir, "frames"), { recursive: true }).then(() => {
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
dir;
|
|
1568
|
+
captureFormat;
|
|
1569
|
+
frames = [];
|
|
1570
|
+
pendingWrites = [];
|
|
1571
|
+
maskSamples = [];
|
|
1572
|
+
counter = 0;
|
|
1573
|
+
ready;
|
|
1574
|
+
/** Queue a frame write. `t` is capture-relative ms. */
|
|
1575
|
+
addFrame(data, t) {
|
|
1576
|
+
this.counter += 1;
|
|
1577
|
+
const name = `${String(this.counter).padStart(6, "0")}.${this.captureFormat === "jpeg" ? "jpg" : "png"}`;
|
|
1578
|
+
this.frames.push({ f: name, t: round1(t) });
|
|
1579
|
+
this.pendingWrites.push(
|
|
1580
|
+
this.ready.then(() => writeFile(join(this.dir, "frames", name), data))
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
addMaskSample(sample) {
|
|
1584
|
+
this.maskSamples.push(sample);
|
|
1585
|
+
}
|
|
1586
|
+
frameCount() {
|
|
1587
|
+
return this.counter;
|
|
1588
|
+
}
|
|
1589
|
+
async finish(meta) {
|
|
1590
|
+
await this.ready;
|
|
1591
|
+
await Promise.all(this.pendingWrites);
|
|
1592
|
+
this.frames.sort((a, b) => a.t - b.t);
|
|
1593
|
+
let frameW = meta.viewport.w * meta.dpr;
|
|
1594
|
+
let frameH = meta.viewport.h * meta.dpr;
|
|
1595
|
+
const first = this.frames[0];
|
|
1596
|
+
if (first) {
|
|
1597
|
+
const info = await sharp(join(this.dir, "frames", first.f)).metadata();
|
|
1598
|
+
if (info.width && info.height) {
|
|
1599
|
+
frameW = info.width;
|
|
1600
|
+
frameH = info.height;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const manifest = {
|
|
1604
|
+
schema: "playhead/bundle@1",
|
|
1605
|
+
playheadVersion: PLAYHEAD_VERSION,
|
|
1606
|
+
specHash: sha256Json(meta.spec),
|
|
1607
|
+
appUrl: meta.appUrl,
|
|
1608
|
+
viewport: meta.viewport,
|
|
1609
|
+
dpr: meta.dpr,
|
|
1610
|
+
frameW,
|
|
1611
|
+
frameH,
|
|
1612
|
+
clockOffsetMs: meta.clockOffsetMs,
|
|
1613
|
+
frameCount: this.frames.length,
|
|
1614
|
+
durationMs: round1(meta.durationMs),
|
|
1615
|
+
createdAtIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1616
|
+
captureFormat: this.captureFormat,
|
|
1617
|
+
hashes: {
|
|
1618
|
+
events: sha256Json(meta.events),
|
|
1619
|
+
framesIndex: sha256Json(this.frames)
|
|
1620
|
+
},
|
|
1621
|
+
...meta.partial ? { partial: true } : {},
|
|
1622
|
+
...meta.failure ? { failure: meta.failure } : {}
|
|
1623
|
+
};
|
|
1624
|
+
await Promise.all([
|
|
1625
|
+
writeFile(join(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
|
|
1626
|
+
writeFile(join(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
|
|
1627
|
+
writeFile(join(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
|
|
1628
|
+
writeFile(join(this.dir, "spec.resolved.json"), JSON.stringify(meta.spec, null, 2)),
|
|
1629
|
+
writeFile(join(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
|
|
1630
|
+
]);
|
|
1631
|
+
return manifest;
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
function round1(n) {
|
|
1635
|
+
return Math.round(n * 10) / 10;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/shared/geometry.ts
|
|
1639
|
+
function rectCenter(r) {
|
|
1640
|
+
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
|
|
1641
|
+
}
|
|
1642
|
+
function rectUnion(a, b) {
|
|
1643
|
+
const x = Math.min(a.x, b.x);
|
|
1644
|
+
const y = Math.min(a.y, b.y);
|
|
1645
|
+
return {
|
|
1646
|
+
x,
|
|
1647
|
+
y,
|
|
1648
|
+
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
|
1649
|
+
h: Math.max(a.y + a.h, b.y + b.h) - y
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
function padRect(r, pad) {
|
|
1653
|
+
return { x: r.x - pad, y: r.y - pad, w: r.w + pad * 2, h: r.h + pad * 2 };
|
|
1654
|
+
}
|
|
1655
|
+
function rectContains(outer, inner, margin = 0) {
|
|
1656
|
+
return inner.x >= outer.x + margin && inner.y >= outer.y + margin && inner.x + inner.w <= outer.x + outer.w - margin && inner.y + inner.h <= outer.y + outer.h - margin;
|
|
1657
|
+
}
|
|
1658
|
+
function clamp(v, min, max) {
|
|
1659
|
+
return v < min ? min : v > max ? max : v;
|
|
1660
|
+
}
|
|
1661
|
+
function dist(a, b) {
|
|
1662
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
1663
|
+
}
|
|
1664
|
+
function visibleRect(cam, plane2) {
|
|
1665
|
+
const w = plane2.vpW / cam.zoom;
|
|
1666
|
+
const h = w * plane2.outH / plane2.outW;
|
|
1667
|
+
return { x: cam.cx - w / 2, y: cam.cy - h / 2, w, h };
|
|
1668
|
+
}
|
|
1669
|
+
function minZoom(plane2) {
|
|
1670
|
+
return Math.max(1, plane2.vpW / (plane2.vpH * plane2.outW / plane2.outH));
|
|
1671
|
+
}
|
|
1672
|
+
function clampCamera(cam, plane2) {
|
|
1673
|
+
const zoom = Math.max(cam.zoom, minZoom(plane2));
|
|
1674
|
+
const w = plane2.vpW / zoom;
|
|
1675
|
+
const h = w * plane2.outH / plane2.outW;
|
|
1676
|
+
return {
|
|
1677
|
+
zoom,
|
|
1678
|
+
cx: clamp(cam.cx, w / 2, plane2.vpW - w / 2),
|
|
1679
|
+
cy: clamp(cam.cy, h / 2, plane2.vpH - h / 2)
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
function projectRect(r, cam, plane2) {
|
|
1683
|
+
const vis = visibleRect(cam, plane2);
|
|
1684
|
+
const s = plane2.outW / vis.w;
|
|
1685
|
+
return { x: (r.x - vis.x) * s, y: (r.y - vis.y) * s, w: r.w * s, h: r.h * s };
|
|
1686
|
+
}
|
|
1687
|
+
function projectPoint(p, cam, plane2) {
|
|
1688
|
+
const vis = visibleRect(cam, plane2);
|
|
1689
|
+
const s = plane2.outW / vis.w;
|
|
1690
|
+
return { x: (p.x - vis.x) * s, y: (p.y - vis.y) * s };
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// src/shared/exit.ts
|
|
1694
|
+
var EXIT = {
|
|
1695
|
+
OK: 0,
|
|
1696
|
+
FLOW: 1,
|
|
1697
|
+
QUALITY: 2,
|
|
1698
|
+
INFRA: 3,
|
|
1699
|
+
USAGE: 4
|
|
1700
|
+
};
|
|
1701
|
+
var FlowError = class extends Error {
|
|
1702
|
+
exitCode = EXIT.FLOW;
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
// src/capture/executor.ts
|
|
1706
|
+
var SETTLE = { idleMs: 300, capMs: 5e3 };
|
|
1707
|
+
var TARGET_TIMEOUT_MS = 1e4;
|
|
1708
|
+
var EXPECT_TIMEOUT_MS = 1e4;
|
|
1709
|
+
var TYPE_DELAY_MS = 45;
|
|
1710
|
+
var STEP_BUDGET_MS = 45e3;
|
|
1711
|
+
var CAPTURE_DEADLINE_MS = 10 * 6e4;
|
|
1712
|
+
async function capture(spec, opts) {
|
|
1713
|
+
const bundleDir = join2(opts.outDir, "capture");
|
|
1714
|
+
const driver = opts.driver ?? new PlaywrightDriver();
|
|
1715
|
+
const dpr = 2;
|
|
1716
|
+
const format = opts.captureFormat ?? "jpeg";
|
|
1717
|
+
const writer = new BundleWriter(bundleDir, format);
|
|
1718
|
+
const events = [];
|
|
1719
|
+
const maskRules = spec.masking.map((m) => ({
|
|
1720
|
+
loc: parseLocator(m.target),
|
|
1721
|
+
style: m.style
|
|
1722
|
+
}));
|
|
1723
|
+
const viewport = resolveViewport(spec);
|
|
1724
|
+
const settleCfg = {
|
|
1725
|
+
idleMs: spec.app.settle?.idleMs ?? SETTLE.idleMs,
|
|
1726
|
+
capMs: spec.app.settle?.capMs ?? SETTLE.capMs
|
|
1727
|
+
};
|
|
1728
|
+
log.info(`capturing "${spec.title}" from ${spec.app.url}`);
|
|
1729
|
+
await driver.launch({
|
|
1730
|
+
viewport,
|
|
1731
|
+
dpr,
|
|
1732
|
+
headless: opts.headless ?? true,
|
|
1733
|
+
...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {},
|
|
1734
|
+
...spec.app.environment ? { environment: spec.app.environment } : {},
|
|
1735
|
+
...spec.app.network ? { network: spec.app.network } : {},
|
|
1736
|
+
...spec.app.dialogs ? { dialogs: spec.app.dialogs } : {}
|
|
1737
|
+
});
|
|
1738
|
+
let t0 = Date.now();
|
|
1739
|
+
const rel = (nodeMs) => nodeMs - t0;
|
|
1740
|
+
const now = () => rel(Date.now());
|
|
1741
|
+
let clockOffsetMs = 0;
|
|
1742
|
+
let failure;
|
|
1743
|
+
let currentStepRef = "";
|
|
1744
|
+
try {
|
|
1745
|
+
clockOffsetMs = await driver.measureClockOffset();
|
|
1746
|
+
t0 = Date.now();
|
|
1747
|
+
driver.onNavigation((url, tNode) => {
|
|
1748
|
+
events.push({ type: "navigation", url, t: rel(tNode) });
|
|
1749
|
+
});
|
|
1750
|
+
const startFilming = () => driver.startCapture((frame) => writer.addFrame(frame.data, rel(frame.tNodeMs)), {
|
|
1751
|
+
format,
|
|
1752
|
+
quality: opts.quality ?? 82,
|
|
1753
|
+
scale: dpr,
|
|
1754
|
+
// 24 requested ≈ 20-22 achieved with the screenshot loop's ~25ms capture cost; frame
|
|
1755
|
+
// blending at compose smooths the rest. (True 30+ needs the screencast source — planned.)
|
|
1756
|
+
fps: 24
|
|
1757
|
+
});
|
|
1758
|
+
if (!opts.fromScene) await startFilming();
|
|
1759
|
+
await driver.installMaskRules(maskRules);
|
|
1760
|
+
await driver.goto(spec.app.url);
|
|
1761
|
+
await applyMasksAndSample(driver, maskRules, writer, now);
|
|
1762
|
+
await driver.settle(settleCfg);
|
|
1763
|
+
if (spec.app.assertLoggedIn) {
|
|
1764
|
+
await driver.expectState(parseLocator(spec.app.assertLoggedIn), { visible: true }, 8e3).catch(() => {
|
|
1765
|
+
throw new Error(
|
|
1766
|
+
`assertLoggedIn failed: ${spec.app.assertLoggedIn} not visible \u2014 storageState stale? Re-run: playhead login ${spec.app.url}`
|
|
1767
|
+
);
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
if (!opts.fromScene) await driver.captureNow();
|
|
1771
|
+
let steps = flattenSteps(spec);
|
|
1772
|
+
const totalSteps = steps.length;
|
|
1773
|
+
if (opts.fromScene) {
|
|
1774
|
+
const idx = steps.findIndex((s) => s.sceneId === opts.fromScene);
|
|
1775
|
+
if (idx < 0) throw new Error(`--from-scene: no scene with id "${opts.fromScene}" in the spec`);
|
|
1776
|
+
log.info(`fast-forwarding ${idx} step(s) to scene "${opts.fromScene}" (state only, not filmed)`);
|
|
1777
|
+
for (const pre of steps.slice(0, idx)) {
|
|
1778
|
+
currentStepRef = `${pre.sceneId}/${pre.stepIndex} (fast-forward)`;
|
|
1779
|
+
await fastForwardStep(driver, pre.step);
|
|
1780
|
+
}
|
|
1781
|
+
currentStepRef = "";
|
|
1782
|
+
steps = steps.slice(idx);
|
|
1783
|
+
await driver.settle(settleCfg);
|
|
1784
|
+
await startFilming();
|
|
1785
|
+
await driver.captureNow();
|
|
1786
|
+
}
|
|
1787
|
+
for (const addressed of steps) {
|
|
1788
|
+
currentStepRef = `${addressed.sceneId}/${addressed.stepIndex}`;
|
|
1789
|
+
if (now() > CAPTURE_DEADLINE_MS) {
|
|
1790
|
+
throw new Error(`capture watchdog: exceeded ${CAPTURE_DEADLINE_MS / 6e4} minutes at step ${currentStepRef}`);
|
|
1791
|
+
}
|
|
1792
|
+
log.step(`${addressed.ordinal}/${totalSteps} ${describeStep(addressed)}`);
|
|
1793
|
+
const event = await withStepBudget(
|
|
1794
|
+
executeStep(driver, addressed, { rel, now }, spec.app.url),
|
|
1795
|
+
STEP_BUDGET_MS,
|
|
1796
|
+
currentStepRef
|
|
1797
|
+
);
|
|
1798
|
+
await applyMasksAndSample(driver, maskRules, writer, now);
|
|
1799
|
+
await driver.settle(settleCfg);
|
|
1800
|
+
const focus = "focus" in addressed.step ? addressed.step.focus : void 0;
|
|
1801
|
+
if (focus && focus !== "target" && focus !== "wide") {
|
|
1802
|
+
const loc = parseLocator(focus);
|
|
1803
|
+
for (let i = 0; i < 10 && !event.focusTarget; i++) {
|
|
1804
|
+
event.focusTarget = await driver.tryMeasure(loc);
|
|
1805
|
+
if (!event.focusTarget) await sleep2(250);
|
|
1806
|
+
}
|
|
1807
|
+
if (!event.focusTarget) {
|
|
1808
|
+
log.warn(`${addressed.sceneId}/${addressed.stepIndex}: focus locator never appeared: ${focus}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
await ensurePostActionFrame(driver, event.tAction, rel);
|
|
1812
|
+
event.tSettled = now();
|
|
1813
|
+
if (event.targetPre && event.locator) {
|
|
1814
|
+
event.targetPost = await driver.tryMeasure(parseLocator(event.locator));
|
|
1815
|
+
}
|
|
1816
|
+
if (addressed.step.action === "type" && addressed.step.mask && event.locator) {
|
|
1817
|
+
const info = await driver.tryMeasure(parseLocator(event.locator));
|
|
1818
|
+
if (info) writer.addMaskSample({ t: now(), rects: [{ style: "solid", ...info.bbox }] });
|
|
1819
|
+
}
|
|
1820
|
+
events.push(event);
|
|
1821
|
+
}
|
|
1822
|
+
currentStepRef = "";
|
|
1823
|
+
await driver.settle(settleCfg);
|
|
1824
|
+
await driver.captureNow();
|
|
1825
|
+
await sleep2(400);
|
|
1826
|
+
return { bundleDir };
|
|
1827
|
+
} catch (e) {
|
|
1828
|
+
const message = e.message;
|
|
1829
|
+
failure = { stepRef: currentStepRef || "(setup)", message };
|
|
1830
|
+
try {
|
|
1831
|
+
const aria = await driver.ariaSnapshot();
|
|
1832
|
+
const url = driver.currentUrl();
|
|
1833
|
+
const { writeFile: writeFile6 } = await import("fs/promises");
|
|
1834
|
+
await writeFile6(
|
|
1835
|
+
join2(bundleDir, "failure.json"),
|
|
1836
|
+
JSON.stringify({ stepRef: failure.stepRef, message, url, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
1837
|
+
);
|
|
1838
|
+
if (aria) await writeFile6(join2(bundleDir, "failure-aria.txt"), aria);
|
|
1839
|
+
} catch {
|
|
1840
|
+
}
|
|
1841
|
+
throw new FlowError(
|
|
1842
|
+
`${message}
|
|
1843
|
+
(partial bundle with ${writer.frameCount()} frames saved to ${bundleDir} \u2014 failure.json has the details)`
|
|
1844
|
+
);
|
|
1845
|
+
} finally {
|
|
1846
|
+
await driver.stopCapture().catch(() => {
|
|
1847
|
+
});
|
|
1848
|
+
try {
|
|
1849
|
+
const consoleLog = driver.consoleLog();
|
|
1850
|
+
if (consoleLog.length > 0) {
|
|
1851
|
+
const { writeFile: writeFile6 } = await import("fs/promises");
|
|
1852
|
+
await writeFile6(
|
|
1853
|
+
join2(bundleDir, "console.json"),
|
|
1854
|
+
JSON.stringify(consoleLog.map((c) => ({ ...c, t: rel(c.t) })), null, 2)
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
const durationMs = now();
|
|
1860
|
+
const eventLog = {
|
|
1861
|
+
schema: "playhead/events@1",
|
|
1862
|
+
meta: {
|
|
1863
|
+
viewport,
|
|
1864
|
+
dpr,
|
|
1865
|
+
frameW: 0,
|
|
1866
|
+
// finalized by writer from the first frame's real dimensions
|
|
1867
|
+
frameH: 0,
|
|
1868
|
+
clockOffsetMs,
|
|
1869
|
+
startedAtIso: new Date(t0).toISOString(),
|
|
1870
|
+
durationMs,
|
|
1871
|
+
appUrl: spec.app.url
|
|
1872
|
+
},
|
|
1873
|
+
events
|
|
1874
|
+
};
|
|
1875
|
+
try {
|
|
1876
|
+
const manifest = await writer.finish({
|
|
1877
|
+
spec,
|
|
1878
|
+
events: eventLog,
|
|
1879
|
+
appUrl: spec.app.url,
|
|
1880
|
+
viewport,
|
|
1881
|
+
dpr,
|
|
1882
|
+
clockOffsetMs,
|
|
1883
|
+
durationMs,
|
|
1884
|
+
...failure ? { partial: true, failure } : {}
|
|
1885
|
+
});
|
|
1886
|
+
if (!failure) {
|
|
1887
|
+
log.ok(`bundle: ${manifest.frameCount} frames, ${(durationMs / 1e3).toFixed(1)}s \u2192 ${bundleDir}`);
|
|
1888
|
+
} else {
|
|
1889
|
+
log.warn(`partial bundle: ${manifest.frameCount} frames up to ${failure.stepRef} \u2192 ${bundleDir}`);
|
|
1890
|
+
}
|
|
1891
|
+
} catch (finishErr) {
|
|
1892
|
+
log.warn(`could not finalize bundle: ${finishErr.message}`);
|
|
1893
|
+
}
|
|
1894
|
+
await driver.close();
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
async function fastForwardStep(driver, step) {
|
|
1898
|
+
const T = step.timeout ?? 8e3;
|
|
1899
|
+
switch (step.action) {
|
|
1900
|
+
case "goto":
|
|
1901
|
+
await driver.goto(step.url);
|
|
1902
|
+
break;
|
|
1903
|
+
case "click":
|
|
1904
|
+
case "dblclick": {
|
|
1905
|
+
const t = await driver.resolveTarget(parseLocator(step.target), T);
|
|
1906
|
+
await driver.clickAt(rectCenter(t.bbox), { double: step.action === "dblclick" });
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
case "hover":
|
|
1910
|
+
break;
|
|
1911
|
+
// no lasting state
|
|
1912
|
+
case "type": {
|
|
1913
|
+
const t = await driver.resolveTarget(parseLocator(step.target), T);
|
|
1914
|
+
await driver.clickAt(rectCenter(t.bbox));
|
|
1915
|
+
await driver.typeText(step.text, 0);
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1918
|
+
case "press":
|
|
1919
|
+
await driver.press(step.keys);
|
|
1920
|
+
break;
|
|
1921
|
+
case "select":
|
|
1922
|
+
await driver.selectOption(parseLocator(step.target), step.value);
|
|
1923
|
+
break;
|
|
1924
|
+
case "scroll":
|
|
1925
|
+
if (step.target) await driver.scrollIntoView(parseLocator(step.target));
|
|
1926
|
+
else if (step.by) await driver.scrollBy(step.by);
|
|
1927
|
+
await driver.waitForScrollSettle(1500);
|
|
1928
|
+
break;
|
|
1929
|
+
case "expect":
|
|
1930
|
+
await driver.expectState(parseLocator(step.target), { ...step.visible !== void 0 ? { visible: step.visible } : {} }, T).catch(() => {
|
|
1931
|
+
});
|
|
1932
|
+
break;
|
|
1933
|
+
case "wait":
|
|
1934
|
+
if (step.for) {
|
|
1935
|
+
await driver.expectState(parseLocator(step.for), { visible: step.state !== "hidden" }, T).catch(() => {
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
break;
|
|
1939
|
+
}
|
|
1940
|
+
await driver.settle({ idleMs: 200, capMs: 2500 });
|
|
1941
|
+
}
|
|
1942
|
+
function withStepBudget(p, ms, stepRef) {
|
|
1943
|
+
return new Promise((resolve, reject) => {
|
|
1944
|
+
const timer = setTimeout(
|
|
1945
|
+
() => reject(new Error(`step ${stepRef} exceeded its ${ms / 1e3}s budget`)),
|
|
1946
|
+
ms
|
|
1947
|
+
);
|
|
1948
|
+
p.then(
|
|
1949
|
+
(v) => {
|
|
1950
|
+
clearTimeout(timer);
|
|
1951
|
+
resolve(v);
|
|
1952
|
+
},
|
|
1953
|
+
(e) => {
|
|
1954
|
+
clearTimeout(timer);
|
|
1955
|
+
reject(e);
|
|
1956
|
+
}
|
|
1957
|
+
);
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
async function executeStep(driver, addressed, clock, appUrl) {
|
|
1961
|
+
const { step, sceneId, stepIndex, ordinal } = addressed;
|
|
1962
|
+
const base = {
|
|
1963
|
+
type: "action",
|
|
1964
|
+
kind: step.action,
|
|
1965
|
+
sceneId,
|
|
1966
|
+
stepIndex,
|
|
1967
|
+
ordinal,
|
|
1968
|
+
cursorPath: [],
|
|
1969
|
+
tStart: clock.now(),
|
|
1970
|
+
tAction: 0,
|
|
1971
|
+
tActionEnd: 0,
|
|
1972
|
+
tSettled: 0,
|
|
1973
|
+
...step.caption ? { caption: step.caption } : {},
|
|
1974
|
+
...step.narration ? { narration: step.narration } : {},
|
|
1975
|
+
...step.shot ? { shot: step.shot } : {},
|
|
1976
|
+
..."focus" in step && step.focus ? { focus: step.focus } : {}
|
|
1977
|
+
};
|
|
1978
|
+
const budgetMs = step.timeout ?? TARGET_TIMEOUT_MS;
|
|
1979
|
+
switch (step.action) {
|
|
1980
|
+
case "goto": {
|
|
1981
|
+
const url = new URL(step.url, appUrl).href;
|
|
1982
|
+
base.locator = url;
|
|
1983
|
+
base.tAction = clock.now();
|
|
1984
|
+
await driver.goto(url);
|
|
1985
|
+
base.tActionEnd = clock.now();
|
|
1986
|
+
return base;
|
|
1987
|
+
}
|
|
1988
|
+
case "click":
|
|
1989
|
+
case "dblclick":
|
|
1990
|
+
case "hover": {
|
|
1991
|
+
base.locator = step.target;
|
|
1992
|
+
const loc = parseLocator(step.target);
|
|
1993
|
+
const pre = await driver.resolveTarget(loc, budgetMs);
|
|
1994
|
+
base.targetPre = pre;
|
|
1995
|
+
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
1996
|
+
if (step.action === "hover") {
|
|
1997
|
+
base.tAction = clock.now();
|
|
1998
|
+
base.tActionEnd = base.tAction;
|
|
1999
|
+
} else {
|
|
2000
|
+
const { tDown, tUp } = await driver.actClick(loc, {
|
|
2001
|
+
double: step.action === "dblclick",
|
|
2002
|
+
timeoutMs: budgetMs
|
|
2003
|
+
});
|
|
2004
|
+
base.tAction = clock.rel(tDown);
|
|
2005
|
+
base.tActionEnd = clock.rel(tUp);
|
|
2006
|
+
}
|
|
2007
|
+
return base;
|
|
2008
|
+
}
|
|
2009
|
+
case "type": {
|
|
2010
|
+
base.locator = step.target;
|
|
2011
|
+
const loc = parseLocator(step.target);
|
|
2012
|
+
const pre = await driver.resolveTarget(loc, budgetMs);
|
|
2013
|
+
base.targetPre = pre;
|
|
2014
|
+
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
2015
|
+
await driver.actClick(loc, { timeoutMs: budgetMs });
|
|
2016
|
+
if (step.mask) {
|
|
2017
|
+
await driver.setTypingMask(loc);
|
|
2018
|
+
}
|
|
2019
|
+
const { tFirst, tLast } = await driver.typeText(step.text, TYPE_DELAY_MS);
|
|
2020
|
+
base.tAction = clock.rel(tFirst);
|
|
2021
|
+
base.tActionEnd = clock.rel(tLast);
|
|
2022
|
+
base.masked = step.mask;
|
|
2023
|
+
base.typedText = step.mask ? "\u2022".repeat(Math.min(step.text.length, 8)) : step.text;
|
|
2024
|
+
return base;
|
|
2025
|
+
}
|
|
2026
|
+
case "press": {
|
|
2027
|
+
base.locator = step.keys;
|
|
2028
|
+
const { tDown } = await driver.press(step.keys);
|
|
2029
|
+
base.tAction = clock.rel(tDown);
|
|
2030
|
+
base.tActionEnd = clock.now();
|
|
2031
|
+
return base;
|
|
2032
|
+
}
|
|
2033
|
+
case "select": {
|
|
2034
|
+
base.locator = step.target;
|
|
2035
|
+
const loc = parseLocator(step.target);
|
|
2036
|
+
const pre = await driver.resolveTarget(loc, budgetMs);
|
|
2037
|
+
base.targetPre = pre;
|
|
2038
|
+
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
2039
|
+
base.tAction = clock.now();
|
|
2040
|
+
await driver.selectOption(loc, step.value);
|
|
2041
|
+
base.typedText = step.value;
|
|
2042
|
+
base.tActionEnd = clock.now();
|
|
2043
|
+
return base;
|
|
2044
|
+
}
|
|
2045
|
+
case "scroll": {
|
|
2046
|
+
base.tAction = clock.now();
|
|
2047
|
+
if (step.target) {
|
|
2048
|
+
base.locator = step.target;
|
|
2049
|
+
const loc = parseLocator(step.target);
|
|
2050
|
+
await driver.scrollIntoView(loc);
|
|
2051
|
+
await driver.waitForScrollSettle(2500);
|
|
2052
|
+
base.targetPre = await driver.tryMeasure(loc);
|
|
2053
|
+
} else if (step.by) {
|
|
2054
|
+
await driver.scrollBy(step.by);
|
|
2055
|
+
await driver.waitForScrollSettle(2500);
|
|
2056
|
+
}
|
|
2057
|
+
base.tActionEnd = clock.now();
|
|
2058
|
+
return base;
|
|
2059
|
+
}
|
|
2060
|
+
case "expect": {
|
|
2061
|
+
base.locator = step.target;
|
|
2062
|
+
const loc = parseLocator(step.target);
|
|
2063
|
+
await driver.expectState(
|
|
2064
|
+
loc,
|
|
2065
|
+
{ ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.text !== void 0 ? { text: step.text } : {}, ...step.count !== void 0 ? { count: step.count } : {} },
|
|
2066
|
+
step.timeout ?? EXPECT_TIMEOUT_MS
|
|
2067
|
+
);
|
|
2068
|
+
base.targetPre = await driver.tryMeasure(loc);
|
|
2069
|
+
base.tAction = clock.now();
|
|
2070
|
+
base.tActionEnd = base.tAction;
|
|
2071
|
+
return base;
|
|
2072
|
+
}
|
|
2073
|
+
case "wait": {
|
|
2074
|
+
base.tAction = clock.now();
|
|
2075
|
+
if (step.for) {
|
|
2076
|
+
base.locator = step.for;
|
|
2077
|
+
await driver.expectState(
|
|
2078
|
+
parseLocator(step.for),
|
|
2079
|
+
{ visible: step.state !== "hidden" },
|
|
2080
|
+
step.timeout ?? EXPECT_TIMEOUT_MS
|
|
2081
|
+
);
|
|
2082
|
+
} else {
|
|
2083
|
+
await sleep2(step.ms ?? 0);
|
|
2084
|
+
}
|
|
2085
|
+
base.tActionEnd = clock.now();
|
|
2086
|
+
return base;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
async function moveToTarget(driver, target, clock) {
|
|
2091
|
+
const to = rectCenter(target.bbox);
|
|
2092
|
+
const travel = clamp(250 + dist(driver.cursorPos(), to) * 0.5, 300, 700);
|
|
2093
|
+
const waypoints = await driver.moveCursor(to, travel);
|
|
2094
|
+
return waypoints.map((w) => ({ x: round12(w.x), y: round12(w.y), t: round12(clock.rel(w.t)) }));
|
|
2095
|
+
}
|
|
2096
|
+
async function ensurePostActionFrame(driver, tAction, rel) {
|
|
2097
|
+
await sleep2(24);
|
|
2098
|
+
await driver.captureNow();
|
|
2099
|
+
if (rel(driver.lastFrameAt()) <= tAction) {
|
|
2100
|
+
for (let i = 0; i < 2 && rel(driver.lastFrameAt()) <= tAction; i++) {
|
|
2101
|
+
await sleep2(40);
|
|
2102
|
+
await driver.captureNow();
|
|
2103
|
+
}
|
|
2104
|
+
if (rel(driver.lastFrameAt()) <= tAction) {
|
|
2105
|
+
log.warn(`no captured frame after action at t=${tAction.toFixed(0)}ms \u2014 footage may hold a stale frame`);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
async function applyMasksAndSample(driver, rules, writer, now) {
|
|
2110
|
+
if (rules.length === 0) return;
|
|
2111
|
+
const rects = await driver.applyMasks(rules);
|
|
2112
|
+
if (rects.length > 0) writer.addMaskSample({ t: now(), rects });
|
|
2113
|
+
}
|
|
2114
|
+
function describeStep(a) {
|
|
2115
|
+
const s = a.step;
|
|
2116
|
+
switch (s.action) {
|
|
2117
|
+
case "goto":
|
|
2118
|
+
return `goto ${s.url}`;
|
|
2119
|
+
case "type":
|
|
2120
|
+
return `type into ${s.target}${s.mask ? " (masked)" : ""}`;
|
|
2121
|
+
case "press":
|
|
2122
|
+
return `press ${s.keys}`;
|
|
2123
|
+
case "wait":
|
|
2124
|
+
return `wait ${s.ms}ms`;
|
|
2125
|
+
case "scroll":
|
|
2126
|
+
return `scroll ${s.target ?? JSON.stringify(s.by)}`;
|
|
2127
|
+
default:
|
|
2128
|
+
return `${s.action} ${"target" in s ? s.target : ""}`;
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
function sleep2(ms) {
|
|
2132
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2133
|
+
}
|
|
2134
|
+
function round12(n) {
|
|
2135
|
+
return Math.round(n * 10) / 10;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// src/bundle/reader.ts
|
|
2139
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
2140
|
+
import { join as join3 } from "path";
|
|
2141
|
+
async function openBundle(dir) {
|
|
2142
|
+
const [manifestRaw, eventsRaw, framesRaw, masksRaw] = await Promise.all([
|
|
2143
|
+
readFile2(join3(dir, "manifest.json"), "utf8"),
|
|
2144
|
+
readFile2(join3(dir, "events.json"), "utf8"),
|
|
2145
|
+
readFile2(join3(dir, "frames", "index.json"), "utf8"),
|
|
2146
|
+
readFile2(join3(dir, "masks.json"), "utf8").catch(() => "[]")
|
|
2147
|
+
]);
|
|
2148
|
+
const manifest = JSON.parse(manifestRaw);
|
|
2149
|
+
if (manifest.schema !== "playhead/bundle@1") {
|
|
2150
|
+
throw new Error(`Unsupported bundle schema "${manifest.schema}" in ${dir}`);
|
|
2151
|
+
}
|
|
2152
|
+
const events = JSON.parse(eventsRaw);
|
|
2153
|
+
const frames = JSON.parse(framesRaw);
|
|
2154
|
+
const maskSamples = JSON.parse(masksRaw);
|
|
2155
|
+
if (frames.length === 0) throw new Error(`Bundle ${dir} contains no frames`);
|
|
2156
|
+
return { dir, manifest, events, frames, maskSamples };
|
|
2157
|
+
}
|
|
2158
|
+
function frameIndexForTime(frames, tMs) {
|
|
2159
|
+
let lo = 0;
|
|
2160
|
+
let hi = frames.length - 1;
|
|
2161
|
+
if (tMs <= frames[0].t) return 0;
|
|
2162
|
+
if (tMs >= frames[hi].t) return hi;
|
|
2163
|
+
while (lo < hi) {
|
|
2164
|
+
const mid = lo + hi + 1 >> 1;
|
|
2165
|
+
if (frames[mid].t <= tMs) lo = mid;
|
|
2166
|
+
else hi = mid - 1;
|
|
2167
|
+
}
|
|
2168
|
+
return lo;
|
|
2169
|
+
}
|
|
2170
|
+
function framePath(bundle, index) {
|
|
2171
|
+
return join3(bundle.dir, "frames", bundle.frames[index].f);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// src/compose/index.ts
|
|
2175
|
+
import { join as join7 } from "path";
|
|
2176
|
+
import { writeFile as writeFile3 } from "fs/promises";
|
|
2177
|
+
|
|
2178
|
+
// src/theme/index.ts
|
|
2179
|
+
import { existsSync } from "fs";
|
|
2180
|
+
import { join as join4, dirname as dirname2 } from "path";
|
|
2181
|
+
import { createRequire as createRequire2 } from "module";
|
|
2182
|
+
import { GlobalFonts } from "@napi-rs/canvas";
|
|
2183
|
+
var DEFAULT_THEME = {
|
|
2184
|
+
name: "default",
|
|
2185
|
+
accent: "#5B8DEF",
|
|
2186
|
+
bg: "#12161f",
|
|
2187
|
+
bgSubtle: "#1a2029",
|
|
2188
|
+
surface: "rgba(18, 22, 31, 0.92)",
|
|
2189
|
+
onSurface: "#ffffff",
|
|
2190
|
+
onSurfaceDim: "rgba(255,255,255,0.72)",
|
|
2191
|
+
fontFamily: "Inter",
|
|
2192
|
+
// Broadcast-subtitle scale: ~3.3% of a 1080p frame. At the old 26px the caption was the same
|
|
2193
|
+
// size as the app's upscaled body copy and read as part of the UI, not narration above it.
|
|
2194
|
+
captionFontPx: 36,
|
|
2195
|
+
captionMinFontPx: 28,
|
|
2196
|
+
captionMaxLines: 2,
|
|
2197
|
+
captionPosition: "bottom",
|
|
2198
|
+
radius: 12
|
|
2199
|
+
};
|
|
2200
|
+
function resolveTheme(spec) {
|
|
2201
|
+
return {
|
|
2202
|
+
...DEFAULT_THEME,
|
|
2203
|
+
...spec.theme.accent ? { accent: spec.theme.accent } : {},
|
|
2204
|
+
captionPosition: spec.theme.captions
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
var fontsRegistered = false;
|
|
2208
|
+
function registerFonts() {
|
|
2209
|
+
if (fontsRegistered) return;
|
|
2210
|
+
fontsRegistered = true;
|
|
2211
|
+
const require5 = createRequire2(import.meta.url);
|
|
2212
|
+
let pkgDir;
|
|
2213
|
+
try {
|
|
2214
|
+
pkgDir = dirname2(require5.resolve("@expo-google-fonts/inter/package.json"));
|
|
2215
|
+
} catch {
|
|
2216
|
+
throw new Error("Font package @expo-google-fonts/inter not found \u2014 run npm install");
|
|
2217
|
+
}
|
|
2218
|
+
const faces = [
|
|
2219
|
+
["400Regular/Inter_400Regular.ttf", "Inter"],
|
|
2220
|
+
["500Medium/Inter_500Medium.ttf", "Inter Medium"],
|
|
2221
|
+
["600SemiBold/Inter_600SemiBold.ttf", "Inter SemiBold"],
|
|
2222
|
+
["700Bold/Inter_700Bold.ttf", "Inter Bold"]
|
|
2223
|
+
];
|
|
2224
|
+
for (const [rel, family] of faces) {
|
|
2225
|
+
const p = join4(pkgDir, rel);
|
|
2226
|
+
if (existsSync(p)) GlobalFonts.registerFromPath(p, family);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// src/compose/types.ts
|
|
2231
|
+
function stageContent(profile) {
|
|
2232
|
+
return profile.stage?.content ?? { x: 0, y: 0, w: profile.width, h: profile.height };
|
|
2233
|
+
}
|
|
2234
|
+
function withStage(profile, chrome) {
|
|
2235
|
+
const { width, height } = profile;
|
|
2236
|
+
const marginY = Math.round(height * 0.055);
|
|
2237
|
+
const chromeH = chrome ? Math.round(44 * (Math.min(width, height) / 1080)) : 0;
|
|
2238
|
+
const contentH = height - 2 * marginY - chromeH;
|
|
2239
|
+
const contentW = Math.round(contentH * (width / height));
|
|
2240
|
+
const x = Math.round((width - contentW) / 2);
|
|
2241
|
+
const y = marginY + chromeH;
|
|
2242
|
+
return {
|
|
2243
|
+
...profile,
|
|
2244
|
+
stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * (Math.min(width, height) / 1080)) }
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
var PROFILE_16x9 = {
|
|
2248
|
+
width: 1920,
|
|
2249
|
+
height: 1080,
|
|
2250
|
+
fps: 30,
|
|
2251
|
+
safeArea: { top: 54, right: 96, bottom: 160, left: 96 },
|
|
2252
|
+
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
2253
|
+
minFocusPx: 110,
|
|
2254
|
+
captionMaxWidth: 1040
|
|
2255
|
+
};
|
|
2256
|
+
var PROFILE_9x16 = {
|
|
2257
|
+
width: 1080,
|
|
2258
|
+
height: 1920,
|
|
2259
|
+
fps: 30,
|
|
2260
|
+
safeArea: { top: 230, right: 56, bottom: 320, left: 56 },
|
|
2261
|
+
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
2262
|
+
minFocusPx: 96,
|
|
2263
|
+
captionMaxWidth: 980
|
|
2264
|
+
};
|
|
2265
|
+
var PROFILE_1x1 = {
|
|
2266
|
+
width: 1080,
|
|
2267
|
+
height: 1080,
|
|
2268
|
+
fps: 30,
|
|
2269
|
+
safeArea: { top: 80, right: 72, bottom: 200, left: 72 },
|
|
2270
|
+
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
2271
|
+
minFocusPx: 100,
|
|
2272
|
+
captionMaxWidth: 940
|
|
2273
|
+
};
|
|
2274
|
+
function profileForAspect(aspect, resolution, fps) {
|
|
2275
|
+
const base = aspect === "9:16" ? PROFILE_9x16 : aspect === "1:1" ? PROFILE_1x1 : PROFILE_16x9;
|
|
2276
|
+
const scaleX = resolution.w / base.width;
|
|
2277
|
+
const scaleY = resolution.h / base.height;
|
|
2278
|
+
return {
|
|
2279
|
+
...base,
|
|
2280
|
+
width: resolution.w,
|
|
2281
|
+
height: resolution.h,
|
|
2282
|
+
fps,
|
|
2283
|
+
safeArea: {
|
|
2284
|
+
top: Math.round(base.safeArea.top * scaleY),
|
|
2285
|
+
bottom: Math.round(base.safeArea.bottom * scaleY),
|
|
2286
|
+
left: Math.round(base.safeArea.left * scaleX),
|
|
2287
|
+
right: Math.round(base.safeArea.right * scaleX)
|
|
2288
|
+
},
|
|
2289
|
+
captionMaxWidth: Math.round(base.captionMaxWidth * scaleX)
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
function actReactWindows(step) {
|
|
2293
|
+
if (!step.focusRectVp || !step.targetRectVp) return null;
|
|
2294
|
+
if (step.focus === "target" || step.focus === "wide") return null;
|
|
2295
|
+
if (step.outEnd - step.outBeat < 1600) return null;
|
|
2296
|
+
return {
|
|
2297
|
+
act: { rect: step.targetRectVp, from: step.outBeat - 60, to: step.outBeat + 400 },
|
|
2298
|
+
react: { rect: step.focusRectVp, from: step.outBeat + 1100, to: step.outEnd }
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// src/compose/kinds.ts
|
|
2303
|
+
var BASE = {
|
|
2304
|
+
pacing: "normal",
|
|
2305
|
+
captionStyle: "instruction",
|
|
2306
|
+
numberedCaptions: true,
|
|
2307
|
+
elapsedClock: false,
|
|
2308
|
+
chapterCards: false,
|
|
2309
|
+
outroMs: 1600,
|
|
2310
|
+
showTypedInput: true,
|
|
2311
|
+
cameraEmphasis: true,
|
|
2312
|
+
stage: true
|
|
2313
|
+
};
|
|
2314
|
+
var PROFILES = {
|
|
2315
|
+
walkthrough: { kind: "walkthrough", ...BASE },
|
|
2316
|
+
demo: {
|
|
2317
|
+
kind: "demo",
|
|
2318
|
+
...BASE,
|
|
2319
|
+
pacing: "relaxed",
|
|
2320
|
+
captionStyle: "chapter",
|
|
2321
|
+
numberedCaptions: false,
|
|
2322
|
+
chapterCards: true,
|
|
2323
|
+
outroMs: 2200
|
|
2324
|
+
},
|
|
2325
|
+
"bug-repro": {
|
|
2326
|
+
kind: "bug-repro",
|
|
2327
|
+
...BASE,
|
|
2328
|
+
pacing: "normal",
|
|
2329
|
+
captionStyle: "factual",
|
|
2330
|
+
numberedCaptions: true,
|
|
2331
|
+
elapsedClock: true,
|
|
2332
|
+
outroMs: 3e3,
|
|
2333
|
+
// A repro is EVIDENCE: no directorial zoom, no spotlights, no matte — the literal screen.
|
|
2334
|
+
cameraEmphasis: false,
|
|
2335
|
+
stage: false
|
|
2336
|
+
},
|
|
2337
|
+
"before-after": { kind: "before-after", ...BASE, pacing: "normal", chapterCards: true, numberedCaptions: false },
|
|
2338
|
+
release: { kind: "release", ...BASE, pacing: "brisk", numberedCaptions: false, outroMs: 1400 },
|
|
2339
|
+
social: { kind: "social", ...BASE, pacing: "brisk", numberedCaptions: false, outroMs: 1400 }
|
|
2340
|
+
};
|
|
2341
|
+
function kindProfile(kind) {
|
|
2342
|
+
return PROFILES[kind] ?? PROFILES.walkthrough;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/capture/events.ts
|
|
2346
|
+
function actionEvents(log2) {
|
|
2347
|
+
return log2.events.filter((e) => e.type === "action");
|
|
2348
|
+
}
|
|
2349
|
+
function navigationEvents(log2) {
|
|
2350
|
+
return log2.events.filter((e) => e.type === "navigation");
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// src/audio/timing.ts
|
|
2354
|
+
var AUDIO_LEAD_MS = 150;
|
|
2355
|
+
var AUDIO_TAIL_MS = 450;
|
|
2356
|
+
|
|
2357
|
+
// src/compose/timeline/pacing.ts
|
|
2358
|
+
function pacingConfig(mode, kind) {
|
|
2359
|
+
const factor = mode === "relaxed" ? 1.25 : mode === "brisk" ? 0.8 : 1;
|
|
2360
|
+
return {
|
|
2361
|
+
factor,
|
|
2362
|
+
titleCardMs: 2200,
|
|
2363
|
+
introMaxMs: 1200,
|
|
2364
|
+
outroMs: kind?.outroMs ?? 1600,
|
|
2365
|
+
idleGapMaxMs: 1500,
|
|
2366
|
+
idleGapOutMs: 400,
|
|
2367
|
+
minStepMs: 1800 * factor,
|
|
2368
|
+
maxTypeOutMs: 4500,
|
|
2369
|
+
holds: {
|
|
2370
|
+
goto: 1200 * factor,
|
|
2371
|
+
click: 600 * factor,
|
|
2372
|
+
dblclick: 600 * factor,
|
|
2373
|
+
hover: 450 * factor,
|
|
2374
|
+
type: 450 * factor,
|
|
2375
|
+
press: 450 * factor,
|
|
2376
|
+
select: 600 * factor,
|
|
2377
|
+
scroll: 350 * factor,
|
|
2378
|
+
expect: 800 * factor,
|
|
2379
|
+
wait: 150
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
function buildTimeline(log2, cfg, audioMs) {
|
|
2384
|
+
const events = actionEvents(log2);
|
|
2385
|
+
const segments = [];
|
|
2386
|
+
const steps = [];
|
|
2387
|
+
segments.push({ kind: "card", outStart: 0, outEnd: cfg.titleCardMs, cardId: "title" });
|
|
2388
|
+
let out = cfg.titleCardMs;
|
|
2389
|
+
let src = events.length > 0 ? Math.max(0, events[0].tStart - cfg.introMaxMs) : 0;
|
|
2390
|
+
for (const e of events) {
|
|
2391
|
+
const gap = e.tStart - src;
|
|
2392
|
+
if (gap > 1) {
|
|
2393
|
+
const outDur = gap > cfg.idleGapMaxMs ? cfg.idleGapOutMs : gap;
|
|
2394
|
+
segments.push({ kind: "source", outStart: out, outEnd: out + outDur, srcStart: src, srcEnd: e.tStart });
|
|
2395
|
+
out += outDur;
|
|
2396
|
+
}
|
|
2397
|
+
const stepOutStart = out;
|
|
2398
|
+
const srcEnd = Math.max(e.tSettled, e.tActionEnd, e.tStart + 1);
|
|
2399
|
+
const spanSrc = srcEnd - e.tStart;
|
|
2400
|
+
let spanOut = spanSrc;
|
|
2401
|
+
if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) spanOut = cfg.maxTypeOutMs;
|
|
2402
|
+
segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
|
|
2403
|
+
const outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
|
|
2404
|
+
out += spanOut;
|
|
2405
|
+
let hold = cfg.holds[e.kind];
|
|
2406
|
+
if (out + hold - stepOutStart < cfg.minStepMs) hold = cfg.minStepMs - (out - stepOutStart);
|
|
2407
|
+
const aud = audioMs?.get(`${e.sceneId}/${e.stepIndex}`);
|
|
2408
|
+
if (aud && aud > 0) {
|
|
2409
|
+
const need = AUDIO_LEAD_MS + aud + AUDIO_TAIL_MS;
|
|
2410
|
+
const have = out - stepOutStart + hold;
|
|
2411
|
+
if (have < need) hold += need - have;
|
|
2412
|
+
}
|
|
2413
|
+
if (e.focusTarget && e.focus && e.focus !== "target" && e.focus !== "wide") {
|
|
2414
|
+
const needAfterBeat = 2100;
|
|
2415
|
+
const haveAfterBeat = out + hold - outBeat;
|
|
2416
|
+
if (haveAfterBeat < needAfterBeat) hold += needAfterBeat - haveAfterBeat;
|
|
2417
|
+
}
|
|
2418
|
+
if (hold > 0.5) {
|
|
2419
|
+
segments.push({ kind: "freeze", outStart: out, outEnd: out + hold, srcAt: srcEnd });
|
|
2420
|
+
out += hold;
|
|
2421
|
+
}
|
|
2422
|
+
steps.push({
|
|
2423
|
+
stepRef: `${e.sceneId}/${e.stepIndex}`,
|
|
2424
|
+
sceneId: e.sceneId,
|
|
2425
|
+
ordinal: e.ordinal,
|
|
2426
|
+
kind: e.kind,
|
|
2427
|
+
...e.locator ? { locator: e.locator } : {},
|
|
2428
|
+
...e.masked ? { masked: true } : {},
|
|
2429
|
+
...e.targetPre ? { targetRectVp: e.targetPre.bbox } : {},
|
|
2430
|
+
...e.focus ? { focus: e.focus } : {},
|
|
2431
|
+
...e.shot ? { shot: e.shot } : {},
|
|
2432
|
+
...e.focusTarget ? { focusRectVp: e.focusTarget.bbox } : {},
|
|
2433
|
+
outStart: stepOutStart,
|
|
2434
|
+
outBeat,
|
|
2435
|
+
outEnd: out,
|
|
2436
|
+
srcAction: e.tAction,
|
|
2437
|
+
srcSettled: srcEnd
|
|
2438
|
+
});
|
|
2439
|
+
src = srcEnd;
|
|
2440
|
+
}
|
|
2441
|
+
const lastSrc = Math.max(src, log2.meta.durationMs - 1);
|
|
2442
|
+
segments.push({ kind: "freeze", outStart: out, outEnd: out + cfg.outroMs, srcAt: lastSrc });
|
|
2443
|
+
out += cfg.outroMs;
|
|
2444
|
+
return { segments, steps, durationMs: out };
|
|
2445
|
+
}
|
|
2446
|
+
function sampleSource(segments, tOut) {
|
|
2447
|
+
const seg = segmentAt(segments, tOut);
|
|
2448
|
+
switch (seg.kind) {
|
|
2449
|
+
case "card":
|
|
2450
|
+
return { srcAt: null, segment: seg };
|
|
2451
|
+
case "freeze":
|
|
2452
|
+
return { srcAt: seg.srcAt, segment: seg };
|
|
2453
|
+
case "source": {
|
|
2454
|
+
const u = (tOut - seg.outStart) / Math.max(1e-6, seg.outEnd - seg.outStart);
|
|
2455
|
+
return { srcAt: seg.srcStart + u * (seg.srcEnd - seg.srcStart), segment: seg };
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
function segmentAt(segments, tOut) {
|
|
2460
|
+
let lo = 0;
|
|
2461
|
+
let hi = segments.length - 1;
|
|
2462
|
+
if (tOut <= segments[0].outStart) return segments[0];
|
|
2463
|
+
if (tOut >= segments[hi].outEnd) return segments[hi];
|
|
2464
|
+
while (lo < hi) {
|
|
2465
|
+
const mid = lo + hi >> 1;
|
|
2466
|
+
if (segments[mid].outEnd <= tOut) lo = mid + 1;
|
|
2467
|
+
else hi = mid;
|
|
2468
|
+
}
|
|
2469
|
+
return segments[lo];
|
|
2470
|
+
}
|
|
2471
|
+
function eventByStepRef(log2) {
|
|
2472
|
+
const map = /* @__PURE__ */ new Map();
|
|
2473
|
+
for (const e of actionEvents(log2)) map.set(`${e.sceneId}/${e.stepIndex}`, e);
|
|
2474
|
+
return map;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// src/compose/camera/interpolate.ts
|
|
2478
|
+
function sampleCamera(keyframes, tOut) {
|
|
2479
|
+
if (keyframes.length === 0) return { cx: 0, cy: 0, zoom: 1 };
|
|
2480
|
+
if (tOut <= keyframes[0].tOut) return { ...keyframes[0].state };
|
|
2481
|
+
const last = keyframes[keyframes.length - 1];
|
|
2482
|
+
if (tOut >= last.tOut) return { ...last.state };
|
|
2483
|
+
let lo = 0;
|
|
2484
|
+
let hi = keyframes.length - 1;
|
|
2485
|
+
while (lo < hi - 1) {
|
|
2486
|
+
const mid = lo + hi >> 1;
|
|
2487
|
+
if (keyframes[mid].tOut <= tOut) lo = mid;
|
|
2488
|
+
else hi = mid;
|
|
2489
|
+
}
|
|
2490
|
+
const a = keyframes[lo];
|
|
2491
|
+
const b = keyframes[hi];
|
|
2492
|
+
const u = (tOut - a.tOut) / Math.max(1e-6, b.tOut - a.tOut);
|
|
2493
|
+
const e = cubicBezier(b.ease, u);
|
|
2494
|
+
return {
|
|
2495
|
+
cx: a.state.cx + (b.state.cx - a.state.cx) * e,
|
|
2496
|
+
cy: a.state.cy + (b.state.cy - a.state.cy) * e,
|
|
2497
|
+
zoom: Math.exp(Math.log(a.state.zoom) + (Math.log(b.state.zoom) - Math.log(a.state.zoom)) * e)
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// src/compose/camera/planner.ts
|
|
2502
|
+
var FOCUS_PAD_VP = 24;
|
|
2503
|
+
var SHOT_MAX_GAP_MS = 4e3;
|
|
2504
|
+
var PAN_DEADBAND_VP = 48;
|
|
2505
|
+
var TRAVEL_MIN_MS = 500;
|
|
2506
|
+
var TRAVEL_MAX_MS = 900;
|
|
2507
|
+
var ARRIVE_LEAD_MS = 80;
|
|
2508
|
+
var LEGIBLE_TARGET_PX = 34;
|
|
2509
|
+
function planCamera(steps, log2, profile, durationMs, opts) {
|
|
2510
|
+
const content = stageContent(profile);
|
|
2511
|
+
const plane2 = {
|
|
2512
|
+
vpW: log2.meta.viewport.w,
|
|
2513
|
+
vpH: log2.meta.viewport.h,
|
|
2514
|
+
outW: content.w,
|
|
2515
|
+
outH: content.h
|
|
2516
|
+
};
|
|
2517
|
+
const emphasis = opts?.emphasis ?? true;
|
|
2518
|
+
const fallbacks = [];
|
|
2519
|
+
const sharpZoom = log2.meta.frameW > 0 ? log2.meta.frameW / content.w : 1.3;
|
|
2520
|
+
const zMaxAllowed = Math.max(minZoom(plane2), sharpZoom * 1.28);
|
|
2521
|
+
const quantums = profile.zoomQuantums.filter((q) => q <= zMaxAllowed + 1e-6);
|
|
2522
|
+
if (quantums.length === 0) quantums.push(1);
|
|
2523
|
+
const navTimes = navigationEvents(log2).map((n) => n.t);
|
|
2524
|
+
const shots = groupShots(steps, navTimes, plane2, durationMs);
|
|
2525
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
2526
|
+
for (const shot2 of shots) shot2.state = shotState(shot2, quantums, plane2, profile, emphasis);
|
|
2527
|
+
const keyframes = emitKeyframes(shots, plane2, durationMs);
|
|
2528
|
+
const violation = checkConstraints(keyframes, steps, plane2, profile);
|
|
2529
|
+
if (!violation) return { keyframes, fallbacks };
|
|
2530
|
+
const shot = shots.find(
|
|
2531
|
+
(s) => s.stepRefs.includes(violation.stepRef) && violation.tOut >= s.outStart && violation.tOut <= s.outEnd
|
|
2532
|
+
) ?? shots.find((s) => s.stepRefs.includes(violation.stepRef));
|
|
2533
|
+
if (!shot || shot.zoomIndex === 0 || shot.wide) {
|
|
2534
|
+
fallbacks.push(`${violation.stepRef}: unresolvable framing violation \u2014 falling back to wide`);
|
|
2535
|
+
if (shot) shot.wide = true;
|
|
2536
|
+
else break;
|
|
2537
|
+
} else {
|
|
2538
|
+
shot.zoomIndex -= 1;
|
|
2539
|
+
fallbacks.push(`${violation.stepRef}: reduced shot zoom to satisfy framing constraints`);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
for (const shot of shots) shot.state = shotState(shot, quantums, plane2, profile, emphasis);
|
|
2543
|
+
return { keyframes: emitKeyframes(shots, plane2, durationMs), fallbacks };
|
|
2544
|
+
}
|
|
2545
|
+
function groupShots(steps, navTimes, plane2, durationMs) {
|
|
2546
|
+
const shots = [];
|
|
2547
|
+
let current = null;
|
|
2548
|
+
const navBetween = (a, b) => navTimes.some((t) => t > a && t <= b);
|
|
2549
|
+
for (const step of steps) {
|
|
2550
|
+
const handoff = actReactWindows(step);
|
|
2551
|
+
if (handoff) {
|
|
2552
|
+
const actFocus = padRect(handoff.act.rect, FOCUS_PAD_VP);
|
|
2553
|
+
const reactFocus = padRect(handoff.react.rect, FOCUS_PAD_VP);
|
|
2554
|
+
shots.push({
|
|
2555
|
+
stepRefs: [step.stepRef],
|
|
2556
|
+
sceneId: step.sceneId,
|
|
2557
|
+
focus: actFocus,
|
|
2558
|
+
minTargetDim: Math.min(handoff.act.rect.w, handoff.act.rect.h),
|
|
2559
|
+
wide: false,
|
|
2560
|
+
outStart: step.outStart,
|
|
2561
|
+
firstBeat: step.outBeat,
|
|
2562
|
+
outEnd: handoff.act.to,
|
|
2563
|
+
zoomIndex: Number.MAX_SAFE_INTEGER
|
|
2564
|
+
});
|
|
2565
|
+
shots.push({
|
|
2566
|
+
stepRefs: [step.stepRef],
|
|
2567
|
+
sceneId: step.sceneId,
|
|
2568
|
+
focus: reactFocus,
|
|
2569
|
+
minTargetDim: Math.min(handoff.react.rect.w, handoff.react.rect.h),
|
|
2570
|
+
wide: false,
|
|
2571
|
+
outStart: handoff.act.to,
|
|
2572
|
+
firstBeat: handoff.react.from,
|
|
2573
|
+
// arrive by the react window's start
|
|
2574
|
+
outEnd: step.outEnd,
|
|
2575
|
+
zoomIndex: Number.MAX_SAFE_INTEGER
|
|
2576
|
+
});
|
|
2577
|
+
current = null;
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
let rect = step.kind === "goto" ? void 0 : step.targetRectVp;
|
|
2581
|
+
let wide = !rect || step.kind === "goto" || step.kind === "scroll";
|
|
2582
|
+
if (step.focus === "wide") {
|
|
2583
|
+
wide = true;
|
|
2584
|
+
} else if (step.focusRectVp) {
|
|
2585
|
+
rect = step.focusRectVp;
|
|
2586
|
+
wide = false;
|
|
2587
|
+
} else if (step.focus === "target" && rect) {
|
|
2588
|
+
wide = false;
|
|
2589
|
+
}
|
|
2590
|
+
if (step.shot === "cut") current = null;
|
|
2591
|
+
const forceContinue = step.shot === "continue";
|
|
2592
|
+
if (forceContinue && current && rect && current.sceneId === step.sceneId) {
|
|
2593
|
+
current.stepRefs.push(step.stepRef);
|
|
2594
|
+
current.focus = rectUnion(current.focus, padRect(rect, FOCUS_PAD_VP));
|
|
2595
|
+
current.minTargetDim = Math.min(current.minTargetDim, Math.min(rect.w, rect.h));
|
|
2596
|
+
current.outEnd = step.outEnd;
|
|
2597
|
+
continue;
|
|
2598
|
+
}
|
|
2599
|
+
const noMerge = step.focus === "target" || step.focusRectVp !== void 0;
|
|
2600
|
+
if (current && !noMerge && current.sceneId === step.sceneId && !current.wide && !wide && rect) {
|
|
2601
|
+
const prevSrc = current.stepRefs.length > 0 ? steps.find((s) => s.stepRef === current.stepRefs[current.stepRefs.length - 1]).srcSettled : 0;
|
|
2602
|
+
const union = rectUnion(current.focus, padRect(rect, FOCUS_PAD_VP));
|
|
2603
|
+
const baseScale = plane2.outW / plane2.vpW;
|
|
2604
|
+
const mergedMinDim = Math.min(current.minTargetDim, Math.min(rect.w, rect.h));
|
|
2605
|
+
const zNeeded = Math.min(LEGIBLE_TARGET_PX / (mergedMinDim * baseScale), 1.6);
|
|
2606
|
+
const zFitUnion = fitZoom(union, plane2) * 0.92;
|
|
2607
|
+
const fitsTogether = fitZoom(union, plane2) >= 1.2 && zFitUnion >= zNeeded - 0.02;
|
|
2608
|
+
const closeInTime = step.outStart - current.outEnd < SHOT_MAX_GAP_MS;
|
|
2609
|
+
if (fitsTogether && closeInTime && !navBetween(prevSrc, step.srcAction)) {
|
|
2610
|
+
current.stepRefs.push(step.stepRef);
|
|
2611
|
+
current.focus = union;
|
|
2612
|
+
current.minTargetDim = Math.min(current.minTargetDim, Math.min(rect.w, rect.h));
|
|
2613
|
+
current.outEnd = step.outEnd;
|
|
2614
|
+
continue;
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
current = {
|
|
2618
|
+
stepRefs: [step.stepRef],
|
|
2619
|
+
sceneId: step.sceneId,
|
|
2620
|
+
focus: rect ? padRect(rect, FOCUS_PAD_VP) : { x: 0, y: 0, w: plane2.vpW, h: plane2.vpH },
|
|
2621
|
+
minTargetDim: rect ? Math.min(rect.w, rect.h) : Math.min(plane2.vpW, plane2.vpH),
|
|
2622
|
+
wide,
|
|
2623
|
+
outStart: step.outStart,
|
|
2624
|
+
firstBeat: step.outBeat,
|
|
2625
|
+
outEnd: step.outEnd,
|
|
2626
|
+
zoomIndex: Number.MAX_SAFE_INTEGER
|
|
2627
|
+
// resolved in shotState
|
|
2628
|
+
};
|
|
2629
|
+
shots.push(current);
|
|
2630
|
+
}
|
|
2631
|
+
if (shots.length > 0) shots[shots.length - 1].outEnd = Math.min(shots[shots.length - 1].outEnd, durationMs);
|
|
2632
|
+
return shots;
|
|
2633
|
+
}
|
|
2634
|
+
function fitZoom(rect, plane2) {
|
|
2635
|
+
const zw = plane2.vpW / rect.w;
|
|
2636
|
+
const zh = plane2.vpW * plane2.outH / plane2.outW / rect.h;
|
|
2637
|
+
return Math.min(zw, zh);
|
|
2638
|
+
}
|
|
2639
|
+
var OCCUPANCY = 0.5;
|
|
2640
|
+
function shotState(shot, quantums, plane2, profile, emphasis) {
|
|
2641
|
+
const zMin = minZoom(plane2);
|
|
2642
|
+
if (shot.wide) {
|
|
2643
|
+
return clampCamera({ cx: plane2.vpW / 2, cy: rectCenter(shot.focus).y, zoom: zMin }, plane2);
|
|
2644
|
+
}
|
|
2645
|
+
const baseScale = plane2.outW / plane2.vpW;
|
|
2646
|
+
const minDim = Math.max(6, shot.minTargetDim);
|
|
2647
|
+
const zLegibility = LEGIBLE_TARGET_PX / (minDim * baseScale);
|
|
2648
|
+
const zFit = fitZoom(shot.focus, plane2) * 0.92;
|
|
2649
|
+
const zComposition = emphasis ? OCCUPANCY * fitZoom(shot.focus, plane2) : 0;
|
|
2650
|
+
const zWant = Math.max(zLegibility, zComposition);
|
|
2651
|
+
const maxFit = Math.min(zFit, quantums[quantums.length - 1]);
|
|
2652
|
+
let zoomIndex = 0;
|
|
2653
|
+
let reachedWant = false;
|
|
2654
|
+
for (let i = 0; i < quantums.length; i++) {
|
|
2655
|
+
if (quantums[i] > maxFit + 1e-6) break;
|
|
2656
|
+
zoomIndex = i;
|
|
2657
|
+
if (!reachedWant && quantums[i] >= zWant - 1e-6) {
|
|
2658
|
+
zoomIndex = i;
|
|
2659
|
+
reachedWant = true;
|
|
2660
|
+
break;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
shot.zoomIndex = Math.min(shot.zoomIndex, zoomIndex);
|
|
2664
|
+
const zoom = Math.max(quantums[shot.zoomIndex], zMin);
|
|
2665
|
+
const center = rectCenter(shot.focus);
|
|
2666
|
+
let state = clampCamera({ cx: center.x, cy: center.y, zoom }, plane2);
|
|
2667
|
+
for (let i = 0; i < 3; i++) {
|
|
2668
|
+
const proj = projectRect(shot.focus, state, plane2);
|
|
2669
|
+
const safe = contentArea(profile);
|
|
2670
|
+
let dyOut = 0;
|
|
2671
|
+
if (proj.y + proj.h > safe.y + safe.h) dyOut = proj.y + proj.h - (safe.y + safe.h);
|
|
2672
|
+
else if (proj.y < safe.y) dyOut = proj.y - safe.y;
|
|
2673
|
+
let dxOut = 0;
|
|
2674
|
+
if (proj.x + proj.w > safe.x + safe.w) dxOut = proj.x + proj.w - (safe.x + safe.w);
|
|
2675
|
+
else if (proj.x < safe.x) dxOut = proj.x - safe.x;
|
|
2676
|
+
if (Math.abs(dxOut) < 1 && Math.abs(dyOut) < 1) break;
|
|
2677
|
+
const visW = plane2.vpW / state.zoom;
|
|
2678
|
+
const scale = visW / plane2.outW;
|
|
2679
|
+
state = clampCamera({ cx: state.cx + dxOut * scale, cy: state.cy + dyOut * scale, zoom: state.zoom }, plane2);
|
|
2680
|
+
}
|
|
2681
|
+
return state;
|
|
2682
|
+
}
|
|
2683
|
+
function contentArea(profile) {
|
|
2684
|
+
const s = stageContent(profile);
|
|
2685
|
+
const left = Math.max(profile.safeArea.left - s.x, 8);
|
|
2686
|
+
const top = Math.max(profile.safeArea.top - s.y, 8);
|
|
2687
|
+
const right = Math.min(s.w - 8, profile.width - profile.safeArea.right - s.x);
|
|
2688
|
+
const bottom = Math.min(s.h - 8, profile.height - profile.safeArea.bottom - s.y);
|
|
2689
|
+
return { x: left, y: top, w: Math.max(16, right - left), h: Math.max(16, bottom - top) };
|
|
2690
|
+
}
|
|
2691
|
+
function emitKeyframes(shots, plane2, durationMs) {
|
|
2692
|
+
const kfs = [];
|
|
2693
|
+
const wideState = clampCamera({ cx: plane2.vpW / 2, cy: plane2.vpH / 2, zoom: minZoom(plane2) }, plane2);
|
|
2694
|
+
let prevState = wideState;
|
|
2695
|
+
let prevEnd = 0;
|
|
2696
|
+
kfs.push({ tOut: 0, state: wideState, ease: EASE_ZOOM });
|
|
2697
|
+
for (const shot of shots) {
|
|
2698
|
+
const target = shot.state;
|
|
2699
|
+
const panDist = Math.hypot(target.cx - prevState.cx, target.cy - prevState.cy);
|
|
2700
|
+
const zoomChanged = Math.abs(Math.log(target.zoom / prevState.zoom)) > 0.01;
|
|
2701
|
+
if (panDist < PAN_DEADBAND_VP / 2 && !zoomChanged) {
|
|
2702
|
+
prevEnd = shot.outEnd;
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
const viaWide = panDist > plane2.vpW * 0.6 && prevState.zoom > 1.15 && target.zoom > 1.15 ? wideState : null;
|
|
2706
|
+
const arriveBy = Math.max(shot.firstBeat - ARRIVE_LEAD_MS, shot.outStart + 100);
|
|
2707
|
+
const travel = clamp(300 + panDist * 0.8, TRAVEL_MIN_MS, TRAVEL_MAX_MS);
|
|
2708
|
+
let moveStart = Math.max(prevEnd, arriveBy - travel);
|
|
2709
|
+
if (viaWide) {
|
|
2710
|
+
const mid = Math.max(prevEnd, moveStart - 350);
|
|
2711
|
+
kfs.push({ tOut: mid, state: prevState, ease: EASE_PAN });
|
|
2712
|
+
kfs.push({ tOut: Math.min(mid + 400, arriveBy - 200), state: viaWide, ease: EASE_ZOOM });
|
|
2713
|
+
moveStart = Math.min(mid + 400, arriveBy - 200);
|
|
2714
|
+
} else {
|
|
2715
|
+
kfs.push({ tOut: moveStart, state: prevState, ease: EASE_PAN });
|
|
2716
|
+
}
|
|
2717
|
+
const moveEnd = Math.max(arriveBy, moveStart + 250);
|
|
2718
|
+
kfs.push({ tOut: moveEnd, state: target, ease: zoomChanged ? EASE_ZOOM : EASE_PAN });
|
|
2719
|
+
prevState = target;
|
|
2720
|
+
prevEnd = shot.outEnd;
|
|
2721
|
+
}
|
|
2722
|
+
if (prevState.zoom > wideState.zoom + 0.01 || Math.hypot(prevState.cx - wideState.cx, prevState.cy - wideState.cy) > PAN_DEADBAND_VP) {
|
|
2723
|
+
const start = Math.min(prevEnd + 100, durationMs - 900);
|
|
2724
|
+
kfs.push({ tOut: start, state: prevState, ease: EASE_ZOOM });
|
|
2725
|
+
kfs.push({ tOut: Math.min(start + 800, durationMs - 50), state: wideState, ease: EASE_ZOOM });
|
|
2726
|
+
}
|
|
2727
|
+
return dedupeSorted(kfs);
|
|
2728
|
+
}
|
|
2729
|
+
function dedupeSorted(kfs) {
|
|
2730
|
+
kfs.sort((a, b) => a.tOut - b.tOut);
|
|
2731
|
+
const out = [];
|
|
2732
|
+
for (const kf of kfs) {
|
|
2733
|
+
const last = out[out.length - 1];
|
|
2734
|
+
if (last && kf.tOut - last.tOut < 1) {
|
|
2735
|
+
out[out.length - 1] = kf;
|
|
2736
|
+
} else {
|
|
2737
|
+
out.push(kf);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
return out;
|
|
2741
|
+
}
|
|
2742
|
+
function checkConstraints(keyframes, steps, plane2, profile) {
|
|
2743
|
+
const content = stageContent(profile);
|
|
2744
|
+
const frame = { x: 0, y: 0, w: content.w, h: content.h };
|
|
2745
|
+
for (const step of steps) {
|
|
2746
|
+
const handoff = actReactWindows(step);
|
|
2747
|
+
const rect = step.focusRectVp ?? step.targetRectVp;
|
|
2748
|
+
const windows = handoff ? [handoff.act, handoff.react] : rect ? [{ rect, from: Math.max(step.outStart, step.outBeat - 60), to: step.outEnd }] : [];
|
|
2749
|
+
for (const w of windows) {
|
|
2750
|
+
for (let t = w.from; t <= w.to; t += 100) {
|
|
2751
|
+
const cam = sampleCamera(keyframes, t);
|
|
2752
|
+
const proj = projectRect(w.rect, cam, plane2);
|
|
2753
|
+
if (!rectContains(frame, proj, 2)) {
|
|
2754
|
+
return { stepRef: step.stepRef, tOut: t };
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/compose/cursor/path.ts
|
|
2763
|
+
var RESAMPLE_POINTS = 32;
|
|
2764
|
+
var CLICK_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
|
|
2765
|
+
var POINTER_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "hover", "type", "select"]);
|
|
2766
|
+
function planCursor(steps, log2) {
|
|
2767
|
+
const events = eventByStepRef(log2);
|
|
2768
|
+
const segments = [];
|
|
2769
|
+
const ripples = [];
|
|
2770
|
+
let rest = { x: log2.meta.viewport.w / 2, y: log2.meta.viewport.h * 0.6 };
|
|
2771
|
+
const restStart = { ...rest };
|
|
2772
|
+
for (const step of steps) {
|
|
2773
|
+
if (!POINTER_KINDS.has(step.kind)) continue;
|
|
2774
|
+
const event = events.get(step.stepRef);
|
|
2775
|
+
if (!event || event.cursorPath.length < 2) continue;
|
|
2776
|
+
const raw = event.cursorPath.map((w) => ({ x: w.x, y: w.y }));
|
|
2777
|
+
raw[0] = { ...rest };
|
|
2778
|
+
const path = resampleByArcLength(raw, RESAMPLE_POINTS);
|
|
2779
|
+
const travelDist = dist(path[0], path[path.length - 1]);
|
|
2780
|
+
const travel = clamp(300 + travelDist * 0.6, 300, 900);
|
|
2781
|
+
const prevEnd = segments.length > 0 ? segments[segments.length - 1].tEnd : 0;
|
|
2782
|
+
const tEnd = step.outBeat;
|
|
2783
|
+
const tStart = Math.max(Math.min(step.outStart, tEnd - 100), tEnd - travel, prevEnd + 40);
|
|
2784
|
+
if (tEnd - tStart > 30) {
|
|
2785
|
+
segments.push({ tStart, tEnd, path, clickAtEnd: CLICK_KINDS.has(step.kind) });
|
|
2786
|
+
}
|
|
2787
|
+
rest = path[path.length - 1];
|
|
2788
|
+
if (CLICK_KINDS.has(step.kind)) {
|
|
2789
|
+
ripples.push({ tOut: step.outBeat, x: rest.x, y: rest.y });
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
return { segments, ripples, restStart };
|
|
2793
|
+
}
|
|
2794
|
+
function sampleCursor(plan2, tOut) {
|
|
2795
|
+
const segs = plan2.segments;
|
|
2796
|
+
if (segs.length === 0) return plan2.restStart;
|
|
2797
|
+
if (tOut <= segs[0].tStart) return segs[0].path[0];
|
|
2798
|
+
for (let i = 0; i < segs.length; i++) {
|
|
2799
|
+
const s = segs[i];
|
|
2800
|
+
if (tOut < s.tStart) return segs[i - 1].path[segs[i - 1].path.length - 1];
|
|
2801
|
+
if (tOut <= s.tEnd) {
|
|
2802
|
+
const u = minJerk((tOut - s.tStart) / Math.max(1e-6, s.tEnd - s.tStart));
|
|
2803
|
+
const fi = u * (s.path.length - 1);
|
|
2804
|
+
const i0 = Math.floor(fi);
|
|
2805
|
+
const i1 = Math.min(i0 + 1, s.path.length - 1);
|
|
2806
|
+
const f = fi - i0;
|
|
2807
|
+
return {
|
|
2808
|
+
x: s.path[i0].x + (s.path[i1].x - s.path[i0].x) * f,
|
|
2809
|
+
y: s.path[i0].y + (s.path[i1].y - s.path[i0].y) * f
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const last = segs[segs.length - 1];
|
|
2814
|
+
return last.path[last.path.length - 1];
|
|
2815
|
+
}
|
|
2816
|
+
function resampleByArcLength(points, n) {
|
|
2817
|
+
const cum = [0];
|
|
2818
|
+
for (let i = 1; i < points.length; i++) {
|
|
2819
|
+
cum.push(cum[i - 1] + dist(points[i - 1], points[i]));
|
|
2820
|
+
}
|
|
2821
|
+
const total = cum[cum.length - 1];
|
|
2822
|
+
if (total < 1e-6) return Array.from({ length: n }, () => ({ ...points[0] }));
|
|
2823
|
+
const out = [];
|
|
2824
|
+
let j = 0;
|
|
2825
|
+
for (let i = 0; i < n; i++) {
|
|
2826
|
+
const target = i / (n - 1) * total;
|
|
2827
|
+
while (j < cum.length - 2 && cum[j + 1] < target) j++;
|
|
2828
|
+
const span = Math.max(1e-6, cum[j + 1] - cum[j]);
|
|
2829
|
+
const f = (target - cum[j]) / span;
|
|
2830
|
+
out.push({
|
|
2831
|
+
x: points[j].x + (points[j + 1].x - points[j].x) * f,
|
|
2832
|
+
y: points[j].y + (points[j + 1].y - points[j].y) * f
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
return out;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// src/compose/captions.ts
|
|
2839
|
+
function writeCaption(event, texts) {
|
|
2840
|
+
if (texts?.caption) return texts.caption;
|
|
2841
|
+
if (event.caption) return event.caption;
|
|
2842
|
+
const label = bestLabel(event);
|
|
2843
|
+
switch (event.kind) {
|
|
2844
|
+
case "click":
|
|
2845
|
+
return label ? `Click \u201C${label}\u201D` : "Click the highlighted element";
|
|
2846
|
+
case "dblclick":
|
|
2847
|
+
return label ? `Double-click \u201C${label}\u201D` : "Double-click the highlighted element";
|
|
2848
|
+
case "hover":
|
|
2849
|
+
return label ? `Hover over \u201C${label}\u201D` : "Hover over the highlighted element";
|
|
2850
|
+
case "type":
|
|
2851
|
+
return label ? `Enter the ${lowerFirst(label)}` : "Enter a value";
|
|
2852
|
+
case "press":
|
|
2853
|
+
return `Press ${event.locator ?? "the key"}`;
|
|
2854
|
+
case "select":
|
|
2855
|
+
return label && event.typedText ? `Choose \u201C${event.typedText}\u201D under ${label}` : label ? `Choose an option under ${label}` : "Choose an option";
|
|
2856
|
+
case "goto":
|
|
2857
|
+
return `Open ${prettyUrl(event.locator ?? "")}`;
|
|
2858
|
+
case "scroll":
|
|
2859
|
+
return label ? `Scroll to ${label}` : "Scroll down";
|
|
2860
|
+
case "expect":
|
|
2861
|
+
case "wait":
|
|
2862
|
+
return null;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
function writeNarration(event, texts) {
|
|
2866
|
+
return texts?.narration ?? event.narration ?? writeCaption(event, texts);
|
|
2867
|
+
}
|
|
2868
|
+
function bestLabel(event) {
|
|
2869
|
+
const name = event.targetPre?.accessibleName?.trim();
|
|
2870
|
+
if (name) return truncate(name, 60);
|
|
2871
|
+
if (event.locator) {
|
|
2872
|
+
try {
|
|
2873
|
+
return truncate(locatorDisplayName(parseLocator(event.locator)), 60);
|
|
2874
|
+
} catch {
|
|
2875
|
+
return null;
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
return null;
|
|
2879
|
+
}
|
|
2880
|
+
function lowerFirst(s) {
|
|
2881
|
+
if (s.length > 1 && s[1] === s[1].toUpperCase() && /[A-Z]/.test(s[1])) return s;
|
|
2882
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
2883
|
+
}
|
|
2884
|
+
function prettyUrl(url) {
|
|
2885
|
+
try {
|
|
2886
|
+
const u = new URL(url);
|
|
2887
|
+
return u.host + (u.pathname !== "/" ? u.pathname : "");
|
|
2888
|
+
} catch {
|
|
2889
|
+
return url;
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
function truncate(s, max) {
|
|
2893
|
+
return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
// src/compose/overlays/text.ts
|
|
2897
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
2898
|
+
var measureCtx = null;
|
|
2899
|
+
function ctx() {
|
|
2900
|
+
if (!measureCtx) measureCtx = createCanvas(8, 8).getContext("2d");
|
|
2901
|
+
return measureCtx;
|
|
2902
|
+
}
|
|
2903
|
+
function measureText(text, font) {
|
|
2904
|
+
const c = ctx();
|
|
2905
|
+
c.font = font;
|
|
2906
|
+
return c.measureText(text).width;
|
|
2907
|
+
}
|
|
2908
|
+
function wrapText(text, fontFor, maxWidth, fontPx, minFontPx, maxLines) {
|
|
2909
|
+
let attempt = wrapAt(text, fontFor(fontPx), maxWidth);
|
|
2910
|
+
if (attempt.length <= maxLines) {
|
|
2911
|
+
return { lines: attempt, fontPx, widest: widestOf(attempt, fontFor(fontPx)), shrunk: false, truncated: false };
|
|
2912
|
+
}
|
|
2913
|
+
attempt = wrapAt(text, fontFor(minFontPx), maxWidth);
|
|
2914
|
+
if (attempt.length <= maxLines) {
|
|
2915
|
+
return { lines: attempt, fontPx: minFontPx, widest: widestOf(attempt, fontFor(minFontPx)), shrunk: true, truncated: false };
|
|
2916
|
+
}
|
|
2917
|
+
const kept = attempt.slice(0, maxLines);
|
|
2918
|
+
let last = kept[maxLines - 1];
|
|
2919
|
+
const font = fontFor(minFontPx);
|
|
2920
|
+
while (last.length > 1 && measureText(last + "\u2026", font) > maxWidth) {
|
|
2921
|
+
last = last.slice(0, -1).trimEnd();
|
|
2922
|
+
}
|
|
2923
|
+
kept[maxLines - 1] = last + "\u2026";
|
|
2924
|
+
return { lines: kept, fontPx: minFontPx, widest: widestOf(kept, font), shrunk: true, truncated: true };
|
|
2925
|
+
}
|
|
2926
|
+
function wrapAt(text, font, maxWidth) {
|
|
2927
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
2928
|
+
const lines = [];
|
|
2929
|
+
let line = "";
|
|
2930
|
+
for (const word of words) {
|
|
2931
|
+
const candidate = line ? line + " " + word : word;
|
|
2932
|
+
if (line && measureText(candidate, font) > maxWidth) {
|
|
2933
|
+
lines.push(line);
|
|
2934
|
+
line = word;
|
|
2935
|
+
} else {
|
|
2936
|
+
line = candidate;
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
if (line) lines.push(line);
|
|
2940
|
+
return lines.length > 0 ? lines : [""];
|
|
2941
|
+
}
|
|
2942
|
+
function widestOf(lines, font) {
|
|
2943
|
+
return Math.max(...lines.map((l) => measureText(l, font)));
|
|
2944
|
+
}
|
|
2945
|
+
function roundRect(c, x, y, w, h, r) {
|
|
2946
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
2947
|
+
c.beginPath();
|
|
2948
|
+
c.moveTo(x + rr, y);
|
|
2949
|
+
c.arcTo(x + w, y, x + w, y + h, rr);
|
|
2950
|
+
c.arcTo(x + w, y + h, x, y + h, rr);
|
|
2951
|
+
c.arcTo(x, y + h, x, y, rr);
|
|
2952
|
+
c.arcTo(x, y, x + w, y, rr);
|
|
2953
|
+
c.closePath();
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// src/compose/overlays/build.ts
|
|
2957
|
+
var CARD_PAD_X = 26;
|
|
2958
|
+
var CARD_PAD_Y = 16;
|
|
2959
|
+
var BADGE_GAP = 14;
|
|
2960
|
+
var CARD_BOTTOM_MARGIN = 44;
|
|
2961
|
+
var CHAPTER_LABEL_MS = 1800;
|
|
2962
|
+
var CAPTION_CROSSFADE_MS = 240;
|
|
2963
|
+
function overlaps(a, b) {
|
|
2964
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
2965
|
+
}
|
|
2966
|
+
function buildOverlays(steps, log2, theme, profile, title, subtitle, titleCardEndMs, diagnostics, kind, sceneTitles, camera, specTexts) {
|
|
2967
|
+
const overlays = [];
|
|
2968
|
+
overlays.push({ kind: "titleCard", cardId: "title", title, subtitle, tStart: 0, tEnd: titleCardEndMs });
|
|
2969
|
+
overlays.push({ kind: "dip", tStart: titleCardEndMs, tEnd: titleCardEndMs + 400 });
|
|
2970
|
+
const events = eventByStepRef(log2);
|
|
2971
|
+
const captioned = steps.filter((s) => {
|
|
2972
|
+
const e = events.get(s.stepRef);
|
|
2973
|
+
return e && writeCaption(e, specTexts?.get(s.stepRef)) !== null;
|
|
2974
|
+
});
|
|
2975
|
+
const total = captioned.length;
|
|
2976
|
+
let number = 0;
|
|
2977
|
+
if (kind.chapterCards) {
|
|
2978
|
+
const boundaries = sceneBoundaries(steps, sceneTitles);
|
|
2979
|
+
let chapter = 0;
|
|
2980
|
+
for (const b of boundaries) {
|
|
2981
|
+
chapter += 1;
|
|
2982
|
+
overlays.push({ kind: "chapterLabel", title: b.title, ordinal: chapter, tStart: b.outStart, tEnd: b.outStart + CHAPTER_LABEL_MS });
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (kind.elapsedClock) {
|
|
2986
|
+
const firstBeat = captioned[0]?.outBeat ?? titleCardEndMs;
|
|
2987
|
+
const last = steps[steps.length - 1];
|
|
2988
|
+
overlays.push({ kind: "elapsedClock", tStart: titleCardEndMs, tEnd: last ? last.outEnd : titleCardEndMs, zeroAtMs: firstBeat });
|
|
2989
|
+
}
|
|
2990
|
+
for (let i = 0; theme.captionPosition !== "off" && i < captioned.length; i++) {
|
|
2991
|
+
const step = captioned[i];
|
|
2992
|
+
const event = events.get(step.stepRef);
|
|
2993
|
+
const base = writeCaption(event, specTexts?.get(step.stepRef));
|
|
2994
|
+
number += 1;
|
|
2995
|
+
const text = kind.captionStyle === "factual" ? `Step ${number} \u2014 ${lowerFirst2(base)}` : base;
|
|
2996
|
+
const fontFor = (px) => `${px}px Inter Medium`;
|
|
2997
|
+
const showBadge = kind.numberedCaptions && kind.captionStyle !== "factual";
|
|
2998
|
+
const badgeFontPx = 15;
|
|
2999
|
+
const badgeText = `${number}/${total}`;
|
|
3000
|
+
const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + 20 : 0;
|
|
3001
|
+
const badgeGap = showBadge ? BADGE_GAP : 0;
|
|
3002
|
+
const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - CARD_PAD_X * 2 - badgeW - badgeGap;
|
|
3003
|
+
const wrap = wrapText(text, fontFor, maxTextWidth, theme.captionFontPx, theme.captionMinFontPx, theme.captionMaxLines);
|
|
3004
|
+
if (wrap.truncated) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "truncated" });
|
|
3005
|
+
else if (wrap.shrunk) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "shrunk" });
|
|
3006
|
+
const lineH = Math.round(wrap.fontPx * 1.35);
|
|
3007
|
+
const cardW = CARD_PAD_X * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
|
|
3008
|
+
const cardH = CARD_PAD_Y * 2 + wrap.lines.length * lineH;
|
|
3009
|
+
const x = (profile.width - cardW) / 2;
|
|
3010
|
+
let y = theme.captionPosition === "bottom" ? profile.height - CARD_BOTTOM_MARGIN - cardH : profile.safeArea.top;
|
|
3011
|
+
if (camera && theme.captionPosition === "bottom") {
|
|
3012
|
+
const rect = step.focusRectVp ?? step.targetRectVp;
|
|
3013
|
+
if (rect) {
|
|
3014
|
+
const content = stageContent(profile);
|
|
3015
|
+
const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
|
|
3016
|
+
const cam = sampleCamera(camera, Math.min(step.outBeat + 250, step.outEnd));
|
|
3017
|
+
const proj = projectRect(rect, cam, capPlane);
|
|
3018
|
+
const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
|
|
3019
|
+
const capBox = { x, y, w: cardW, h: cardH };
|
|
3020
|
+
if (overlaps(projFrame, capBox)) y = profile.safeArea.top;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
const next = captioned[i + 1];
|
|
3024
|
+
const tEnd = next ? Math.min(step.outEnd, next.outStart + CAPTION_CROSSFADE_MS) : step.outEnd;
|
|
3025
|
+
const overlay = {
|
|
3026
|
+
kind: "caption",
|
|
3027
|
+
stepRef: step.stepRef,
|
|
3028
|
+
ordinal: number,
|
|
3029
|
+
totalSteps: total,
|
|
3030
|
+
showBadge,
|
|
3031
|
+
text,
|
|
3032
|
+
lines: wrap.lines,
|
|
3033
|
+
fontPx: wrap.fontPx,
|
|
3034
|
+
box: { x, y, w: cardW, h: cardH },
|
|
3035
|
+
tStart: step.outStart,
|
|
3036
|
+
tEnd,
|
|
3037
|
+
shrunk: wrap.shrunk,
|
|
3038
|
+
truncated: wrap.truncated
|
|
3039
|
+
};
|
|
3040
|
+
overlays.push(overlay);
|
|
3041
|
+
}
|
|
3042
|
+
return overlays;
|
|
3043
|
+
}
|
|
3044
|
+
function sceneBoundaries(steps, sceneTitles) {
|
|
3045
|
+
const out = [];
|
|
3046
|
+
let lastScene = "";
|
|
3047
|
+
for (const s of steps) {
|
|
3048
|
+
if (s.sceneId !== lastScene) {
|
|
3049
|
+
lastScene = s.sceneId;
|
|
3050
|
+
out.push({ title: sceneTitles?.get(s.sceneId) ?? titleFromSceneId(s.sceneId), outStart: s.outStart });
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
return out;
|
|
3054
|
+
}
|
|
3055
|
+
function titleFromSceneId(id) {
|
|
3056
|
+
const t = id.replace(/-/g, " ");
|
|
3057
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
3058
|
+
}
|
|
3059
|
+
function lowerFirst2(s) {
|
|
3060
|
+
if (s.length > 1 && s[1] === s[1].toUpperCase() && /[A-Z]/.test(s[1])) return s;
|
|
3061
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
// src/compose/render/renderer.ts
|
|
3065
|
+
import { createCanvas as createCanvas3 } from "@napi-rs/canvas";
|
|
3066
|
+
|
|
3067
|
+
// src/compose/cursor/render.ts
|
|
3068
|
+
var RIPPLE_MS = 350;
|
|
3069
|
+
var PRESS_DOWN_MS = 90;
|
|
3070
|
+
var PRESS_UP_MS = 120;
|
|
3071
|
+
function drawCursor(ctx2, p, zoom, tOut, ripples, rippleCenters, theme, baseScale = 1, glyph = "arrow") {
|
|
3072
|
+
let scale = 1;
|
|
3073
|
+
for (const r of ripples) {
|
|
3074
|
+
const dt = tOut - r.tOut;
|
|
3075
|
+
if (dt >= -PRESS_DOWN_MS && dt < 0) scale = 1 - 0.15 * (1 + dt / PRESS_DOWN_MS);
|
|
3076
|
+
else if (dt >= 0 && dt <= PRESS_UP_MS) scale = 0.85 + 0.15 * (dt / PRESS_UP_MS);
|
|
3077
|
+
}
|
|
3078
|
+
for (let i = 0; i < ripples.length; i++) {
|
|
3079
|
+
const r = ripples[i];
|
|
3080
|
+
const dt = tOut - r.tOut;
|
|
3081
|
+
if (dt < 0 || dt > RIPPLE_MS) continue;
|
|
3082
|
+
const u = dt / RIPPLE_MS;
|
|
3083
|
+
const c = rippleCenters[i];
|
|
3084
|
+
ctx2.save();
|
|
3085
|
+
ctx2.beginPath();
|
|
3086
|
+
ctx2.arc(c.x, c.y, (6 + 42 * easeOut(u)) * baseScale, 0, Math.PI * 2);
|
|
3087
|
+
ctx2.strokeStyle = theme.accent;
|
|
3088
|
+
ctx2.globalAlpha = 0.6 * (1 - u);
|
|
3089
|
+
ctx2.lineWidth = 3.5 * baseScale * (1 - u * 0.5);
|
|
3090
|
+
ctx2.stroke();
|
|
3091
|
+
ctx2.restore();
|
|
3092
|
+
}
|
|
3093
|
+
const size = 24 * baseScale * (1 + 0.15 * (zoom - 1)) * scale;
|
|
3094
|
+
ctx2.save();
|
|
3095
|
+
ctx2.translate(p.x, p.y);
|
|
3096
|
+
ctx2.scale(size / 24, size / 24);
|
|
3097
|
+
ctx2.shadowColor = "rgba(0,0,0,0.35)";
|
|
3098
|
+
ctx2.shadowBlur = 5;
|
|
3099
|
+
ctx2.shadowOffsetY = 1.5;
|
|
3100
|
+
if (glyph === "ibeam") {
|
|
3101
|
+
ctx2.beginPath();
|
|
3102
|
+
ctx2.moveTo(-4, -9);
|
|
3103
|
+
ctx2.lineTo(4, -9);
|
|
3104
|
+
ctx2.moveTo(0, -9);
|
|
3105
|
+
ctx2.lineTo(0, 9);
|
|
3106
|
+
ctx2.moveTo(-4, 9);
|
|
3107
|
+
ctx2.lineTo(4, 9);
|
|
3108
|
+
ctx2.lineWidth = 2.4;
|
|
3109
|
+
ctx2.strokeStyle = "#ffffff";
|
|
3110
|
+
ctx2.stroke();
|
|
3111
|
+
ctx2.shadowColor = "transparent";
|
|
3112
|
+
ctx2.lineWidth = 1;
|
|
3113
|
+
ctx2.strokeStyle = "rgba(20,24,34,0.9)";
|
|
3114
|
+
ctx2.stroke();
|
|
3115
|
+
} else {
|
|
3116
|
+
ctx2.beginPath();
|
|
3117
|
+
ctx2.moveTo(0, 0);
|
|
3118
|
+
ctx2.lineTo(0, 17.5);
|
|
3119
|
+
ctx2.lineTo(4.2, 13.6);
|
|
3120
|
+
ctx2.lineTo(7.4, 20.6);
|
|
3121
|
+
ctx2.lineTo(10.3, 19.2);
|
|
3122
|
+
ctx2.lineTo(7.2, 12.4);
|
|
3123
|
+
ctx2.lineTo(12.8, 12.2);
|
|
3124
|
+
ctx2.closePath();
|
|
3125
|
+
ctx2.fillStyle = "#ffffff";
|
|
3126
|
+
ctx2.fill();
|
|
3127
|
+
ctx2.shadowColor = "transparent";
|
|
3128
|
+
ctx2.lineWidth = 1.4;
|
|
3129
|
+
ctx2.strokeStyle = "rgba(20,24,34,0.9)";
|
|
3130
|
+
ctx2.stroke();
|
|
3131
|
+
}
|
|
3132
|
+
ctx2.restore();
|
|
3133
|
+
}
|
|
3134
|
+
function easeOut(u) {
|
|
3135
|
+
return 1 - (1 - u) * (1 - u);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// src/compose/overlays/draw.ts
|
|
3139
|
+
var FADE_MS = 200;
|
|
3140
|
+
var SCRIM_H = 210;
|
|
3141
|
+
function drawOverlays(ctx2, overlays, tOut, theme, profile) {
|
|
3142
|
+
const bottomCaptions = overlays.filter(
|
|
3143
|
+
(o) => o.kind === "caption" && tOut >= o.tStart && tOut <= o.tEnd && o.box.y > profile.height / 2
|
|
3144
|
+
);
|
|
3145
|
+
if (bottomCaptions.length > 0) {
|
|
3146
|
+
const a = Math.max(...bottomCaptions.map((o) => captionAlpha(o, tOut)));
|
|
3147
|
+
if (a > 0) {
|
|
3148
|
+
const g = ctx2.createLinearGradient(0, profile.height - SCRIM_H, 0, profile.height);
|
|
3149
|
+
g.addColorStop(0, "rgba(8,10,16,0)");
|
|
3150
|
+
g.addColorStop(1, `rgba(8,10,16,${(0.55 * a).toFixed(3)})`);
|
|
3151
|
+
ctx2.fillStyle = g;
|
|
3152
|
+
ctx2.fillRect(0, profile.height - SCRIM_H, profile.width, SCRIM_H);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
for (const o of overlays) {
|
|
3156
|
+
if (tOut < o.tStart || tOut > o.tEnd) continue;
|
|
3157
|
+
switch (o.kind) {
|
|
3158
|
+
case "caption":
|
|
3159
|
+
drawCaption(ctx2, o, tOut, theme);
|
|
3160
|
+
break;
|
|
3161
|
+
case "dip": {
|
|
3162
|
+
const u = (tOut - o.tStart) / Math.max(1, o.tEnd - o.tStart);
|
|
3163
|
+
ctx2.save();
|
|
3164
|
+
ctx2.globalAlpha = o.direction === "out" ? u : 1 - u;
|
|
3165
|
+
ctx2.fillStyle = theme.bg;
|
|
3166
|
+
ctx2.fillRect(0, 0, profile.width, profile.height);
|
|
3167
|
+
ctx2.restore();
|
|
3168
|
+
break;
|
|
3169
|
+
}
|
|
3170
|
+
case "elapsedClock":
|
|
3171
|
+
drawElapsedClock(ctx2, o.zeroAtMs, tOut, theme, profile);
|
|
3172
|
+
break;
|
|
3173
|
+
case "chapterLabel":
|
|
3174
|
+
drawChapterLabel(ctx2, o, tOut, theme, profile);
|
|
3175
|
+
break;
|
|
3176
|
+
case "titleCard":
|
|
3177
|
+
case "failureCard":
|
|
3178
|
+
break;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
|
|
3183
|
+
const ms = Math.max(0, tOut - zeroAtMs);
|
|
3184
|
+
const s = Math.floor(ms / 1e3);
|
|
3185
|
+
const label = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}.${String(Math.floor(ms % 1e3 / 100))}`;
|
|
3186
|
+
const font = "600 26px Inter SemiBold";
|
|
3187
|
+
ctx2.save();
|
|
3188
|
+
ctx2.font = font;
|
|
3189
|
+
const textW = ctx2.measureText(label).width;
|
|
3190
|
+
const padX = 16;
|
|
3191
|
+
const w = textW + padX * 2 + 30;
|
|
3192
|
+
const h = 44;
|
|
3193
|
+
const x = profile.width - profile.safeArea.right - w;
|
|
3194
|
+
const y = profile.safeArea.top;
|
|
3195
|
+
ctx2.fillStyle = "rgba(18,22,31,0.82)";
|
|
3196
|
+
roundRect(ctx2, x, y, w, h, 10);
|
|
3197
|
+
ctx2.fill();
|
|
3198
|
+
ctx2.fillStyle = "#ef4444";
|
|
3199
|
+
ctx2.beginPath();
|
|
3200
|
+
ctx2.arc(x + padX + 6, y + h / 2, 6, 0, Math.PI * 2);
|
|
3201
|
+
ctx2.fill();
|
|
3202
|
+
ctx2.fillStyle = "#ffffff";
|
|
3203
|
+
ctx2.textAlign = "left";
|
|
3204
|
+
ctx2.textBaseline = "middle";
|
|
3205
|
+
ctx2.font = font;
|
|
3206
|
+
ctx2.fillText(label, x + padX + 24, y + h / 2 + 1);
|
|
3207
|
+
ctx2.restore();
|
|
3208
|
+
}
|
|
3209
|
+
function drawChapterLabel(ctx2, o, tOut, theme, profile) {
|
|
3210
|
+
const alpha = clamp(Math.min((tOut - o.tStart) / 260, (o.tEnd - tOut) / 320), 0, 1);
|
|
3211
|
+
if (alpha <= 0) return;
|
|
3212
|
+
ctx2.save();
|
|
3213
|
+
ctx2.globalAlpha = alpha;
|
|
3214
|
+
const font = "700 30px Inter Bold";
|
|
3215
|
+
ctx2.font = font;
|
|
3216
|
+
const textW = ctx2.measureText(o.title).width;
|
|
3217
|
+
const barW = textW + 96;
|
|
3218
|
+
const barH = 56;
|
|
3219
|
+
const x = (profile.width - barW) / 2;
|
|
3220
|
+
const y = profile.stage ? profile.stage.content.y + 14 : profile.safeArea.top + 8;
|
|
3221
|
+
ctx2.fillStyle = theme.accent;
|
|
3222
|
+
roundRect(ctx2, x, y, barW, barH, 12);
|
|
3223
|
+
ctx2.fill();
|
|
3224
|
+
ctx2.fillStyle = "#ffffff";
|
|
3225
|
+
ctx2.font = "600 16px Inter SemiBold";
|
|
3226
|
+
ctx2.textAlign = "left";
|
|
3227
|
+
ctx2.textBaseline = "middle";
|
|
3228
|
+
ctx2.fillText(String(o.ordinal), x + 22, y + barH / 2 + 1);
|
|
3229
|
+
ctx2.font = font;
|
|
3230
|
+
ctx2.fillText(o.title, x + 48, y + barH / 2 + 1);
|
|
3231
|
+
ctx2.restore();
|
|
3232
|
+
}
|
|
3233
|
+
function captionAlpha(o, tOut) {
|
|
3234
|
+
return clamp(Math.min((tOut - o.tStart) / FADE_MS, (o.tEnd - tOut) / FADE_MS), 0, 1);
|
|
3235
|
+
}
|
|
3236
|
+
function drawCaption(ctx2, o, tOut, theme) {
|
|
3237
|
+
const alpha = captionAlpha(o, tOut);
|
|
3238
|
+
if (alpha <= 0) return;
|
|
3239
|
+
ctx2.save();
|
|
3240
|
+
ctx2.globalAlpha = alpha;
|
|
3241
|
+
ctx2.shadowColor = "rgba(0,0,0,0.30)";
|
|
3242
|
+
ctx2.shadowBlur = 18;
|
|
3243
|
+
ctx2.shadowOffsetY = 4;
|
|
3244
|
+
ctx2.fillStyle = theme.surface;
|
|
3245
|
+
roundRect(ctx2, o.box.x, o.box.y, o.box.w, o.box.h, theme.radius);
|
|
3246
|
+
ctx2.fill();
|
|
3247
|
+
ctx2.shadowColor = "transparent";
|
|
3248
|
+
let badgeW = 0;
|
|
3249
|
+
const bx = o.box.x + 26;
|
|
3250
|
+
if (o.showBadge) {
|
|
3251
|
+
const badgeText = `${o.ordinal}/${o.totalSteps}`;
|
|
3252
|
+
const badgeFont = `600 15px Inter SemiBold`;
|
|
3253
|
+
badgeW = Math.ceil(measureText(badgeText, badgeFont)) + 20;
|
|
3254
|
+
const badgeH = 26;
|
|
3255
|
+
const by = o.box.y + o.box.h / 2 - badgeH / 2;
|
|
3256
|
+
ctx2.fillStyle = theme.accent;
|
|
3257
|
+
roundRect(ctx2, bx, by, badgeW, badgeH, 13);
|
|
3258
|
+
ctx2.fill();
|
|
3259
|
+
ctx2.fillStyle = "#ffffff";
|
|
3260
|
+
ctx2.font = badgeFont;
|
|
3261
|
+
ctx2.textBaseline = "middle";
|
|
3262
|
+
ctx2.textAlign = "center";
|
|
3263
|
+
ctx2.fillText(badgeText, bx + badgeW / 2, by + badgeH / 2 + 1);
|
|
3264
|
+
}
|
|
3265
|
+
const lineH = Math.round(o.fontPx * 1.35);
|
|
3266
|
+
const textX = o.showBadge ? bx + badgeW + 14 : bx;
|
|
3267
|
+
const textTop = o.box.y + (o.box.h - o.lines.length * lineH) / 2;
|
|
3268
|
+
ctx2.fillStyle = theme.onSurface;
|
|
3269
|
+
ctx2.font = `${o.fontPx}px Inter Medium`;
|
|
3270
|
+
ctx2.textAlign = "left";
|
|
3271
|
+
ctx2.textBaseline = "middle";
|
|
3272
|
+
o.lines.forEach((line, i) => {
|
|
3273
|
+
ctx2.fillText(line, textX, textTop + i * lineH + lineH / 2 + 1);
|
|
3274
|
+
});
|
|
3275
|
+
ctx2.restore();
|
|
3276
|
+
}
|
|
3277
|
+
function drawFailureCard(ctx2, card, tOut, theme, profile) {
|
|
3278
|
+
const { width, height } = profile;
|
|
3279
|
+
const g = ctx2.createLinearGradient(0, 0, 0, height);
|
|
3280
|
+
g.addColorStop(0, "#1c1114");
|
|
3281
|
+
g.addColorStop(1, "#12090b");
|
|
3282
|
+
ctx2.fillStyle = g;
|
|
3283
|
+
ctx2.fillRect(0, 0, width, height);
|
|
3284
|
+
const t = tOut - card.tStart;
|
|
3285
|
+
const a = clamp(t / 300, 0, 1);
|
|
3286
|
+
ctx2.save();
|
|
3287
|
+
ctx2.globalAlpha = a;
|
|
3288
|
+
ctx2.fillStyle = "#ef4444";
|
|
3289
|
+
roundRect(ctx2, width / 2 - 32, height / 2 - 150, 64, 8, 4);
|
|
3290
|
+
ctx2.fill();
|
|
3291
|
+
ctx2.fillStyle = "#ffffff";
|
|
3292
|
+
ctx2.font = `52px Inter Bold`;
|
|
3293
|
+
ctx2.textAlign = "center";
|
|
3294
|
+
ctx2.textBaseline = "middle";
|
|
3295
|
+
ctx2.fillText("Flow failed", width / 2, height / 2 - 70);
|
|
3296
|
+
ctx2.fillStyle = "#fca5a5";
|
|
3297
|
+
ctx2.font = `600 30px Inter SemiBold`;
|
|
3298
|
+
ctx2.fillText(`at step ${card.stepRef}`, width / 2, height / 2 - 12);
|
|
3299
|
+
ctx2.fillStyle = "rgba(255,255,255,0.75)";
|
|
3300
|
+
ctx2.font = `24px Inter`;
|
|
3301
|
+
const maxW = Math.min(1200, width - 200);
|
|
3302
|
+
const words = card.message.replace(/\s+/g, " ").split(" ");
|
|
3303
|
+
const lines = [];
|
|
3304
|
+
let line = "";
|
|
3305
|
+
for (const w of words) {
|
|
3306
|
+
const probe = line ? `${line} ${w}` : w;
|
|
3307
|
+
if (ctx2.measureText(probe).width > maxW && line) {
|
|
3308
|
+
lines.push(line);
|
|
3309
|
+
line = w;
|
|
3310
|
+
if (lines.length >= 3) break;
|
|
3311
|
+
} else line = probe;
|
|
3312
|
+
}
|
|
3313
|
+
if (lines.length < 3 && line) lines.push(line);
|
|
3314
|
+
else if (line && lines.length >= 3) lines[2] = `${lines[2].slice(0, -1)}\u2026`;
|
|
3315
|
+
lines.forEach((l, i) => ctx2.fillText(l, width / 2, height / 2 + 48 + i * 36));
|
|
3316
|
+
ctx2.restore();
|
|
3317
|
+
}
|
|
3318
|
+
function drawTitleCard(ctx2, card, tOut, theme, profile) {
|
|
3319
|
+
const { width, height } = profile;
|
|
3320
|
+
const g = ctx2.createLinearGradient(0, 0, 0, height);
|
|
3321
|
+
g.addColorStop(0, theme.bgSubtle);
|
|
3322
|
+
g.addColorStop(1, theme.bg);
|
|
3323
|
+
ctx2.fillStyle = g;
|
|
3324
|
+
ctx2.fillRect(0, 0, width, height);
|
|
3325
|
+
const t = tOut - card.tStart;
|
|
3326
|
+
const enter = (delay) => {
|
|
3327
|
+
const u = clamp((t - delay) / 380, 0, 1);
|
|
3328
|
+
const e = 1 - (1 - u) * (1 - u) * (1 - u);
|
|
3329
|
+
return { alpha: u, rise: 16 * (1 - e) };
|
|
3330
|
+
};
|
|
3331
|
+
const bar = enter(0);
|
|
3332
|
+
const title = enter(130);
|
|
3333
|
+
const sub = enter(240);
|
|
3334
|
+
ctx2.save();
|
|
3335
|
+
ctx2.globalAlpha = bar.alpha;
|
|
3336
|
+
ctx2.fillStyle = theme.accent;
|
|
3337
|
+
roundRect(ctx2, width / 2 - 32, height / 2 - 110 + bar.rise, 64, 8, 4);
|
|
3338
|
+
ctx2.fill();
|
|
3339
|
+
ctx2.globalAlpha = title.alpha;
|
|
3340
|
+
ctx2.fillStyle = "#ffffff";
|
|
3341
|
+
ctx2.font = `56px Inter Bold`;
|
|
3342
|
+
ctx2.textAlign = "center";
|
|
3343
|
+
ctx2.textBaseline = "middle";
|
|
3344
|
+
ctx2.fillText(card.title, width / 2, height / 2 - 20 + title.rise);
|
|
3345
|
+
if (card.subtitle) {
|
|
3346
|
+
ctx2.globalAlpha = sub.alpha;
|
|
3347
|
+
ctx2.fillStyle = theme.onSurfaceDim;
|
|
3348
|
+
ctx2.font = `26px Inter`;
|
|
3349
|
+
ctx2.fillText(card.subtitle, width / 2, height / 2 + 44 + sub.rise);
|
|
3350
|
+
}
|
|
3351
|
+
ctx2.restore();
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// src/compose/render/frameStore.ts
|
|
3355
|
+
import sharp2 from "sharp";
|
|
3356
|
+
import { createCanvas as createCanvas2, ImageData } from "@napi-rs/canvas";
|
|
3357
|
+
var LRU_SIZE = 6;
|
|
3358
|
+
var FrameStore = class {
|
|
3359
|
+
constructor(bundle) {
|
|
3360
|
+
this.bundle = bundle;
|
|
3361
|
+
}
|
|
3362
|
+
bundle;
|
|
3363
|
+
cache = /* @__PURE__ */ new Map();
|
|
3364
|
+
/** Decoded canvas for the frame that should be on screen at capture-relative tMs. */
|
|
3365
|
+
async frameForTime(tMs) {
|
|
3366
|
+
const idx = frameIndexForTime(this.bundle.frames, tMs);
|
|
3367
|
+
const canvas = await this.get(idx);
|
|
3368
|
+
if (idx + 1 < this.bundle.frames.length) void this.get(idx + 1);
|
|
3369
|
+
return canvas;
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* The frame at tMs plus its successor with a blend factor: the capture is ~20fps but output
|
|
3373
|
+
* is 30fps, so nearest-frame playback holds each source frame in a 2-1-2-1 judder pattern.
|
|
3374
|
+
* Blending adjacent frames during real motion smooths it without optical flow.
|
|
3375
|
+
*/
|
|
3376
|
+
async framePairForTime(tMs) {
|
|
3377
|
+
const idx = frameIndexForTime(this.bundle.frames, tMs);
|
|
3378
|
+
const a = await this.get(idx);
|
|
3379
|
+
if (idx + 1 >= this.bundle.frames.length) return { a, b: null, mix: 0, gapMs: Infinity };
|
|
3380
|
+
const t0 = this.bundle.frames[idx].t;
|
|
3381
|
+
const t1 = this.bundle.frames[idx + 1].t;
|
|
3382
|
+
const gapMs = t1 - t0;
|
|
3383
|
+
const mix = gapMs > 0 ? Math.min(1, Math.max(0, (tMs - t0) / gapMs)) : 0;
|
|
3384
|
+
const b = await this.get(idx + 1);
|
|
3385
|
+
return { a, b, mix, gapMs };
|
|
3386
|
+
}
|
|
3387
|
+
get(idx) {
|
|
3388
|
+
const hit = this.cache.get(idx);
|
|
3389
|
+
if (hit) {
|
|
3390
|
+
this.cache.delete(idx);
|
|
3391
|
+
this.cache.set(idx, hit);
|
|
3392
|
+
return hit;
|
|
3393
|
+
}
|
|
3394
|
+
const promise = this.decode(idx);
|
|
3395
|
+
this.cache.set(idx, promise);
|
|
3396
|
+
while (this.cache.size > LRU_SIZE) {
|
|
3397
|
+
const oldest = this.cache.keys().next().value;
|
|
3398
|
+
this.cache.delete(oldest);
|
|
3399
|
+
}
|
|
3400
|
+
return promise;
|
|
3401
|
+
}
|
|
3402
|
+
async decode(idx) {
|
|
3403
|
+
const { data, info } = await sharp2(framePath(this.bundle, idx)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
3404
|
+
const canvas = createCanvas2(info.width, info.height);
|
|
3405
|
+
const ctx2 = canvas.getContext("2d");
|
|
3406
|
+
ctx2.putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), info.width, info.height), 0, 0);
|
|
3407
|
+
return canvas;
|
|
3408
|
+
}
|
|
3409
|
+
};
|
|
3410
|
+
|
|
3411
|
+
// src/compose/render/encoder.ts
|
|
3412
|
+
import { spawn as spawn2 } from "child_process";
|
|
3413
|
+
import { once } from "events";
|
|
3414
|
+
|
|
3415
|
+
// src/shared/ffmpeg.ts
|
|
3416
|
+
import { spawn } from "child_process";
|
|
3417
|
+
import { createRequire as createRequire3 } from "module";
|
|
3418
|
+
var require2 = createRequire3(import.meta.url);
|
|
3419
|
+
function ffmpegPath() {
|
|
3420
|
+
if (process.env.PLAYHEAD_FFMPEG) return process.env.PLAYHEAD_FFMPEG;
|
|
3421
|
+
const p = require2("ffmpeg-static");
|
|
3422
|
+
if (!p) throw new Error("ffmpeg-static did not provide a binary; set PLAYHEAD_FFMPEG");
|
|
3423
|
+
return p;
|
|
3424
|
+
}
|
|
3425
|
+
function ffprobePath() {
|
|
3426
|
+
if (process.env.PLAYHEAD_FFPROBE) return process.env.PLAYHEAD_FFPROBE;
|
|
3427
|
+
const mod = require2("ffprobe-static");
|
|
3428
|
+
return mod.path;
|
|
3429
|
+
}
|
|
3430
|
+
function runBinary(bin, args, stdin) {
|
|
3431
|
+
return new Promise((resolve, reject) => {
|
|
3432
|
+
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
3433
|
+
const out = [];
|
|
3434
|
+
const err = [];
|
|
3435
|
+
child.stdout.on("data", (d) => out.push(d));
|
|
3436
|
+
child.stderr.on("data", (d) => err.push(d));
|
|
3437
|
+
child.on("error", reject);
|
|
3438
|
+
child.on("close", (code) => {
|
|
3439
|
+
resolve({
|
|
3440
|
+
code: code ?? -1,
|
|
3441
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
3442
|
+
stderr: Buffer.concat(err).toString("utf8")
|
|
3443
|
+
});
|
|
3444
|
+
});
|
|
3445
|
+
if (stdin) child.stdin.end(stdin);
|
|
3446
|
+
else child.stdin.end();
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
function runBinaryRaw(bin, args) {
|
|
3450
|
+
return new Promise((resolve, reject) => {
|
|
3451
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
3452
|
+
const out = [];
|
|
3453
|
+
const err = [];
|
|
3454
|
+
child.stdout.on("data", (d) => out.push(d));
|
|
3455
|
+
child.stderr.on("data", (d) => err.push(d));
|
|
3456
|
+
child.on("error", reject);
|
|
3457
|
+
child.on("close", (code) => {
|
|
3458
|
+
resolve({ code: code ?? -1, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString("utf8") });
|
|
3459
|
+
});
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// src/compose/render/encoder.ts
|
|
3464
|
+
var Encoder = class {
|
|
3465
|
+
child;
|
|
3466
|
+
stderrTail = [];
|
|
3467
|
+
exit;
|
|
3468
|
+
constructor(outPath, width, height, fps) {
|
|
3469
|
+
this.child = spawn2(
|
|
3470
|
+
ffmpegPath(),
|
|
3471
|
+
[
|
|
3472
|
+
"-hide_banner",
|
|
3473
|
+
"-loglevel",
|
|
3474
|
+
"error",
|
|
3475
|
+
"-f",
|
|
3476
|
+
"rawvideo",
|
|
3477
|
+
"-pix_fmt",
|
|
3478
|
+
"rgba",
|
|
3479
|
+
"-s",
|
|
3480
|
+
`${width}x${height}`,
|
|
3481
|
+
"-r",
|
|
3482
|
+
String(fps),
|
|
3483
|
+
"-i",
|
|
3484
|
+
"pipe:0",
|
|
3485
|
+
// A silent AAC track: some players/galleries refuse an audio-less MP4.
|
|
3486
|
+
"-f",
|
|
3487
|
+
"lavfi",
|
|
3488
|
+
"-i",
|
|
3489
|
+
"anullsrc=channel_layout=stereo:sample_rate=44100",
|
|
3490
|
+
"-c:v",
|
|
3491
|
+
"libx264",
|
|
3492
|
+
"-preset",
|
|
3493
|
+
"medium",
|
|
3494
|
+
"-crf",
|
|
3495
|
+
"18",
|
|
3496
|
+
"-pix_fmt",
|
|
3497
|
+
"yuv420p",
|
|
3498
|
+
"-profile:v",
|
|
3499
|
+
"high",
|
|
3500
|
+
"-c:a",
|
|
3501
|
+
"aac",
|
|
3502
|
+
"-b:a",
|
|
3503
|
+
"96k",
|
|
3504
|
+
"-shortest",
|
|
3505
|
+
"-movflags",
|
|
3506
|
+
"+faststart",
|
|
3507
|
+
"-y",
|
|
3508
|
+
outPath
|
|
3509
|
+
],
|
|
3510
|
+
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
3511
|
+
);
|
|
3512
|
+
this.child.stderr.on("data", (d) => {
|
|
3513
|
+
this.stderrTail.push(d.toString("utf8"));
|
|
3514
|
+
if (this.stderrTail.length > 40) this.stderrTail.shift();
|
|
3515
|
+
});
|
|
3516
|
+
this.exit = new Promise((resolve, reject) => {
|
|
3517
|
+
this.child.on("error", reject);
|
|
3518
|
+
this.child.on("close", (code) => resolve(code ?? -1));
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
async write(rgba) {
|
|
3522
|
+
const stdin = this.child.stdin;
|
|
3523
|
+
if (!stdin.writable) throw new Error(`ffmpeg closed early: ${this.stderrTail.join("")}`);
|
|
3524
|
+
if (!stdin.write(rgba)) {
|
|
3525
|
+
await once(stdin, "drain");
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
async finish() {
|
|
3529
|
+
this.child.stdin.end();
|
|
3530
|
+
const code = await this.exit;
|
|
3531
|
+
if (code !== 0) {
|
|
3532
|
+
throw new Error(`ffmpeg exited with code ${code}: ${this.stderrTail.join("")}`);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
};
|
|
3536
|
+
|
|
3537
|
+
// src/compose/render/renderer.ts
|
|
3538
|
+
var SPOTLIGHT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
|
|
3539
|
+
var SPOTLIGHT_LEAD_MS = 200;
|
|
3540
|
+
var SPOTLIGHT_TAIL_MS = 650;
|
|
3541
|
+
var SPOTLIGHT_MAX_DIM = 0.34;
|
|
3542
|
+
async function renderVideo(bundle, manifest, outPath) {
|
|
3543
|
+
registerFonts();
|
|
3544
|
+
const { profile, theme } = manifest;
|
|
3545
|
+
const { width, height, fps } = profile;
|
|
3546
|
+
const content = stageContent(profile);
|
|
3547
|
+
const plane2 = {
|
|
3548
|
+
vpW: manifest.viewport.w,
|
|
3549
|
+
vpH: manifest.viewport.h,
|
|
3550
|
+
outW: content.w,
|
|
3551
|
+
outH: content.h
|
|
3552
|
+
};
|
|
3553
|
+
const frameScale = bundle.manifest.frameW / manifest.viewport.w;
|
|
3554
|
+
const store = new FrameStore(bundle);
|
|
3555
|
+
const encoder = new Encoder(outPath, width, height, fps);
|
|
3556
|
+
const canvas = createCanvas3(width, height);
|
|
3557
|
+
const ctx2 = canvas.getContext("2d");
|
|
3558
|
+
ctx2.imageSmoothingEnabled = true;
|
|
3559
|
+
ctx2.imageSmoothingQuality = "high";
|
|
3560
|
+
const spotCanvas = createCanvas3(content.w, content.h);
|
|
3561
|
+
const spotCtx = spotCanvas.getContext("2d");
|
|
3562
|
+
const cursorPlan = {
|
|
3563
|
+
segments: manifest.cursor,
|
|
3564
|
+
ripples: manifest.ripples,
|
|
3565
|
+
restStart: manifest.cursor.length > 0 ? manifest.cursor[0].path[0] : { x: manifest.viewport.w / 2, y: manifest.viewport.h * 0.6 }
|
|
3566
|
+
};
|
|
3567
|
+
const cards = new Map(
|
|
3568
|
+
manifest.overlays.filter((o) => o.kind === "titleCard").map((c) => [c.cardId, c])
|
|
3569
|
+
);
|
|
3570
|
+
const transitions = manifest.transitions ?? [];
|
|
3571
|
+
const spotlightSteps = manifest.steps.filter(
|
|
3572
|
+
(s) => SPOTLIGHT_KINDS.has(s.kind) && (s.focusRectVp ?? s.targetRectVp)
|
|
3573
|
+
);
|
|
3574
|
+
const start = Date.now();
|
|
3575
|
+
for (let i = 0; i < manifest.totalFrames; i++) {
|
|
3576
|
+
const tOut = i * 1e3 / fps;
|
|
3577
|
+
const { srcAt, segment } = sampleSource(manifest.segments, tOut);
|
|
3578
|
+
if (srcAt === null) {
|
|
3579
|
+
const failCard = segment.kind === "card" && segment.cardId === "failure" ? manifest.overlays.find((o) => o.kind === "failureCard") : void 0;
|
|
3580
|
+
const card = segment.kind === "card" ? cards.get(segment.cardId) : void 0;
|
|
3581
|
+
if (failCard && failCard.kind === "failureCard") drawFailureCard(ctx2, failCard, tOut, theme, profile);
|
|
3582
|
+
else if (card) drawTitleCard(ctx2, card, tOut, theme, profile);
|
|
3583
|
+
else {
|
|
3584
|
+
ctx2.fillStyle = theme.bg;
|
|
3585
|
+
ctx2.fillRect(0, 0, width, height);
|
|
3586
|
+
}
|
|
3587
|
+
} else {
|
|
3588
|
+
const cam = sampleCamera(manifest.camera, tOut);
|
|
3589
|
+
const vis = visibleRect(cam, plane2);
|
|
3590
|
+
paintStageBackdrop(ctx2, profile, theme);
|
|
3591
|
+
ctx2.save();
|
|
3592
|
+
if (profile.stage) {
|
|
3593
|
+
paintStageCard(ctx2, profile, theme, bundle.manifest.appUrl);
|
|
3594
|
+
roundRect(ctx2, content.x, content.y, content.w, content.h, 0);
|
|
3595
|
+
ctx2.clip();
|
|
3596
|
+
}
|
|
3597
|
+
const drawSrc = (img, alpha) => {
|
|
3598
|
+
ctx2.globalAlpha = alpha;
|
|
3599
|
+
ctx2.drawImage(
|
|
3600
|
+
img,
|
|
3601
|
+
vis.x * frameScale,
|
|
3602
|
+
vis.y * frameScale,
|
|
3603
|
+
vis.w * frameScale,
|
|
3604
|
+
vis.h * frameScale,
|
|
3605
|
+
content.x,
|
|
3606
|
+
content.y,
|
|
3607
|
+
content.w,
|
|
3608
|
+
content.h
|
|
3609
|
+
);
|
|
3610
|
+
ctx2.globalAlpha = 1;
|
|
3611
|
+
};
|
|
3612
|
+
const pair = await store.framePairForTime(srcAt);
|
|
3613
|
+
if (segment.kind === "source" && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
|
|
3614
|
+
drawSrc(pair.a, 1);
|
|
3615
|
+
drawSrc(pair.b, pair.mix);
|
|
3616
|
+
} else {
|
|
3617
|
+
drawSrc(pair.a, 1);
|
|
3618
|
+
}
|
|
3619
|
+
const tr = transitions.find((w) => tOut >= w.tStart && tOut <= w.tEnd);
|
|
3620
|
+
if (tr) {
|
|
3621
|
+
const u = clamp((tOut - tr.tStart) / Math.max(1, tr.tEnd - tr.tStart), 0, 1);
|
|
3622
|
+
if (u < 1) {
|
|
3623
|
+
const from = await store.frameForTime(tr.srcFrom);
|
|
3624
|
+
drawSrc(from, 1 - u);
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
const spot = activeSpotlight(spotlightSteps, tOut);
|
|
3628
|
+
if (spot) {
|
|
3629
|
+
const rect = spot.step.focusRectVp ?? spot.step.targetRectVp;
|
|
3630
|
+
const proj = projectRect(rect, cam, plane2);
|
|
3631
|
+
drawSpotlight(spotCtx, content, proj, spot.alpha);
|
|
3632
|
+
ctx2.drawImage(spotCanvas, content.x, content.y);
|
|
3633
|
+
}
|
|
3634
|
+
const cursorVp = sampleCursor(cursorPlan, tOut);
|
|
3635
|
+
const cursorOut = projectPoint(cursorVp, cam, plane2);
|
|
3636
|
+
const rippleCenters = manifest.ripples.map((r) => {
|
|
3637
|
+
const p = projectPoint({ x: r.x, y: r.y }, cam, plane2);
|
|
3638
|
+
return { x: p.x + content.x, y: p.y + content.y };
|
|
3639
|
+
});
|
|
3640
|
+
const glyph = cursorGlyphAt(manifest.steps, tOut);
|
|
3641
|
+
drawCursor(
|
|
3642
|
+
ctx2,
|
|
3643
|
+
{ x: cursorOut.x + content.x, y: cursorOut.y + content.y },
|
|
3644
|
+
cam.zoom,
|
|
3645
|
+
tOut,
|
|
3646
|
+
manifest.ripples,
|
|
3647
|
+
rippleCenters,
|
|
3648
|
+
theme,
|
|
3649
|
+
plane2.outW / plane2.vpW,
|
|
3650
|
+
glyph
|
|
3651
|
+
);
|
|
3652
|
+
ctx2.restore();
|
|
3653
|
+
}
|
|
3654
|
+
drawOverlays(ctx2, manifest.overlays, tOut, theme, profile);
|
|
3655
|
+
await encoder.write(Buffer.from(ctx2.getImageData(0, 0, width, height).data.buffer));
|
|
3656
|
+
if (i > 0 && i % 300 === 0) {
|
|
3657
|
+
const fpsNow = i / ((Date.now() - start) / 1e3);
|
|
3658
|
+
log.debug(`rendered ${i}/${manifest.totalFrames} frames (${fpsNow.toFixed(0)} fps)`);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
await encoder.finish();
|
|
3662
|
+
const elapsed = (Date.now() - start) / 1e3;
|
|
3663
|
+
log.ok(
|
|
3664
|
+
`rendered ${manifest.totalFrames} frames in ${elapsed.toFixed(1)}s (${(manifest.totalFrames / elapsed).toFixed(0)} fps) \u2192 ${outPath}`
|
|
3665
|
+
);
|
|
3666
|
+
}
|
|
3667
|
+
function paintStageBackdrop(ctx2, profile, theme) {
|
|
3668
|
+
const g = ctx2.createLinearGradient(0, 0, 0, profile.height);
|
|
3669
|
+
g.addColorStop(0, theme.bgSubtle);
|
|
3670
|
+
g.addColorStop(1, theme.bg);
|
|
3671
|
+
ctx2.fillStyle = g;
|
|
3672
|
+
ctx2.fillRect(0, 0, profile.width, profile.height);
|
|
3673
|
+
}
|
|
3674
|
+
function paintStageCard(ctx2, profile, theme, appUrl) {
|
|
3675
|
+
const s = profile.stage;
|
|
3676
|
+
const c = s.content;
|
|
3677
|
+
const cardX = c.x;
|
|
3678
|
+
const cardY = c.y - s.chromeH;
|
|
3679
|
+
const cardW = c.w;
|
|
3680
|
+
const cardH = c.h + s.chromeH;
|
|
3681
|
+
ctx2.save();
|
|
3682
|
+
ctx2.shadowColor = "rgba(0,0,0,0.45)";
|
|
3683
|
+
ctx2.shadowBlur = 42;
|
|
3684
|
+
ctx2.shadowOffsetY = 14;
|
|
3685
|
+
ctx2.fillStyle = "#20242e";
|
|
3686
|
+
roundRect(ctx2, cardX, cardY, cardW, cardH, s.radius);
|
|
3687
|
+
ctx2.fill();
|
|
3688
|
+
ctx2.restore();
|
|
3689
|
+
if (s.chromeH > 0) {
|
|
3690
|
+
const dotY = cardY + s.chromeH / 2;
|
|
3691
|
+
const dotR = Math.max(5, Math.round(s.chromeH * 0.14));
|
|
3692
|
+
const colors = ["#ff5f57", "#febc2e", "#28c840"];
|
|
3693
|
+
colors.forEach((col, i) => {
|
|
3694
|
+
ctx2.fillStyle = col;
|
|
3695
|
+
ctx2.beginPath();
|
|
3696
|
+
ctx2.arc(cardX + 24 + i * (dotR * 2 + 9), dotY, dotR, 0, Math.PI * 2);
|
|
3697
|
+
ctx2.fill();
|
|
3698
|
+
});
|
|
3699
|
+
const url = displayUrl(appUrl);
|
|
3700
|
+
const pillH = Math.round(s.chromeH * 0.62);
|
|
3701
|
+
ctx2.font = `500 ${Math.round(pillH * 0.52)}px Inter Medium`;
|
|
3702
|
+
const textW = ctx2.measureText(url).width;
|
|
3703
|
+
const pillW = Math.min(cardW * 0.5, textW + 36);
|
|
3704
|
+
const pillX = cardX + (cardW - pillW) / 2;
|
|
3705
|
+
const pillY = cardY + (s.chromeH - pillH) / 2;
|
|
3706
|
+
ctx2.fillStyle = "rgba(255,255,255,0.08)";
|
|
3707
|
+
roundRect(ctx2, pillX, pillY, pillW, pillH, pillH / 2);
|
|
3708
|
+
ctx2.fill();
|
|
3709
|
+
ctx2.fillStyle = "rgba(255,255,255,0.55)";
|
|
3710
|
+
ctx2.textAlign = "center";
|
|
3711
|
+
ctx2.textBaseline = "middle";
|
|
3712
|
+
ctx2.fillText(url, pillX + pillW / 2, pillY + pillH / 2 + 1, pillW - 24);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
function displayUrl(appUrl) {
|
|
3716
|
+
try {
|
|
3717
|
+
const u = new URL(appUrl);
|
|
3718
|
+
return u.host + (u.pathname !== "/" ? u.pathname : "");
|
|
3719
|
+
} catch {
|
|
3720
|
+
return appUrl;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
function activeSpotlight(steps, tOut) {
|
|
3724
|
+
for (const step of steps) {
|
|
3725
|
+
const from = step.outBeat - SPOTLIGHT_LEAD_MS;
|
|
3726
|
+
const to = step.outBeat + SPOTLIGHT_TAIL_MS;
|
|
3727
|
+
if (tOut < from || tOut > to) continue;
|
|
3728
|
+
const rampIn = clamp((tOut - from) / 180, 0, 1);
|
|
3729
|
+
const rampOut = clamp((to - tOut) / 250, 0, 1);
|
|
3730
|
+
return { step, alpha: SPOTLIGHT_MAX_DIM * Math.min(rampIn, rampOut) };
|
|
3731
|
+
}
|
|
3732
|
+
return null;
|
|
3733
|
+
}
|
|
3734
|
+
function drawSpotlight(sctx, content, proj, alpha) {
|
|
3735
|
+
sctx.clearRect(0, 0, content.w, content.h);
|
|
3736
|
+
sctx.fillStyle = `rgba(6,8,14,${alpha.toFixed(3)})`;
|
|
3737
|
+
sctx.fillRect(0, 0, content.w, content.h);
|
|
3738
|
+
const pad = 14;
|
|
3739
|
+
sctx.save();
|
|
3740
|
+
sctx.globalCompositeOperation = "destination-out";
|
|
3741
|
+
sctx.shadowColor = "#ffffff";
|
|
3742
|
+
sctx.shadowBlur = 30;
|
|
3743
|
+
sctx.fillStyle = "#ffffff";
|
|
3744
|
+
roundRect(sctx, proj.x - pad, proj.y - pad, proj.w + pad * 2, proj.h + pad * 2, 10);
|
|
3745
|
+
sctx.fill();
|
|
3746
|
+
sctx.restore();
|
|
3747
|
+
}
|
|
3748
|
+
function cursorGlyphAt(steps, tOut) {
|
|
3749
|
+
for (const s of steps) {
|
|
3750
|
+
if (s.kind === "type" && tOut >= s.outStart && tOut <= s.outEnd) return "ibeam";
|
|
3751
|
+
}
|
|
3752
|
+
return "arrow";
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
// src/audio/tts.ts
|
|
3756
|
+
import { execFile } from "child_process";
|
|
3757
|
+
import { promisify } from "util";
|
|
3758
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
3759
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3760
|
+
import { join as join5 } from "path";
|
|
3761
|
+
import { createHash as createHash2 } from "crypto";
|
|
3762
|
+
import { createRequire as createRequire4 } from "module";
|
|
3763
|
+
var require3 = createRequire4(import.meta.url);
|
|
3764
|
+
var ffprobePath2 = require3("ffprobe-static").path;
|
|
3765
|
+
var exec = promisify(execFile);
|
|
3766
|
+
var SayProvider = class {
|
|
3767
|
+
constructor(voice, rate) {
|
|
3768
|
+
this.voice = voice;
|
|
3769
|
+
this.rate = rate;
|
|
3770
|
+
}
|
|
3771
|
+
voice;
|
|
3772
|
+
rate;
|
|
3773
|
+
name = "say";
|
|
3774
|
+
async synth(text, outFile) {
|
|
3775
|
+
const txt = `${outFile}.txt`;
|
|
3776
|
+
await writeFile2(txt, text);
|
|
3777
|
+
await exec("say", ["-v", this.voice, "-r", String(this.rate), "-f", txt, "-o", outFile]);
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
function providerFor(opts) {
|
|
3781
|
+
const voice = opts.voice ?? "Samantha";
|
|
3782
|
+
const rate = opts.rate ?? 178;
|
|
3783
|
+
switch (opts.provider) {
|
|
3784
|
+
case "say":
|
|
3785
|
+
return { provider: new SayProvider(voice, rate), voice, rate };
|
|
3786
|
+
default:
|
|
3787
|
+
throw new Error(`unknown TTS provider: ${opts.provider}`);
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
async function probeDurationMs(file) {
|
|
3791
|
+
const { stdout } = await exec(ffprobePath2, [
|
|
3792
|
+
"-v",
|
|
3793
|
+
"error",
|
|
3794
|
+
"-show_entries",
|
|
3795
|
+
"format=duration",
|
|
3796
|
+
"-of",
|
|
3797
|
+
"csv=p=0",
|
|
3798
|
+
file
|
|
3799
|
+
]);
|
|
3800
|
+
return Math.round(parseFloat(stdout.trim()) * 1e3);
|
|
3801
|
+
}
|
|
3802
|
+
async function synthesizeNarration(lines, opts, cacheDir) {
|
|
3803
|
+
await mkdir2(cacheDir, { recursive: true });
|
|
3804
|
+
const { provider, voice, rate } = providerFor(opts);
|
|
3805
|
+
const out = /* @__PURE__ */ new Map();
|
|
3806
|
+
for (const { stepRef, text } of lines) {
|
|
3807
|
+
const key = createHash2("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
|
|
3808
|
+
const file = join5(cacheDir, `${key}.aiff`);
|
|
3809
|
+
if (!existsSync2(file)) await provider.synth(text, file);
|
|
3810
|
+
out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
|
|
3811
|
+
}
|
|
3812
|
+
return out;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
// src/audio/mux.ts
|
|
3816
|
+
import { execFile as execFile2 } from "child_process";
|
|
3817
|
+
import { promisify as promisify2 } from "util";
|
|
3818
|
+
import { rename, mkdir as mkdir3 } from "fs/promises";
|
|
3819
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3820
|
+
import { join as join6 } from "path";
|
|
3821
|
+
import { createRequire as createRequire5 } from "module";
|
|
3822
|
+
var require4 = createRequire5(import.meta.url);
|
|
3823
|
+
var ffmpegPath2 = require4("ffmpeg-static");
|
|
3824
|
+
var exec2 = promisify2(execFile2);
|
|
3825
|
+
async function mixAudio(videoPath, steps, durationMs, opts) {
|
|
3826
|
+
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);
|
|
3827
|
+
const beats = opts.sfxBeats ?? [];
|
|
3828
|
+
const hasMusic = !!opts.music;
|
|
3829
|
+
if (placed.length === 0 && beats.length === 0 && !hasMusic) return [];
|
|
3830
|
+
const durSec = (durationMs / 1e3).toFixed(3);
|
|
3831
|
+
const args = ["-y", "-loglevel", "error", "-i", videoPath];
|
|
3832
|
+
const inputs = [];
|
|
3833
|
+
const filters = [];
|
|
3834
|
+
let inputIdx = 1;
|
|
3835
|
+
args.push("-f", "lavfi", "-t", durSec, "-i", "anullsrc=r=44100:cl=stereo");
|
|
3836
|
+
const baseIdx = inputIdx++;
|
|
3837
|
+
inputs.push(`[${baseIdx}]`);
|
|
3838
|
+
let voiceBus = null;
|
|
3839
|
+
if (placed.length > 0) {
|
|
3840
|
+
const labels = [];
|
|
3841
|
+
placed.forEach((p, i) => {
|
|
3842
|
+
args.push("-i", p.clip.file);
|
|
3843
|
+
const idx = inputIdx++;
|
|
3844
|
+
filters.push(
|
|
3845
|
+
`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,adelay=${p.start}|${p.start}[v${i}]`
|
|
3846
|
+
);
|
|
3847
|
+
labels.push(`[v${i}]`);
|
|
3848
|
+
});
|
|
3849
|
+
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[voice]`);
|
|
3850
|
+
voiceBus = "[voice]";
|
|
3851
|
+
}
|
|
3852
|
+
if (beats.length > 0) {
|
|
3853
|
+
const click = await ensureClickSample(opts.cacheDir);
|
|
3854
|
+
args.push("-i", click);
|
|
3855
|
+
const idx = inputIdx++;
|
|
3856
|
+
const splits = beats.map((_, i) => `[c${i}]`).join("");
|
|
3857
|
+
filters.push(`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,asplit=${beats.length}${splits}`);
|
|
3858
|
+
const labels = [];
|
|
3859
|
+
beats.forEach((b, i) => {
|
|
3860
|
+
const at = Math.max(0, Math.round(b.tOut));
|
|
3861
|
+
filters.push(`[c${i}]adelay=${at}|${at}[ck${i}]`);
|
|
3862
|
+
labels.push(`[ck${i}]`);
|
|
3863
|
+
});
|
|
3864
|
+
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[sfx]`);
|
|
3865
|
+
inputs.push("[sfx]");
|
|
3866
|
+
}
|
|
3867
|
+
if (opts.music) {
|
|
3868
|
+
args.push("-stream_loop", "-1", "-i", opts.music.file);
|
|
3869
|
+
const idx = inputIdx++;
|
|
3870
|
+
const fadeStart = Math.max(0, durationMs / 1e3 - 1.8).toFixed(2);
|
|
3871
|
+
filters.push(
|
|
3872
|
+
`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,atrim=0:${durSec},volume=${opts.music.gainDb}dB,afade=t=out:st=${fadeStart}:d=1.8[mraw]`
|
|
3873
|
+
);
|
|
3874
|
+
if (voiceBus) {
|
|
3875
|
+
filters.push(`${voiceBus}asplit=2[vmain][vkey]`);
|
|
3876
|
+
filters.push(`[mraw][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=40:release=400[mduck]`);
|
|
3877
|
+
inputs.push("[mduck]", "[vmain]");
|
|
3878
|
+
voiceBus = null;
|
|
3879
|
+
} else {
|
|
3880
|
+
inputs.push("[mraw]");
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
if (voiceBus) inputs.push(voiceBus);
|
|
3884
|
+
filters.push(
|
|
3885
|
+
`${inputs.join("")}amix=inputs=${inputs.length}:normalize=0:duration=longest,loudnorm=I=-16:TP=-1.5:LRA=11[mix]`
|
|
3886
|
+
);
|
|
3887
|
+
const tmp = videoPath.replace(/\.mp4$/, ".audio.tmp.mp4");
|
|
3888
|
+
args.push(
|
|
3889
|
+
"-filter_complex",
|
|
3890
|
+
filters.join(";"),
|
|
3891
|
+
"-map",
|
|
3892
|
+
"0:v:0",
|
|
3893
|
+
"-map",
|
|
3894
|
+
"[mix]",
|
|
3895
|
+
"-c:v",
|
|
3896
|
+
"copy",
|
|
3897
|
+
"-c:a",
|
|
3898
|
+
"aac",
|
|
3899
|
+
"-b:a",
|
|
3900
|
+
"160k",
|
|
3901
|
+
"-shortest",
|
|
3902
|
+
"-movflags",
|
|
3903
|
+
"+faststart",
|
|
3904
|
+
tmp
|
|
3905
|
+
);
|
|
3906
|
+
await exec2(ffmpegPath2, args, { maxBuffer: 1 << 24 });
|
|
3907
|
+
await rename(tmp, videoPath);
|
|
3908
|
+
return placed.map((p) => ({ stepRef: p.clip.stepRef, tStart: p.start, durMs: p.clip.durationMs }));
|
|
3909
|
+
}
|
|
3910
|
+
async function ensureClickSample(cacheDir) {
|
|
3911
|
+
await mkdir3(cacheDir, { recursive: true });
|
|
3912
|
+
const file = join6(cacheDir, "click.wav");
|
|
3913
|
+
if (existsSync3(file)) return file;
|
|
3914
|
+
await exec2(ffmpegPath2, [
|
|
3915
|
+
"-y",
|
|
3916
|
+
"-loglevel",
|
|
3917
|
+
"error",
|
|
3918
|
+
"-f",
|
|
3919
|
+
"lavfi",
|
|
3920
|
+
"-i",
|
|
3921
|
+
"sine=frequency=1250:duration=0.05",
|
|
3922
|
+
"-f",
|
|
3923
|
+
"lavfi",
|
|
3924
|
+
"-i",
|
|
3925
|
+
"sine=frequency=2500:duration=0.03",
|
|
3926
|
+
"-filter_complex",
|
|
3927
|
+
"[0]volume=0.5[a];[1]volume=0.22[b];[a][b]amix=inputs=2:normalize=0,afade=t=in:d=0.004,afade=t=out:st=0.014:d=0.036,aformat=sample_rates=44100:channel_layouts=stereo",
|
|
3928
|
+
file
|
|
3929
|
+
]);
|
|
3930
|
+
return file;
|
|
3931
|
+
}
|
|
3932
|
+
|
|
3933
|
+
// src/compose/index.ts
|
|
3934
|
+
function specStepTexts(spec) {
|
|
3935
|
+
const map = /* @__PURE__ */ new Map();
|
|
3936
|
+
for (const a of flattenSteps(spec)) {
|
|
3937
|
+
const s = a.step;
|
|
3938
|
+
if (s.caption || s.narration) {
|
|
3939
|
+
map.set(`${a.sceneId}/${a.stepIndex}`, {
|
|
3940
|
+
...s.caption ? { caption: s.caption } : {},
|
|
3941
|
+
...s.narration ? { narration: s.narration } : {}
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
return map;
|
|
3946
|
+
}
|
|
3947
|
+
function plan(bundle, spec, theme, profile, audioMs) {
|
|
3948
|
+
registerFonts();
|
|
3949
|
+
const resolvedTheme = theme ?? resolveTheme(spec);
|
|
3950
|
+
const kind = kindProfile(spec.output.kind);
|
|
3951
|
+
let prof = profile ?? profileForAspect(spec.output.aspect, resolveResolution(spec), spec.output.fps);
|
|
3952
|
+
if (kind.stage && !prof.stage) prof = withStage(prof, true);
|
|
3953
|
+
const diagnostics = { overflows: [], cameraFallbacks: [], clockWarnings: [] };
|
|
3954
|
+
const cfg = pacingConfig(spec.output.pacing ?? kind.pacing, kind);
|
|
3955
|
+
const dwell = spec.output.dwellScale;
|
|
3956
|
+
if (dwell && dwell !== 1) {
|
|
3957
|
+
cfg.minStepMs *= dwell;
|
|
3958
|
+
cfg.outroMs *= dwell;
|
|
3959
|
+
for (const k of Object.keys(cfg.holds)) cfg.holds[k] *= dwell;
|
|
3960
|
+
}
|
|
3961
|
+
const timeline = buildTimeline(bundle.events, cfg, audioMs);
|
|
3962
|
+
const END_CARD_MS = 2400;
|
|
3963
|
+
if (spec.output.endCard) {
|
|
3964
|
+
const endStart = timeline.durationMs;
|
|
3965
|
+
timeline.segments.push({ kind: "card", outStart: endStart, outEnd: endStart + END_CARD_MS, cardId: "end" });
|
|
3966
|
+
timeline.durationMs += END_CARD_MS;
|
|
3967
|
+
}
|
|
3968
|
+
const FAILURE_CARD_MS = 3200;
|
|
3969
|
+
if (bundle.manifest.partial && bundle.manifest.failure) {
|
|
3970
|
+
const failStart = timeline.durationMs;
|
|
3971
|
+
timeline.segments.push({ kind: "card", outStart: failStart, outEnd: failStart + FAILURE_CARD_MS, cardId: "failure" });
|
|
3972
|
+
timeline.durationMs += FAILURE_CARD_MS;
|
|
3973
|
+
}
|
|
3974
|
+
for (const step of timeline.steps) {
|
|
3975
|
+
const hasFrame = bundle.frames.some((f) => f.t >= step.srcAction && f.t <= step.srcAction + 150);
|
|
3976
|
+
if (!hasFrame && step.kind !== "wait" && step.kind !== "expect") {
|
|
3977
|
+
diagnostics.clockWarnings.push(`${step.stepRef}: no source frame within 150ms after the action`);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
const camera = planCamera(timeline.steps, bundle.events, prof, timeline.durationMs, {
|
|
3981
|
+
emphasis: kind.cameraEmphasis
|
|
3982
|
+
});
|
|
3983
|
+
diagnostics.cameraFallbacks.push(...camera.fallbacks);
|
|
3984
|
+
const cursor = planCursor(timeline.steps, bundle.events);
|
|
3985
|
+
const transitions = [];
|
|
3986
|
+
for (let i = 1; i < timeline.steps.length; i++) {
|
|
3987
|
+
const prev = timeline.steps[i - 1];
|
|
3988
|
+
const cur = timeline.steps[i];
|
|
3989
|
+
if (cur.sceneId !== prev.sceneId) {
|
|
3990
|
+
const tStart = cur.outStart;
|
|
3991
|
+
transitions.push({ tStart, tEnd: tStart + 280, srcFrom: Math.max(0, prev.srcSettled - 1) });
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
const subtitle = spec.subtitle ?? safeHost(bundle.manifest.appUrl);
|
|
3995
|
+
const sceneTitles = new Map(
|
|
3996
|
+
spec.scenes.filter((s) => s.title).map((s) => [s.id, s.title])
|
|
3997
|
+
);
|
|
3998
|
+
const overlays = buildOverlays(
|
|
3999
|
+
timeline.steps,
|
|
4000
|
+
bundle.events,
|
|
4001
|
+
resolvedTheme,
|
|
4002
|
+
prof,
|
|
4003
|
+
spec.title,
|
|
4004
|
+
subtitle,
|
|
4005
|
+
cfg.titleCardMs,
|
|
4006
|
+
diagnostics,
|
|
4007
|
+
kind,
|
|
4008
|
+
sceneTitles,
|
|
4009
|
+
camera.keyframes,
|
|
4010
|
+
specStepTexts(spec)
|
|
4011
|
+
);
|
|
4012
|
+
if (spec.output.endCard && !bundle.manifest.partial) {
|
|
4013
|
+
const endStart = timeline.durationMs - END_CARD_MS;
|
|
4014
|
+
overlays.push({ kind: "dip", tStart: endStart - 350, tEnd: endStart, direction: "out" });
|
|
4015
|
+
overlays.push({
|
|
4016
|
+
kind: "titleCard",
|
|
4017
|
+
cardId: "end",
|
|
4018
|
+
title: spec.output.endCard.title,
|
|
4019
|
+
...spec.output.endCard.subtitle ? { subtitle: spec.output.endCard.subtitle } : {},
|
|
4020
|
+
tStart: endStart,
|
|
4021
|
+
tEnd: timeline.durationMs
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
if (bundle.manifest.partial && bundle.manifest.failure) {
|
|
4025
|
+
const failStart = timeline.durationMs - FAILURE_CARD_MS;
|
|
4026
|
+
overlays.push({ kind: "dip", tStart: failStart - 300, tEnd: failStart, direction: "out" });
|
|
4027
|
+
overlays.push({
|
|
4028
|
+
kind: "failureCard",
|
|
4029
|
+
stepRef: bundle.manifest.failure.stepRef,
|
|
4030
|
+
message: bundle.manifest.failure.message,
|
|
4031
|
+
tStart: failStart,
|
|
4032
|
+
tEnd: timeline.durationMs
|
|
4033
|
+
});
|
|
4034
|
+
}
|
|
4035
|
+
const totalFrames = Math.ceil(timeline.durationMs / 1e3 * prof.fps);
|
|
4036
|
+
return {
|
|
4037
|
+
schema: "playhead/compose@1",
|
|
4038
|
+
profile: prof,
|
|
4039
|
+
viewport: bundle.manifest.viewport,
|
|
4040
|
+
durationMs: timeline.durationMs,
|
|
4041
|
+
totalFrames,
|
|
4042
|
+
segments: timeline.segments,
|
|
4043
|
+
camera: camera.keyframes,
|
|
4044
|
+
cursor: cursor.segments,
|
|
4045
|
+
ripples: cursor.ripples,
|
|
4046
|
+
overlays,
|
|
4047
|
+
...transitions.length > 0 ? { transitions } : {},
|
|
4048
|
+
steps: timeline.steps,
|
|
4049
|
+
theme: resolvedTheme,
|
|
4050
|
+
diagnostics,
|
|
4051
|
+
bundleHashes: bundle.manifest.hashes
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
async function compose(bundle, spec, opts) {
|
|
4055
|
+
const audioCfg = spec.output.audio;
|
|
4056
|
+
let narration;
|
|
4057
|
+
let audioMs;
|
|
4058
|
+
if (audioCfg && audioCfg.narration === "tts") {
|
|
4059
|
+
const texts = specStepTexts(spec);
|
|
4060
|
+
const lines = actionEvents(bundle.events).map((e) => {
|
|
4061
|
+
const ref = `${e.sceneId}/${e.stepIndex}`;
|
|
4062
|
+
return { stepRef: ref, text: writeNarration(e, texts.get(ref)) };
|
|
4063
|
+
}).filter((l) => typeof l.text === "string" && l.text.length > 0);
|
|
4064
|
+
log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${audioCfg.voice ?? "Samantha"})`);
|
|
4065
|
+
try {
|
|
4066
|
+
narration = await synthesizeNarration(lines, audioCfg, join7(opts.outDir, "narration"));
|
|
4067
|
+
audioMs = new Map([...narration].map(([k, v]) => [k, v.durationMs]));
|
|
4068
|
+
} catch (e) {
|
|
4069
|
+
log.warn(`narration unavailable on this host (${e.message.split("\n")[0]}) \u2014 rendering WITHOUT voice`);
|
|
4070
|
+
narration = void 0;
|
|
4071
|
+
audioMs = void 0;
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
const manifest = plan(bundle, spec, opts.theme, opts.profile, audioMs);
|
|
4075
|
+
for (const w of manifest.diagnostics.clockWarnings) log.warn(w);
|
|
4076
|
+
for (const f of manifest.diagnostics.cameraFallbacks) log.warn(f);
|
|
4077
|
+
const videoPath = join7(opts.outDir, opts.fileName ?? "out.mp4");
|
|
4078
|
+
log.info(
|
|
4079
|
+
`composing ${(manifest.durationMs / 1e3).toFixed(1)}s (${manifest.totalFrames} frames @ ${manifest.profile.fps}fps, ${manifest.profile.width}x${manifest.profile.height})`
|
|
4080
|
+
);
|
|
4081
|
+
await renderVideo(bundle, manifest, videoPath);
|
|
4082
|
+
if (audioCfg && (narration || audioCfg.sfx || audioCfg.music)) {
|
|
4083
|
+
const clips = await mixAudio(videoPath, manifest.steps, manifest.durationMs, {
|
|
4084
|
+
...narration ? { narration } : {},
|
|
4085
|
+
...audioCfg.sfx ? { sfxBeats: manifest.ripples } : {},
|
|
4086
|
+
...audioCfg.music ? { music: audioCfg.music } : {},
|
|
4087
|
+
cacheDir: join7(opts.outDir, "narration")
|
|
4088
|
+
});
|
|
4089
|
+
if (narration) {
|
|
4090
|
+
manifest.audio = {
|
|
4091
|
+
narrated: true,
|
|
4092
|
+
provider: audioCfg.provider,
|
|
4093
|
+
voice: audioCfg.voice ?? "Samantha",
|
|
4094
|
+
lines: clips.length,
|
|
4095
|
+
clips
|
|
4096
|
+
};
|
|
4097
|
+
}
|
|
4098
|
+
log.info(
|
|
4099
|
+
`\u2713 audio: ${clips.length} narration line(s)${audioCfg.sfx ? `, ${manifest.ripples.length} click(s)` : ""}${audioCfg.music ? ", music bed" : ""} \u2192 ${videoPath}`
|
|
4100
|
+
);
|
|
4101
|
+
}
|
|
4102
|
+
manifest.videoSha256 = await sha256File(videoPath);
|
|
4103
|
+
const manifestPath = join7(opts.outDir, "compose-manifest.json");
|
|
4104
|
+
await writeFile3(manifestPath, JSON.stringify(manifest, null, 2));
|
|
4105
|
+
return { videoPath, manifestPath, manifest };
|
|
4106
|
+
}
|
|
4107
|
+
async function renderFromManifest(bundle, manifest, videoPath) {
|
|
4108
|
+
await renderVideo(bundle, manifest, videoPath);
|
|
4109
|
+
}
|
|
4110
|
+
function safeHost(url) {
|
|
4111
|
+
try {
|
|
4112
|
+
return new URL(url).host;
|
|
4113
|
+
} catch {
|
|
4114
|
+
return url;
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
// src/verify/runner.ts
|
|
4119
|
+
import { join as join8 } from "path";
|
|
4120
|
+
import { mkdir as mkdir4, writeFile as writeFile5, readFile as readFile4 } from "fs/promises";
|
|
4121
|
+
|
|
4122
|
+
// src/verify/media.ts
|
|
4123
|
+
import sharp3 from "sharp";
|
|
4124
|
+
async function extractGrayFrames(videoPath, fps, w, h) {
|
|
4125
|
+
const res = await runBinaryRaw(ffmpegPath(), [
|
|
4126
|
+
"-hide_banner",
|
|
4127
|
+
"-loglevel",
|
|
4128
|
+
"error",
|
|
4129
|
+
"-i",
|
|
4130
|
+
videoPath,
|
|
4131
|
+
"-vf",
|
|
4132
|
+
`fps=${fps},scale=${w}:${h}`,
|
|
4133
|
+
"-f",
|
|
4134
|
+
"rawvideo",
|
|
4135
|
+
"-pix_fmt",
|
|
4136
|
+
"gray",
|
|
4137
|
+
"pipe:1"
|
|
4138
|
+
]);
|
|
4139
|
+
if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
|
|
4140
|
+
const frameSize = w * h;
|
|
4141
|
+
const frames = [];
|
|
4142
|
+
for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
|
|
4143
|
+
frames.push(res.stdout.subarray(off, off + frameSize));
|
|
4144
|
+
}
|
|
4145
|
+
return { frames, intervalMs: 1e3 / fps };
|
|
4146
|
+
}
|
|
4147
|
+
async function extractFrameAt(videoPath, tMs) {
|
|
4148
|
+
const res = await runBinaryRaw(ffmpegPath(), [
|
|
4149
|
+
"-hide_banner",
|
|
4150
|
+
"-loglevel",
|
|
4151
|
+
"error",
|
|
4152
|
+
"-ss",
|
|
4153
|
+
(Math.max(0, tMs) / 1e3).toFixed(3),
|
|
4154
|
+
"-i",
|
|
4155
|
+
videoPath,
|
|
4156
|
+
"-frames:v",
|
|
4157
|
+
"1",
|
|
4158
|
+
"-f",
|
|
4159
|
+
"image2pipe",
|
|
4160
|
+
"-vcodec",
|
|
4161
|
+
"png",
|
|
4162
|
+
"pipe:1"
|
|
4163
|
+
]);
|
|
4164
|
+
if (res.code !== 0 || res.stdout.length === 0) {
|
|
4165
|
+
throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
|
|
4166
|
+
}
|
|
4167
|
+
return res.stdout;
|
|
4168
|
+
}
|
|
4169
|
+
async function probeVideo(videoPath) {
|
|
4170
|
+
const res = await runBinary(ffprobePath(), [
|
|
4171
|
+
"-v",
|
|
4172
|
+
"error",
|
|
4173
|
+
"-print_format",
|
|
4174
|
+
"json",
|
|
4175
|
+
"-show_format",
|
|
4176
|
+
"-show_streams",
|
|
4177
|
+
videoPath
|
|
4178
|
+
]);
|
|
4179
|
+
if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
|
|
4180
|
+
const json = JSON.parse(res.stdout);
|
|
4181
|
+
const v = json.streams?.find((s) => s.codec_type === "video");
|
|
4182
|
+
if (!v) throw new Error("no video stream found");
|
|
4183
|
+
const [num, den] = v.avg_frame_rate.split("/").map(Number);
|
|
4184
|
+
return {
|
|
4185
|
+
codec: v.codec_name,
|
|
4186
|
+
width: v.width,
|
|
4187
|
+
height: v.height,
|
|
4188
|
+
durationSec: Number(json.format?.duration ?? 0),
|
|
4189
|
+
fps: den ? num / den : 0
|
|
4190
|
+
};
|
|
4191
|
+
}
|
|
4192
|
+
function meanAbsDiff(a, b) {
|
|
4193
|
+
const n = Math.min(a.length, b.length);
|
|
4194
|
+
let sum = 0;
|
|
4195
|
+
for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
|
|
4196
|
+
return sum / n;
|
|
4197
|
+
}
|
|
4198
|
+
function variance(a) {
|
|
4199
|
+
let sum = 0;
|
|
4200
|
+
for (let i = 0; i < a.length; i++) sum += a[i];
|
|
4201
|
+
const mean = sum / a.length;
|
|
4202
|
+
let v = 0;
|
|
4203
|
+
for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
|
|
4204
|
+
return v / a.length;
|
|
4205
|
+
}
|
|
4206
|
+
async function sourceFrameGray(bundle, tMs, width) {
|
|
4207
|
+
const idx = frameIndexForTime(bundle.frames, tMs);
|
|
4208
|
+
const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
4209
|
+
return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
|
|
4210
|
+
}
|
|
4211
|
+
async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
|
|
4212
|
+
let idx = frameIndexForTime(bundle.frames, tMs);
|
|
4213
|
+
if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
|
|
4214
|
+
const meta = await sharp3(framePath(bundle, idx)).metadata();
|
|
4215
|
+
const fw = meta.width ?? 0;
|
|
4216
|
+
const fh = meta.height ?? 0;
|
|
4217
|
+
const x = Math.max(0, Math.round(region.x));
|
|
4218
|
+
const y = Math.max(0, Math.round(region.y));
|
|
4219
|
+
const w = Math.min(Math.round(region.w), fw - x);
|
|
4220
|
+
const h = Math.min(Math.round(region.h), fh - y);
|
|
4221
|
+
if (w < 4 || h < 4) return null;
|
|
4222
|
+
let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
|
|
4223
|
+
if (!color) img = img.grayscale();
|
|
4224
|
+
const { data } = await img.raw().toBuffer({ resolveWithObject: true });
|
|
4225
|
+
return data;
|
|
4226
|
+
}
|
|
4227
|
+
async function regionGray48(input, rect) {
|
|
4228
|
+
try {
|
|
4229
|
+
const img = sharp3(input);
|
|
4230
|
+
const meta = await img.metadata();
|
|
4231
|
+
const W = meta.width ?? 0;
|
|
4232
|
+
const H = meta.height ?? 0;
|
|
4233
|
+
const left = Math.max(0, Math.round(rect.x));
|
|
4234
|
+
const top = Math.max(0, Math.round(rect.y));
|
|
4235
|
+
const width = Math.min(W - left, Math.round(rect.w));
|
|
4236
|
+
const height = Math.min(H - top, Math.round(rect.h));
|
|
4237
|
+
if (width < 8 || height < 8) return null;
|
|
4238
|
+
return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
|
|
4239
|
+
} catch {
|
|
4240
|
+
return null;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
function normalizedCorrelation(a, b) {
|
|
4244
|
+
const n = Math.min(a.length, b.length);
|
|
4245
|
+
let ma = 0;
|
|
4246
|
+
let mb = 0;
|
|
4247
|
+
for (let i = 0; i < n; i++) {
|
|
4248
|
+
ma += a[i];
|
|
4249
|
+
mb += b[i];
|
|
4250
|
+
}
|
|
4251
|
+
ma /= n;
|
|
4252
|
+
mb /= n;
|
|
4253
|
+
let num = 0;
|
|
4254
|
+
let da = 0;
|
|
4255
|
+
let db = 0;
|
|
4256
|
+
for (let i = 0; i < n; i++) {
|
|
4257
|
+
const xa = a[i] - ma;
|
|
4258
|
+
const xb = b[i] - mb;
|
|
4259
|
+
num += xa * xb;
|
|
4260
|
+
da += xa * xa;
|
|
4261
|
+
db += xb * xb;
|
|
4262
|
+
}
|
|
4263
|
+
if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
|
|
4264
|
+
return num / Math.sqrt(da * db);
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
// src/verify/checks.ts
|
|
4268
|
+
var EFFECT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "type", "select", "goto"]);
|
|
4269
|
+
var CLICK_KINDS2 = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
|
|
4270
|
+
function plane(ctx2) {
|
|
4271
|
+
const content = stageContent(ctx2.manifest.profile);
|
|
4272
|
+
return {
|
|
4273
|
+
vpW: ctx2.manifest.viewport.w,
|
|
4274
|
+
vpH: ctx2.manifest.viewport.h,
|
|
4275
|
+
outW: content.w,
|
|
4276
|
+
outH: content.h
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
async function checkTargetInFrame(ctx2) {
|
|
4280
|
+
const p = plane(ctx2);
|
|
4281
|
+
const frame = { x: 0, y: 0, w: p.outW, h: p.outH };
|
|
4282
|
+
const evidence = [];
|
|
4283
|
+
let checked = 0;
|
|
4284
|
+
let minHeight = Infinity;
|
|
4285
|
+
const captions = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
|
|
4286
|
+
for (const step of ctx2.manifest.steps) {
|
|
4287
|
+
const handoff = actReactWindows(step);
|
|
4288
|
+
const rect = step.focusRectVp ?? step.targetRectVp;
|
|
4289
|
+
const windows = handoff ? [handoff.act, handoff.react] : rect ? [{ rect, from: Math.max(step.outStart, step.outBeat - 60), to: step.outEnd }] : [];
|
|
4290
|
+
if (windows.length === 0) continue;
|
|
4291
|
+
checked += 1;
|
|
4292
|
+
for (const w of windows)
|
|
4293
|
+
for (let t = w.from; t <= w.to; t += 100) {
|
|
4294
|
+
const rect2 = w.rect;
|
|
4295
|
+
const cam = sampleCamera(ctx2.manifest.camera, t);
|
|
4296
|
+
const proj = projectRect(rect2, cam, p);
|
|
4297
|
+
minHeight = Math.min(minHeight, proj.h);
|
|
4298
|
+
if (!rectContains(frame, proj, 2)) {
|
|
4299
|
+
evidence.push({ stepRef: step.stepRef, t, note: `target cropped by frame edge (projected ${fmtRect(proj)})` });
|
|
4300
|
+
break;
|
|
4301
|
+
}
|
|
4302
|
+
if (step.kind !== "scroll" && step.focus !== "wide" && proj.h < 28) {
|
|
4303
|
+
evidence.push({ stepRef: step.stepRef, t, note: `target renders too small (${proj.h.toFixed(0)}px high)` });
|
|
4304
|
+
break;
|
|
4305
|
+
}
|
|
4306
|
+
const content = stageContent(ctx2.manifest.profile);
|
|
4307
|
+
const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
|
|
4308
|
+
const cap = captions.find((c) => t >= c.tStart + 150 && t <= c.tEnd - 150 && rectsOverlap(c.box, projFrame));
|
|
4309
|
+
if (cap) {
|
|
4310
|
+
evidence.push({ stepRef: step.stepRef, t, note: `caption "${cap.text}" overlaps the target` });
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
if (checked === 0) return { id: "target-in-frame", status: "skip", details: "no steps with targets" };
|
|
4316
|
+
return evidence.length > 0 ? { id: "target-in-frame", status: "fail", details: `${evidence.length}/${checked} targets violate framing`, evidence } : {
|
|
4317
|
+
id: "target-in-frame",
|
|
4318
|
+
status: "pass",
|
|
4319
|
+
details: `${checked}/${checked} targets fully framed, min rendered height ${minHeight === Infinity ? "\u2014" : minHeight.toFixed(0) + "px"}`
|
|
4320
|
+
};
|
|
4321
|
+
}
|
|
4322
|
+
async function checkCursorOnTarget(ctx2) {
|
|
4323
|
+
const cursorPlan = {
|
|
4324
|
+
segments: ctx2.manifest.cursor,
|
|
4325
|
+
ripples: ctx2.manifest.ripples,
|
|
4326
|
+
restStart: { x: ctx2.manifest.viewport.w / 2, y: ctx2.manifest.viewport.h * 0.6 }
|
|
4327
|
+
};
|
|
4328
|
+
const evidence = [];
|
|
4329
|
+
let checked = 0;
|
|
4330
|
+
for (const step of ctx2.manifest.steps) {
|
|
4331
|
+
if (!CLICK_KINDS2.has(step.kind) || !step.targetRectVp) continue;
|
|
4332
|
+
checked += 1;
|
|
4333
|
+
const pos = sampleCursor(cursorPlan, step.outBeat);
|
|
4334
|
+
const r = step.targetRectVp;
|
|
4335
|
+
const pad = 4;
|
|
4336
|
+
const inside = pos.x >= r.x - pad && pos.x <= r.x + r.w + pad && pos.y >= r.y - pad && pos.y <= r.y + r.h + pad;
|
|
4337
|
+
if (!inside) {
|
|
4338
|
+
evidence.push({
|
|
4339
|
+
stepRef: step.stepRef,
|
|
4340
|
+
t: step.outBeat,
|
|
4341
|
+
note: `cursor at (${pos.x.toFixed(0)},${pos.y.toFixed(0)}) missed target ${fmtRect(r)}`
|
|
4342
|
+
});
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
if (checked === 0) return { id: "cursor-on-target", status: "skip", details: "no click steps" };
|
|
4346
|
+
return evidence.length > 0 ? { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence } : { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target` };
|
|
4347
|
+
}
|
|
4348
|
+
async function checkActionEffect(ctx2) {
|
|
4349
|
+
const evidence = [];
|
|
4350
|
+
const inconclusive = [];
|
|
4351
|
+
let checked = 0;
|
|
4352
|
+
const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
|
|
4353
|
+
const lastFrameT = ctx2.bundle.frames[ctx2.bundle.frames.length - 1].t;
|
|
4354
|
+
const steps = ctx2.manifest.steps;
|
|
4355
|
+
let skippedMasked = 0;
|
|
4356
|
+
for (let i = 0; i < steps.length; i++) {
|
|
4357
|
+
const step = steps[i];
|
|
4358
|
+
if (!EFFECT_KINDS.has(step.kind)) continue;
|
|
4359
|
+
if (step.masked) {
|
|
4360
|
+
skippedMasked += 1;
|
|
4361
|
+
continue;
|
|
4362
|
+
}
|
|
4363
|
+
checked += 1;
|
|
4364
|
+
const nextAction = i + 1 < steps.length ? steps[i + 1].srcAction : lastFrameT;
|
|
4365
|
+
const afterT = Math.max(step.srcSettled, nextAction - 60);
|
|
4366
|
+
const before = await sourceFrameGray(ctx2.bundle, step.srcAction - 40, 320);
|
|
4367
|
+
const after = await sourceFrameGray(ctx2.bundle, afterT, 320);
|
|
4368
|
+
if (after.frameT <= before.frameT) {
|
|
4369
|
+
inconclusive.push({ stepRef: step.stepRef, note: "no post-action frame captured to compare" });
|
|
4370
|
+
continue;
|
|
4371
|
+
}
|
|
4372
|
+
if (before.frameT >= step.srcAction) {
|
|
4373
|
+
inconclusive.push({ stepRef: step.stepRef, note: "no pre-action frame captured to compare" });
|
|
4374
|
+
continue;
|
|
4375
|
+
}
|
|
4376
|
+
const overall = meanAbsDiff(before.data, after.data);
|
|
4377
|
+
let regionDiff = 0;
|
|
4378
|
+
const regions = [step.targetRectVp, step.focusRectVp].filter((r) => !!r);
|
|
4379
|
+
for (const r of regions) {
|
|
4380
|
+
const region = scaleRect(r, frameScale);
|
|
4381
|
+
const [ra, rb] = await Promise.all([
|
|
4382
|
+
sourceRegionGray(ctx2.bundle, step.srcAction - 40, region, false, true),
|
|
4383
|
+
sourceRegionGray(ctx2.bundle, afterT, region, true, true)
|
|
4384
|
+
]);
|
|
4385
|
+
if (ra && rb && ra.length === rb.length) regionDiff = Math.max(regionDiff, meanAbsDiff(ra, rb));
|
|
4386
|
+
}
|
|
4387
|
+
if (overall < 0.5 && regionDiff < 2) {
|
|
4388
|
+
evidence.push({
|
|
4389
|
+
stepRef: step.stepRef,
|
|
4390
|
+
t: step.outBeat,
|
|
4391
|
+
note: `no visible effect (frame diff ${overall.toFixed(2)}, target diff ${regionDiff.toFixed(2)})`
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
if (checked === 0) {
|
|
4396
|
+
return {
|
|
4397
|
+
id: "action-effect",
|
|
4398
|
+
status: "skip",
|
|
4399
|
+
details: skippedMasked > 0 ? `no verifiable actions (${skippedMasked} masked)` : "no effectful actions"
|
|
4400
|
+
};
|
|
4401
|
+
}
|
|
4402
|
+
const maskedNote = skippedMasked > 0 ? ` (${skippedMasked} masked skipped)` : "";
|
|
4403
|
+
const inconcNote = inconclusive.length > 0 ? `, ${inconclusive.length} inconclusive (capture gap)` : "";
|
|
4404
|
+
if (evidence.length > 0) {
|
|
4405
|
+
return {
|
|
4406
|
+
id: "action-effect",
|
|
4407
|
+
status: "fail",
|
|
4408
|
+
details: `${evidence.length}/${checked} actions show no visible effect${maskedNote}${inconcNote}`,
|
|
4409
|
+
evidence: [...evidence, ...inconclusive]
|
|
4410
|
+
};
|
|
4411
|
+
}
|
|
4412
|
+
if (inconclusive.length > 0) {
|
|
4413
|
+
return {
|
|
4414
|
+
id: "action-effect",
|
|
4415
|
+
status: "warn",
|
|
4416
|
+
details: `${checked - inconclusive.length}/${checked} actions visibly effective; ${inconclusive.length} unverified (capture gap)${maskedNote}`,
|
|
4417
|
+
evidence: inconclusive
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}` };
|
|
4421
|
+
}
|
|
4422
|
+
async function checkFrozenBlank(ctx2) {
|
|
4423
|
+
const { frames, intervalMs } = await extractGrayFrames(ctx2.videoPath, 2, 320, 180);
|
|
4424
|
+
if (frames.length < 2) return { id: "frozen-blank", status: "fail", details: "could not decode output frames" };
|
|
4425
|
+
const evidence = [];
|
|
4426
|
+
const dips = ctx2.manifest.overlays.filter((o) => o.kind === "dip");
|
|
4427
|
+
const inTransition = (t) => segmentAt(ctx2.manifest.segments, t).kind === "card" || dips.some((d) => t >= d.tStart - 60 && t <= d.tEnd + 60);
|
|
4428
|
+
for (let i = 0; i < frames.length; i++) {
|
|
4429
|
+
const t = i * intervalMs;
|
|
4430
|
+
if (inTransition(t)) continue;
|
|
4431
|
+
if (variance(frames[i]) < 2) {
|
|
4432
|
+
evidence.push({ t, note: `blank frame at ${(t / 1e3).toFixed(1)}s` });
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
const maskedWindows = ctx2.manifest.steps.filter((s) => s.masked).map((s) => ({ start: s.outStart, end: s.outEnd }));
|
|
4436
|
+
const inMaskedWindow = (t0, t1) => maskedWindows.some((w) => t1 >= w.start - 200 && t0 <= w.end + 200);
|
|
4437
|
+
const lastBeat = ctx2.manifest.steps.reduce((m, s) => Math.max(m, s.outBeat), 0);
|
|
4438
|
+
const narrated = !!ctx2.manifest.audio?.narrated;
|
|
4439
|
+
const clipWindows = (ctx2.manifest.audio?.clips ?? []).map((c) => ({
|
|
4440
|
+
start: c.tStart - 300,
|
|
4441
|
+
end: c.tStart + c.durMs + 1200
|
|
4442
|
+
// clip + the tail-and-extend hold pacing grants it
|
|
4443
|
+
}));
|
|
4444
|
+
const voiceCovers = (t0, t1) => clipWindows.length === 0 ? narrated : clipWindows.some((w) => t0 >= w.start && t1 <= w.end);
|
|
4445
|
+
let runStart = 0;
|
|
4446
|
+
for (let i = 1; i <= frames.length; i++) {
|
|
4447
|
+
const identical = i < frames.length && meanAbsDiff(frames[i], frames[i - 1]) < 0.15;
|
|
4448
|
+
if (!identical) {
|
|
4449
|
+
const runMs = (i - 1 - runStart) * intervalMs;
|
|
4450
|
+
const t0 = runStart * intervalMs;
|
|
4451
|
+
const t1 = (i - 1) * intervalMs;
|
|
4452
|
+
const inFinalHold = t0 >= lastBeat - 800;
|
|
4453
|
+
if (runMs > 2e3 && !inMaskedWindow(t0, t1) && !inFinalHold && !(narrated && voiceCovers(t0, t1))) {
|
|
4454
|
+
const beatsInside = ctx2.manifest.steps.filter((s) => s.outBeat > t0 + 200 && s.outBeat < t1 - 200);
|
|
4455
|
+
if (beatsInside.length >= 1 && runMs > 2500) {
|
|
4456
|
+
evidence.push({
|
|
4457
|
+
t: t0,
|
|
4458
|
+
note: `frozen footage ${(runMs / 1e3).toFixed(1)}s spanning ${beatsInside.length} action beat(s)`
|
|
4459
|
+
});
|
|
4460
|
+
} else if (runMs > 8e3) {
|
|
4461
|
+
evidence.push({ t: t0, note: `frozen footage for ${(runMs / 1e3).toFixed(1)}s` });
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
runStart = i;
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
return evidence.length > 0 ? { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence } : { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
|
|
4468
|
+
}
|
|
4469
|
+
async function checkCaptions(ctx2) {
|
|
4470
|
+
const evidence = [];
|
|
4471
|
+
let warned = 0;
|
|
4472
|
+
let count = 0;
|
|
4473
|
+
const out = { x: 0, y: 0, w: ctx2.manifest.profile.width, h: ctx2.manifest.profile.height };
|
|
4474
|
+
for (const o of ctx2.manifest.overlays) {
|
|
4475
|
+
if (o.kind !== "caption") continue;
|
|
4476
|
+
count += 1;
|
|
4477
|
+
if (o.truncated) evidence.push({ stepRef: o.stepRef, note: `caption truncated: "${o.text}"` });
|
|
4478
|
+
if (o.fontPx < ctx2.manifest.theme.captionMinFontPx)
|
|
4479
|
+
evidence.push({ stepRef: o.stepRef, note: `caption font ${o.fontPx}px below minimum` });
|
|
4480
|
+
if (!rectContains(out, o.box, 24)) evidence.push({ stepRef: o.stepRef, note: "caption box outside safe bounds" });
|
|
4481
|
+
if (o.shrunk && !o.truncated) warned += 1;
|
|
4482
|
+
}
|
|
4483
|
+
if (count === 0) return { id: "captions", status: "skip", details: "no captions" };
|
|
4484
|
+
if (evidence.length > 0)
|
|
4485
|
+
return { id: "captions", status: "fail", details: `${evidence.length} caption problem(s)`, evidence };
|
|
4486
|
+
return warned > 0 ? { id: "captions", status: "warn", details: `${count} captions ok, ${warned} auto-shrunk to fit` } : { id: "captions", status: "pass", details: `${count} captions fit at full size` };
|
|
4487
|
+
}
|
|
4488
|
+
async function checkPacing(ctx2) {
|
|
4489
|
+
const evidence = [];
|
|
4490
|
+
for (const step of ctx2.manifest.steps) {
|
|
4491
|
+
const dur = step.outEnd - step.outStart;
|
|
4492
|
+
if (step.kind !== "wait" && dur < 1200) {
|
|
4493
|
+
evidence.push({ stepRef: step.stepRef, note: `step on screen only ${(dur / 1e3).toFixed(1)}s` });
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
const total = ctx2.manifest.durationMs;
|
|
4497
|
+
if (total < 3e3) evidence.push({ note: `video too short (${(total / 1e3).toFixed(1)}s)` });
|
|
4498
|
+
if (total > 15 * 6e4) evidence.push({ note: `video too long (${(total / 6e4).toFixed(1)}min)` });
|
|
4499
|
+
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` };
|
|
4500
|
+
}
|
|
4501
|
+
async function checkAudio(ctx2) {
|
|
4502
|
+
if (!ctx2.manifest.audio?.narrated) return { id: "audio", status: "skip", details: "no narration" };
|
|
4503
|
+
const evidence = [];
|
|
4504
|
+
const probe = await runBinary(ffprobePath(), [
|
|
4505
|
+
"-v",
|
|
4506
|
+
"error",
|
|
4507
|
+
"-select_streams",
|
|
4508
|
+
"a",
|
|
4509
|
+
"-show_entries",
|
|
4510
|
+
"stream=codec_name,duration",
|
|
4511
|
+
"-of",
|
|
4512
|
+
"json",
|
|
4513
|
+
ctx2.videoPath
|
|
4514
|
+
]);
|
|
4515
|
+
const stream = JSON.parse(probe.stdout).streams?.[0];
|
|
4516
|
+
if (!stream) return { id: "audio", status: "fail", details: "narration requested but the output has no audio stream" };
|
|
4517
|
+
const vd = await runBinary(ffmpegPath(), ["-hide_banner", "-nostats", "-i", ctx2.videoPath, "-af", "volumedetect", "-f", "null", "-"]);
|
|
4518
|
+
const mean = Number(/mean_volume:\s*(-?[\d.]+) dB/.exec(vd.stderr)?.[1] ?? "0");
|
|
4519
|
+
if (mean <= -70) evidence.push({ note: `narration track is effectively silent (mean ${mean} dB)` });
|
|
4520
|
+
const expectSec = ctx2.manifest.durationMs / 1e3;
|
|
4521
|
+
const aDur = Number(stream.duration ?? 0);
|
|
4522
|
+
if (aDur > 0 && Math.abs(aDur - expectSec) > Math.max(0.5, expectSec * 0.05))
|
|
4523
|
+
evidence.push({ note: `audio ${aDur.toFixed(1)}s vs video ${expectSec.toFixed(1)}s` });
|
|
4524
|
+
const clips = ctx2.manifest.audio.clips ?? [];
|
|
4525
|
+
let loudness = null;
|
|
4526
|
+
if (clips.length > 0) {
|
|
4527
|
+
const det = await runBinary(ffmpegPath(), [
|
|
4528
|
+
"-hide_banner",
|
|
4529
|
+
"-nostats",
|
|
4530
|
+
"-i",
|
|
4531
|
+
ctx2.videoPath,
|
|
4532
|
+
"-af",
|
|
4533
|
+
"silencedetect=noise=-40dB:d=0.35,ebur128",
|
|
4534
|
+
"-f",
|
|
4535
|
+
"null",
|
|
4536
|
+
"-"
|
|
4537
|
+
]);
|
|
4538
|
+
const silences = [];
|
|
4539
|
+
let open = null;
|
|
4540
|
+
for (const m of det.stderr.matchAll(/silence_(start|end): ([\d.]+)/g)) {
|
|
4541
|
+
if (m[1] === "start") open = parseFloat(m[2]) * 1e3;
|
|
4542
|
+
else if (open !== null) {
|
|
4543
|
+
silences.push({ start: open, end: parseFloat(m[2]) * 1e3 });
|
|
4544
|
+
open = null;
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
if (open !== null) silences.push({ start: open, end: ctx2.manifest.durationMs });
|
|
4548
|
+
for (const clip of clips) {
|
|
4549
|
+
const w0 = clip.tStart;
|
|
4550
|
+
const w1 = clip.tStart + clip.durMs;
|
|
4551
|
+
let silent = 0;
|
|
4552
|
+
for (const s of silences) {
|
|
4553
|
+
silent += Math.max(0, Math.min(w1, s.end) - Math.max(w0, s.start));
|
|
4554
|
+
}
|
|
4555
|
+
const voiced = (w1 - w0 - silent) / Math.max(1, w1 - w0);
|
|
4556
|
+
if (voiced < 0.45) {
|
|
4557
|
+
evidence.push({
|
|
4558
|
+
stepRef: clip.stepRef,
|
|
4559
|
+
t: clip.tStart,
|
|
4560
|
+
note: `narration line is mostly silent in its own window (${(voiced * 100).toFixed(0)}% voiced) \u2014 audio/video drift`
|
|
4561
|
+
});
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
const lus = [...det.stderr.matchAll(/I:\s+(-?[\d.]+)\s+LUFS/g)];
|
|
4565
|
+
const lu = lus[lus.length - 1];
|
|
4566
|
+
if (lu) {
|
|
4567
|
+
loudness = parseFloat(lu[1]);
|
|
4568
|
+
if (loudness < -20 || loudness > -12) {
|
|
4569
|
+
evidence.push({ note: `integrated loudness ${loudness.toFixed(1)} LUFS is outside the -16\xB14 target` });
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
4573
|
+
return evidence.length ? { id: "audio", status: "fail", details: `${evidence.length} narration problem(s)`, evidence } : {
|
|
4574
|
+
id: "audio",
|
|
4575
|
+
status: "pass",
|
|
4576
|
+
details: `${ctx2.manifest.audio.lines} lines audible in their windows, ${stream.codec_name}${loudness !== null ? `, ${loudness.toFixed(1)} LUFS` : `, mean ${mean.toFixed(0)} dB`}`
|
|
4577
|
+
};
|
|
4578
|
+
}
|
|
4579
|
+
async function checkEncode(ctx2) {
|
|
4580
|
+
let info;
|
|
4581
|
+
try {
|
|
4582
|
+
info = await probeVideo(ctx2.videoPath);
|
|
4583
|
+
} catch (e) {
|
|
4584
|
+
return { id: "encode", status: "fail", details: `unreadable video: ${e.message}` };
|
|
4585
|
+
}
|
|
4586
|
+
const problems = [];
|
|
4587
|
+
if (info.codec !== "h264") problems.push(`codec ${info.codec}`);
|
|
4588
|
+
if (info.width !== ctx2.manifest.profile.width || info.height !== ctx2.manifest.profile.height)
|
|
4589
|
+
problems.push(`resolution ${info.width}x${info.height}`);
|
|
4590
|
+
const expectSec = ctx2.manifest.durationMs / 1e3;
|
|
4591
|
+
if (Math.abs(info.durationSec - expectSec) > Math.max(0.5, expectSec * 0.03))
|
|
4592
|
+
problems.push(`duration ${info.durationSec.toFixed(1)}s vs planned ${expectSec.toFixed(1)}s`);
|
|
4593
|
+
if (Math.abs(info.fps - ctx2.manifest.profile.fps) > 0.5) problems.push(`fps ${info.fps.toFixed(2)}`);
|
|
4594
|
+
return problems.length > 0 ? { id: "encode", status: "fail", details: problems.join("; ") } : {
|
|
4595
|
+
id: "encode",
|
|
4596
|
+
status: "pass",
|
|
4597
|
+
details: `h264 ${info.width}x${info.height} @ ${info.fps.toFixed(0)}fps, ${info.durationSec.toFixed(1)}s`
|
|
4598
|
+
};
|
|
4599
|
+
}
|
|
4600
|
+
async function checkMasks(ctx2) {
|
|
4601
|
+
const evidence = [];
|
|
4602
|
+
const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
|
|
4603
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4604
|
+
const blurKeys = /* @__PURE__ */ new Set();
|
|
4605
|
+
for (const sample of ctx2.bundle.maskSamples) {
|
|
4606
|
+
for (const rect of sample.rects) {
|
|
4607
|
+
const posKey = `${Math.round(rect.x / 24)}:${Math.round(rect.y / 24)}:${Math.round(rect.w / 24)}:${Math.round(rect.h / 24)}`;
|
|
4608
|
+
if (rect.style === "blur") {
|
|
4609
|
+
blurKeys.add(posKey);
|
|
4610
|
+
continue;
|
|
4611
|
+
}
|
|
4612
|
+
const inset = scaleRect(shrinkRect(rect, Math.min(6, rect.w / 8, rect.h / 8)), frameScale);
|
|
4613
|
+
const region = await sourceRegionGray(ctx2.bundle, sample.t + 60, inset, true);
|
|
4614
|
+
if (!region) continue;
|
|
4615
|
+
const sd = Math.sqrt(variance(region));
|
|
4616
|
+
const key = posKey;
|
|
4617
|
+
const prev = buckets.get(key);
|
|
4618
|
+
if (!prev || sd < prev.minSd) buckets.set(key, { rect, minSd: sd, t: sample.t });
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
for (const b of buckets.values()) {
|
|
4622
|
+
if (b.minSd > 16) {
|
|
4623
|
+
evidence.push({ t: b.t, note: `solid mask at ${fmtRect(b.rect)} never verified opaque (best \u03C3=${b.minSd.toFixed(1)})` });
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
4626
|
+
for (const e of actionEvents(ctx2.bundle.events)) {
|
|
4627
|
+
if (e.masked && e.typedText && /[^•]/.test(e.typedText)) {
|
|
4628
|
+
evidence.push({ stepRef: `${e.sceneId}/${e.stepIndex}`, note: "masked step leaked typed text into the event log" });
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
if (ctx2.bundle.maskSamples.length === 0) {
|
|
4632
|
+
return { id: "masks", status: "skip", details: "no masking rules" };
|
|
4633
|
+
}
|
|
4634
|
+
if (evidence.length > 0) {
|
|
4635
|
+
return { id: "masks", status: "fail", details: `${evidence.length} mask violation(s)`, evidence };
|
|
4636
|
+
}
|
|
4637
|
+
const parts = [];
|
|
4638
|
+
if (buckets.size > 0) parts.push(`${buckets.size} solid region(s) verified opaque`);
|
|
4639
|
+
if (blurKeys.size > 0) parts.push(`${blurKeys.size} blurred region(s) applied`);
|
|
4640
|
+
return { id: "masks", status: "pass", details: `${parts.join(", ")}; log clean` };
|
|
4641
|
+
}
|
|
4642
|
+
function scaleRect(r, s) {
|
|
4643
|
+
return { x: r.x * s, y: r.y * s, w: r.w * s, h: r.h * s };
|
|
4644
|
+
}
|
|
4645
|
+
function rectsOverlap(a, b) {
|
|
4646
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
4647
|
+
}
|
|
4648
|
+
function shrinkRect(r, by) {
|
|
4649
|
+
return { x: r.x + by, y: r.y + by, w: Math.max(1, r.w - 2 * by), h: Math.max(1, r.h - 2 * by) };
|
|
4650
|
+
}
|
|
4651
|
+
function fmtRect(r) {
|
|
4652
|
+
return `[${r.x.toFixed(0)},${r.y.toFixed(0)} ${r.w.toFixed(0)}x${r.h.toFixed(0)}]`;
|
|
4653
|
+
}
|
|
4654
|
+
async function checkOutputPixels(ctx2) {
|
|
4655
|
+
const p = plane(ctx2);
|
|
4656
|
+
const content = stageContent(ctx2.manifest.profile);
|
|
4657
|
+
const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
|
|
4658
|
+
const captions = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
|
|
4659
|
+
const candidates = ctx2.manifest.steps.filter((s) => !s.masked && (s.focusRectVp ?? s.targetRectVp));
|
|
4660
|
+
const stride = Math.max(1, Math.floor(candidates.length / 6));
|
|
4661
|
+
const sampled = candidates.filter((_, i) => i % stride === 0).slice(0, 6);
|
|
4662
|
+
if (sampled.length === 0) return { id: "output-pixels", status: "skip", details: "no steps with targets" };
|
|
4663
|
+
const evidence = [];
|
|
4664
|
+
let checked = 0;
|
|
4665
|
+
for (const step of sampled) {
|
|
4666
|
+
const rect = step.focusRectVp ?? step.targetRectVp;
|
|
4667
|
+
const tOut = Math.max(step.outBeat + 100, step.outEnd - 250);
|
|
4668
|
+
const cam = sampleCamera(ctx2.manifest.camera, tOut);
|
|
4669
|
+
const proj = projectRect(rect, cam, p);
|
|
4670
|
+
const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
|
|
4671
|
+
if (captions.some((c) => tOut >= c.tStart && tOut <= c.tEnd && rectsOverlap(c.box, projFrame))) continue;
|
|
4672
|
+
const { srcAt } = sampleSource(ctx2.manifest.segments, tOut);
|
|
4673
|
+
if (srcAt === null) continue;
|
|
4674
|
+
const outPng = await extractFrameAt(ctx2.videoPath, tOut).catch(() => null);
|
|
4675
|
+
if (!outPng) continue;
|
|
4676
|
+
const outRegion = await regionGray48(outPng, projFrame);
|
|
4677
|
+
const srcFile = framePath(ctx2.bundle, frameIndexForTime(ctx2.bundle.frames, srcAt));
|
|
4678
|
+
const srcRegion = await regionGray48(srcFile, {
|
|
4679
|
+
x: rect.x * frameScale,
|
|
4680
|
+
y: rect.y * frameScale,
|
|
4681
|
+
w: rect.w * frameScale,
|
|
4682
|
+
h: rect.h * frameScale
|
|
4683
|
+
});
|
|
4684
|
+
if (!outRegion || !srcRegion) continue;
|
|
4685
|
+
if (variance(outRegion) < 4 || variance(srcRegion) < 4) continue;
|
|
4686
|
+
checked += 1;
|
|
4687
|
+
const corr = normalizedCorrelation(outRegion, srcRegion);
|
|
4688
|
+
if (corr < 0.5) {
|
|
4689
|
+
evidence.push({
|
|
4690
|
+
stepRef: step.stepRef,
|
|
4691
|
+
t: tOut,
|
|
4692
|
+
note: `output region does not match the planned source region (correlation ${corr.toFixed(2)})`
|
|
4693
|
+
});
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
if (checked === 0) return { id: "output-pixels", status: "skip", details: "no comparable samples (captions overlapped or frames unavailable)" };
|
|
4697
|
+
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` };
|
|
4698
|
+
}
|
|
4699
|
+
|
|
4700
|
+
// src/verify/contact-sheet.ts
|
|
4701
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
4702
|
+
import { createCanvas as createCanvas4, loadImage } from "@napi-rs/canvas";
|
|
4703
|
+
var TILE_W = 480;
|
|
4704
|
+
var LABEL_H = 42;
|
|
4705
|
+
var GUTTER = 16;
|
|
4706
|
+
var COLS = 3;
|
|
4707
|
+
async function renderContactSheet(manifest, videoPath, outPath) {
|
|
4708
|
+
registerFonts();
|
|
4709
|
+
const tileH = Math.round(TILE_W * manifest.profile.height / manifest.profile.width);
|
|
4710
|
+
const captionsByStep = new Map(
|
|
4711
|
+
manifest.overlays.filter((o) => o.kind === "caption").map((o) => [o.stepRef, o])
|
|
4712
|
+
);
|
|
4713
|
+
const moments = [
|
|
4714
|
+
{ t: manifest.overlays.find((o) => o.kind === "titleCard").tEnd / 2, label: "Title" }
|
|
4715
|
+
];
|
|
4716
|
+
for (const step of manifest.steps) {
|
|
4717
|
+
const caption = captionsByStep.get(step.stepRef);
|
|
4718
|
+
const label = caption ? `${caption.ordinal}. ${caption.text}` : `${step.kind} (${step.stepRef})`;
|
|
4719
|
+
const settled = Math.max(step.outBeat + 150, step.outEnd - 400);
|
|
4720
|
+
moments.push({ t: Math.min(settled, step.outEnd - 50), label });
|
|
4721
|
+
}
|
|
4722
|
+
moments.push({ t: manifest.durationMs - 200, label: "Final state" });
|
|
4723
|
+
const rows = Math.ceil(moments.length / COLS);
|
|
4724
|
+
const width = GUTTER + COLS * (TILE_W + GUTTER);
|
|
4725
|
+
const height = GUTTER + rows * (tileH + LABEL_H + GUTTER);
|
|
4726
|
+
const canvas = createCanvas4(width, height);
|
|
4727
|
+
const ctx2 = canvas.getContext("2d");
|
|
4728
|
+
ctx2.fillStyle = "#14161d";
|
|
4729
|
+
ctx2.fillRect(0, 0, width, height);
|
|
4730
|
+
for (let i = 0; i < moments.length; i++) {
|
|
4731
|
+
const m = moments[i];
|
|
4732
|
+
const col = i % COLS;
|
|
4733
|
+
const row = Math.floor(i / COLS);
|
|
4734
|
+
const x = GUTTER + col * (TILE_W + GUTTER);
|
|
4735
|
+
const y = GUTTER + row * (tileH + LABEL_H + GUTTER);
|
|
4736
|
+
try {
|
|
4737
|
+
const png = await extractFrameAt(videoPath, m.t);
|
|
4738
|
+
const img = await loadImage(png);
|
|
4739
|
+
ctx2.drawImage(img, x, y, TILE_W, tileH);
|
|
4740
|
+
} catch {
|
|
4741
|
+
ctx2.fillStyle = "#3a2030";
|
|
4742
|
+
ctx2.fillRect(x, y, TILE_W, tileH);
|
|
4743
|
+
ctx2.fillStyle = "#ffffff";
|
|
4744
|
+
ctx2.font = "16px Inter Medium";
|
|
4745
|
+
ctx2.textAlign = "center";
|
|
4746
|
+
ctx2.fillText("frame unavailable", x + TILE_W / 2, y + tileH / 2);
|
|
4747
|
+
}
|
|
4748
|
+
ctx2.fillStyle = "#1e222c";
|
|
4749
|
+
roundRect(ctx2, x, y + tileH + 4, TILE_W, LABEL_H - 8, 6);
|
|
4750
|
+
ctx2.fill();
|
|
4751
|
+
ctx2.fillStyle = "rgba(255,255,255,0.92)";
|
|
4752
|
+
ctx2.font = "15px Inter Medium";
|
|
4753
|
+
ctx2.textAlign = "left";
|
|
4754
|
+
ctx2.textBaseline = "middle";
|
|
4755
|
+
ctx2.fillText(truncate2(m.label, 58), x + 12, y + tileH + 4 + (LABEL_H - 8) / 2 + 1);
|
|
4756
|
+
ctx2.fillStyle = "rgba(255,255,255,0.45)";
|
|
4757
|
+
ctx2.font = "13px Inter";
|
|
4758
|
+
ctx2.textAlign = "right";
|
|
4759
|
+
ctx2.fillText(`${(m.t / 1e3).toFixed(1)}s`, x + TILE_W - 12, y + tileH + 4 + (LABEL_H - 8) / 2 + 1);
|
|
4760
|
+
}
|
|
4761
|
+
await writeFile4(outPath, await canvas.encode("png"));
|
|
4762
|
+
return outPath;
|
|
4763
|
+
}
|
|
4764
|
+
function truncate2(s, n) {
|
|
4765
|
+
return s.length <= n ? s : s.slice(0, n - 1) + "\u2026";
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
// src/verify/vision.ts
|
|
4769
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
4770
|
+
var DEFAULT_MODEL = process.env.PLAYHEAD_VISION_MODEL ?? "claude-opus-5";
|
|
4771
|
+
async function visionReview(contactSheetPath, manifest) {
|
|
4772
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
4773
|
+
return {
|
|
4774
|
+
check: { id: "vision-review", status: "skip", details: "ANTHROPIC_API_KEY not set" },
|
|
4775
|
+
review: null
|
|
4776
|
+
};
|
|
4777
|
+
}
|
|
4778
|
+
try {
|
|
4779
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
4780
|
+
const client = new Anthropic();
|
|
4781
|
+
const png = await readFile3(contactSheetPath);
|
|
4782
|
+
const captions = manifest.overlays.filter((o) => o.kind === "caption").map((o) => `${o.ordinal}. ${o.text}`).join("\n");
|
|
4783
|
+
const response = await client.messages.create({
|
|
4784
|
+
model: DEFAULT_MODEL,
|
|
4785
|
+
max_tokens: 1200,
|
|
4786
|
+
messages: [
|
|
4787
|
+
{
|
|
4788
|
+
role: "user",
|
|
4789
|
+
content: [
|
|
4790
|
+
{
|
|
4791
|
+
type: "image",
|
|
4792
|
+
source: { type: "base64", media_type: "image/png", data: png.toString("base64") }
|
|
4793
|
+
},
|
|
4794
|
+
{
|
|
4795
|
+
type: "text",
|
|
4796
|
+
text: [
|
|
4797
|
+
"This is a contact sheet of key moments from an automatically produced product walkthrough video.",
|
|
4798
|
+
"Each tile shows the output frame at one step, labeled with its instruction caption. The steps are:",
|
|
4799
|
+
captions,
|
|
4800
|
+
"",
|
|
4801
|
+
"Review it as a demanding video editor. Report only real problems a viewer would notice:",
|
|
4802
|
+
"illegible or clipped UI, captions that do not match what is on screen, awkward framing,",
|
|
4803
|
+
"visual glitches, or anything unprofessional. Ignore the tile borders and labels themselves.",
|
|
4804
|
+
'Respond with strict JSON: {"findings": [{"severity": "warn", "note": "..."}]} \u2014',
|
|
4805
|
+
"an empty findings array if the video looks publishable."
|
|
4806
|
+
].join("\n")
|
|
4807
|
+
}
|
|
4808
|
+
]
|
|
4809
|
+
}
|
|
4810
|
+
]
|
|
4811
|
+
});
|
|
4812
|
+
const text = response.content.map((b) => b.type === "text" ? b.text : "").join("");
|
|
4813
|
+
const parsed = extractJson(text);
|
|
4814
|
+
const findings = Array.isArray(parsed?.findings) ? parsed.findings.filter((f) => !!f && typeof f === "object").map((f) => ({ severity: String(f.severity ?? "warn"), note: String(f.note ?? "") })) : [];
|
|
4815
|
+
const review = { model: DEFAULT_MODEL, findings };
|
|
4816
|
+
return {
|
|
4817
|
+
check: {
|
|
4818
|
+
id: "vision-review",
|
|
4819
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
4820
|
+
details: findings.length > 0 ? `${findings.length} advisory finding(s) from ${DEFAULT_MODEL}` : `no issues flagged by ${DEFAULT_MODEL}`,
|
|
4821
|
+
...findings.length > 0 ? { evidence: findings.map((f) => ({ note: f.note })) } : {}
|
|
4822
|
+
},
|
|
4823
|
+
review
|
|
4824
|
+
};
|
|
4825
|
+
} catch (e) {
|
|
4826
|
+
log.warn(`vision review failed: ${e.message}`);
|
|
4827
|
+
return {
|
|
4828
|
+
check: { id: "vision-review", status: "skip", details: `vision review errored: ${e.message}` },
|
|
4829
|
+
review: null
|
|
4830
|
+
};
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4833
|
+
function extractJson(text) {
|
|
4834
|
+
const start = text.indexOf("{");
|
|
4835
|
+
const end = text.lastIndexOf("}");
|
|
4836
|
+
if (start === -1 || end <= start) return null;
|
|
4837
|
+
try {
|
|
4838
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
4839
|
+
} catch {
|
|
4840
|
+
return null;
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
// src/verify/report.ts
|
|
4845
|
+
import { createHmac } from "crypto";
|
|
4846
|
+
function signablePayload(v) {
|
|
4847
|
+
return JSON.stringify({
|
|
4848
|
+
schema: v.schema,
|
|
4849
|
+
verdict: v.verdict,
|
|
4850
|
+
videoSha256: v.video.sha256,
|
|
4851
|
+
bundleHashes: { events: v.bundleHashes.events, framesIndex: v.bundleHashes.framesIndex },
|
|
4852
|
+
provenance: {
|
|
4853
|
+
playheadVersion: v.provenance.playheadVersion,
|
|
4854
|
+
specHash: v.provenance.specHash,
|
|
4855
|
+
appUrl: v.provenance.appUrl,
|
|
4856
|
+
capturedAt: v.provenance.capturedAt,
|
|
4857
|
+
verifiedAt: v.provenance.verifiedAt,
|
|
4858
|
+
checkSuiteVersion: v.provenance.checkSuiteVersion,
|
|
4859
|
+
host: v.provenance.host,
|
|
4860
|
+
partial: v.provenance.partial ?? false
|
|
4861
|
+
},
|
|
4862
|
+
checks: v.checks.map((c) => ({ id: c.id, status: c.status }))
|
|
4863
|
+
});
|
|
4864
|
+
}
|
|
4865
|
+
function signVerdict(v, key = process.env.PLAYHEAD_SIGNING_KEY) {
|
|
4866
|
+
if (!key) return null;
|
|
4867
|
+
return createHmac("sha256", key).update(signablePayload(v)).digest("hex");
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
// src/verify/runner.ts
|
|
4871
|
+
import { platform, arch } from "os";
|
|
4872
|
+
import pc2 from "picocolors";
|
|
4873
|
+
var CHECK_SUITE_VERSION = "playhead/checks@2";
|
|
4874
|
+
async function verify(bundle, manifest, videoPath, opts) {
|
|
4875
|
+
const verifyDir = join8(opts.outDir, "verify");
|
|
4876
|
+
await mkdir4(verifyDir, { recursive: true });
|
|
4877
|
+
const ctx2 = { bundle, manifest, videoPath, outDir: verifyDir };
|
|
4878
|
+
log.info("verifying output");
|
|
4879
|
+
const checks = [];
|
|
4880
|
+
const suite = [
|
|
4881
|
+
checkEncode,
|
|
4882
|
+
// fail fast on a broken file before decoding frames from it
|
|
4883
|
+
checkTargetInFrame,
|
|
4884
|
+
checkCursorOnTarget,
|
|
4885
|
+
checkActionEffect,
|
|
4886
|
+
checkOutputPixels,
|
|
4887
|
+
// the delivered MP4, not the plan
|
|
4888
|
+
checkFrozenBlank,
|
|
4889
|
+
checkCaptions,
|
|
4890
|
+
checkPacing,
|
|
4891
|
+
checkMasks,
|
|
4892
|
+
checkAudio
|
|
4893
|
+
];
|
|
4894
|
+
for (const check of suite) {
|
|
4895
|
+
const result = await check(ctx2);
|
|
4896
|
+
checks.push(result);
|
|
4897
|
+
report(result);
|
|
4898
|
+
}
|
|
4899
|
+
const contactSheet = join8(verifyDir, "contact-sheet.png");
|
|
4900
|
+
await renderContactSheet(manifest, videoPath, contactSheet);
|
|
4901
|
+
log.ok(`contact sheet \u2192 ${contactSheet}`);
|
|
4902
|
+
let vision = null;
|
|
4903
|
+
if (opts.vision) {
|
|
4904
|
+
const { check, review } = await visionReview(contactSheet, manifest);
|
|
4905
|
+
checks.push(check);
|
|
4906
|
+
report(check);
|
|
4907
|
+
vision = review;
|
|
4908
|
+
}
|
|
4909
|
+
const failed = checks.some((c) => c.status === "fail");
|
|
4910
|
+
const unsigned = {
|
|
4911
|
+
schema: "playhead/verdict@2",
|
|
4912
|
+
verdict: failed ? "not-publishable" : "publishable",
|
|
4913
|
+
video: { path: videoPath, sha256: manifest.videoSha256 ?? await sha256File(videoPath) },
|
|
4914
|
+
bundleHashes: manifest.bundleHashes,
|
|
4915
|
+
provenance: {
|
|
4916
|
+
playheadVersion: PLAYHEAD_VERSION,
|
|
4917
|
+
specHash: bundle.manifest.specHash,
|
|
4918
|
+
appUrl: bundle.manifest.appUrl,
|
|
4919
|
+
capturedAt: bundle.manifest.createdAtIso,
|
|
4920
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4921
|
+
checkSuiteVersion: CHECK_SUITE_VERSION,
|
|
4922
|
+
host: `${platform()}-${arch()}`,
|
|
4923
|
+
...bundle.manifest.partial ? { partial: true } : {},
|
|
4924
|
+
...bundle.manifest.failure ? { failure: bundle.manifest.failure } : {}
|
|
4925
|
+
},
|
|
4926
|
+
checks,
|
|
4927
|
+
contactSheet,
|
|
4928
|
+
vision
|
|
4929
|
+
};
|
|
4930
|
+
const verdict = { ...unsigned, signature: signVerdict(unsigned) };
|
|
4931
|
+
if (!verdict.signature) log.debug("verdict unsigned \u2014 set PLAYHEAD_SIGNING_KEY to sign");
|
|
4932
|
+
await writeFile5(join8(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
|
|
4933
|
+
if (failed) log.error(`verdict: NOT PUBLISHABLE`);
|
|
4934
|
+
else log.ok(`verdict: publishable`);
|
|
4935
|
+
return verdict;
|
|
4936
|
+
}
|
|
4937
|
+
function report(c) {
|
|
4938
|
+
const label = c.status === "pass" ? pc2.green("pass") : c.status === "fail" ? pc2.red("FAIL") : c.status === "warn" ? pc2.yellow("warn") : pc2.dim("skip");
|
|
4939
|
+
console.error(` ${label} ${c.id}: ${c.details}`);
|
|
4940
|
+
for (const e of c.evidence ?? []) {
|
|
4941
|
+
console.error(pc2.dim(` ${e.stepRef ?? ""}${e.t !== void 0 ? ` @${(e.t / 1e3).toFixed(1)}s` : ""} \u2014 ${e.note}`));
|
|
4942
|
+
}
|
|
4943
|
+
}
|
|
4944
|
+
export {
|
|
4945
|
+
PROFILE_16x9,
|
|
4946
|
+
SpecError,
|
|
4947
|
+
capture,
|
|
4948
|
+
compose,
|
|
4949
|
+
flattenSteps,
|
|
4950
|
+
loadSpec,
|
|
4951
|
+
locatorDisplayName,
|
|
4952
|
+
openBundle,
|
|
4953
|
+
parseLocator,
|
|
4954
|
+
parseSpec,
|
|
4955
|
+
plan,
|
|
4956
|
+
registerFonts,
|
|
4957
|
+
renderFromManifest,
|
|
4958
|
+
resolveTheme,
|
|
4959
|
+
specSchema,
|
|
4960
|
+
verify,
|
|
4961
|
+
writeCaption
|
|
4962
|
+
};
|
|
4963
|
+
//# sourceMappingURL=index.js.map
|