playhead-cli 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +9 -6
- package/README.md +43 -7
- package/action.yml +56 -2
- package/dist/audio/mux.d.ts +2 -0
- package/dist/audio/mux.d.ts.map +1 -1
- package/dist/authoring/author.d.ts +8 -2
- package/dist/authoring/author.d.ts.map +1 -1
- package/dist/authoring/catalog.d.ts.map +1 -1
- package/dist/authoring/explore.d.ts.map +1 -1
- package/dist/authoring/validate.d.ts.map +1 -1
- package/dist/bundle/types.d.ts +2 -0
- package/dist/bundle/types.d.ts.map +1 -1
- package/dist/bundle/writer.d.ts +6 -1
- package/dist/bundle/writer.d.ts.map +1 -1
- package/dist/capture/driver-api.d.ts +14 -1
- package/dist/capture/driver-api.d.ts.map +1 -1
- package/dist/capture/events.d.ts +1 -1
- package/dist/capture/events.d.ts.map +1 -1
- package/dist/capture/executor.d.ts.map +1 -1
- package/dist/capture/playwright-driver.d.ts +22 -2
- package/dist/capture/playwright-driver.d.ts.map +1 -1
- package/dist/cli/index.js +1969 -514
- package/dist/cli/index.js.map +1 -1
- package/dist/compose/camera/interpolate.d.ts +16 -3
- package/dist/compose/camera/interpolate.d.ts.map +1 -1
- package/dist/compose/camera/planner.d.ts.map +1 -1
- package/dist/compose/captions.d.ts.map +1 -1
- package/dist/compose/index.d.ts.map +1 -1
- package/dist/compose/overlays/build.d.ts.map +1 -1
- package/dist/compose/overlays/draw.d.ts.map +1 -1
- package/dist/compose/render/encoder.d.ts.map +1 -1
- package/dist/compose/render/frameStore.d.ts.map +1 -1
- package/dist/compose/render/renderer.d.ts.map +1 -1
- package/dist/compose/timeline/pacing.d.ts +4 -0
- package/dist/compose/timeline/pacing.d.ts.map +1 -1
- package/dist/compose/types.d.ts +14 -3
- package/dist/compose/types.d.ts.map +1 -1
- package/dist/index.js +1299 -382
- package/dist/index.js.map +1 -1
- package/dist/mcp/bin.js +1833 -726
- package/dist/mcp/bin.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/shared/version.d.ts +0 -2
- package/dist/shared/version.d.ts.map +1 -1
- package/dist/spec/locators.d.ts.map +1 -1
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/schema.d.ts +263 -2
- package/dist/spec/schema.d.ts.map +1 -1
- package/dist/verify/checks.d.ts.map +1 -1
- package/dist/verify/report.d.ts +33 -6
- package/dist/verify/report.d.ts.map +1 -1
- package/dist/verify/runner.d.ts +1 -1
- package/dist/verify/runner.d.ts.map +1 -1
- package/dist/verify/types.d.ts +36 -4
- package/dist/verify/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/bin.js
CHANGED
|
@@ -76,8 +76,48 @@ var init_log = __esm({
|
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
// src/shared/exit.ts
|
|
80
|
+
var exit_exports = {};
|
|
81
|
+
__export(exit_exports, {
|
|
82
|
+
EXIT: () => EXIT,
|
|
83
|
+
FlowError: () => FlowError,
|
|
84
|
+
InfraError: () => InfraError,
|
|
85
|
+
exitCodeFor: () => exitCodeFor
|
|
86
|
+
});
|
|
87
|
+
function exitCodeFor(e) {
|
|
88
|
+
if (e && typeof e === "object") {
|
|
89
|
+
if ("exitCode" in e && typeof e.exitCode === "number") {
|
|
90
|
+
return e.exitCode;
|
|
91
|
+
}
|
|
92
|
+
if (e.name === "SpecError" || e instanceof Object && e.constructor?.name === "SpecError") {
|
|
93
|
+
return EXIT.USAGE;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return EXIT.INFRA;
|
|
97
|
+
}
|
|
98
|
+
var EXIT, FlowError, InfraError;
|
|
99
|
+
var init_exit = __esm({
|
|
100
|
+
"src/shared/exit.ts"() {
|
|
101
|
+
"use strict";
|
|
102
|
+
EXIT = {
|
|
103
|
+
OK: 0,
|
|
104
|
+
FLOW: 1,
|
|
105
|
+
QUALITY: 2,
|
|
106
|
+
INFRA: 3,
|
|
107
|
+
USAGE: 4
|
|
108
|
+
};
|
|
109
|
+
FlowError = class extends Error {
|
|
110
|
+
exitCode = EXIT.FLOW;
|
|
111
|
+
};
|
|
112
|
+
InfraError = class extends Error {
|
|
113
|
+
exitCode = EXIT.INFRA;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
79
118
|
// src/capture/playwright-driver.ts
|
|
80
119
|
import { chromium } from "playwright";
|
|
120
|
+
import { access } from "fs/promises";
|
|
81
121
|
function toPw(m) {
|
|
82
122
|
if ("exact" in m) return m.exact;
|
|
83
123
|
if ("substring" in m) return m.substring;
|
|
@@ -114,12 +154,31 @@ function withTimeout(p, ms) {
|
|
|
114
154
|
);
|
|
115
155
|
});
|
|
116
156
|
}
|
|
157
|
+
function imageWidth(data, format) {
|
|
158
|
+
try {
|
|
159
|
+
if (format === "png") {
|
|
160
|
+
return data.length >= 24 ? data.readUInt32BE(16) : null;
|
|
161
|
+
}
|
|
162
|
+
let i = 2;
|
|
163
|
+
while (i + 9 < data.length) {
|
|
164
|
+
if (data[i] !== 255) return null;
|
|
165
|
+
const marker = data[i + 1];
|
|
166
|
+
if (marker >= 192 && marker <= 195) return data.readUInt16BE(i + 7);
|
|
167
|
+
const len = data.readUInt16BE(i + 2);
|
|
168
|
+
i += 2 + len;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
117
175
|
var INPUT_TIMEOUT_MS, MASK_INIT_SCRIPT, FALLBACK_NAME_FN, COLLECT_INTERACTABLES_FN, PlaywrightDriver;
|
|
118
176
|
var init_playwright_driver = __esm({
|
|
119
177
|
"src/capture/playwright-driver.ts"() {
|
|
120
178
|
"use strict";
|
|
121
179
|
init_easing();
|
|
122
180
|
init_log();
|
|
181
|
+
init_exit();
|
|
123
182
|
INPUT_TIMEOUT_MS = 5e3;
|
|
124
183
|
MASK_INIT_SCRIPT = `
|
|
125
184
|
(() => {
|
|
@@ -162,11 +221,24 @@ var init_playwright_driver = __esm({
|
|
|
162
221
|
// is tagged the frame it appears \u2014 not seconds later at the next step boundary. Node-side
|
|
163
222
|
// tagging remains the backstop for rules needing Playwright semantics (role/label/text).
|
|
164
223
|
let scanTick = 0;
|
|
224
|
+
// querySelectorAll does NOT pierce shadow roots \u2014 but Playwright's tagging does, so a masked
|
|
225
|
+
// element inside a web component (any design system) would be tagged yet never overlaid,
|
|
226
|
+
// filming the secret while masks.json claims coverage (round-2 audit). Walk shadow roots too.
|
|
227
|
+
const deepQuery = (sel) => {
|
|
228
|
+
const out = [];
|
|
229
|
+
const walk = (root) => {
|
|
230
|
+
try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch (e) {}
|
|
231
|
+
const all = root.querySelectorAll('*');
|
|
232
|
+
for (const el of all) if (el.shadowRoot) walk(el.shadowRoot);
|
|
233
|
+
};
|
|
234
|
+
walk(document);
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
165
237
|
const scanRules = () => {
|
|
166
238
|
const rules = window.__playheadMaskCssRules || [];
|
|
167
239
|
for (const r of rules) {
|
|
168
240
|
try {
|
|
169
|
-
for (const el of
|
|
241
|
+
for (const el of deepQuery(r.css)) {
|
|
170
242
|
if (!el.hasAttribute('data-playhead-mask')) el.setAttribute('data-playhead-mask', r.style);
|
|
171
243
|
}
|
|
172
244
|
} catch (e) {}
|
|
@@ -175,7 +247,7 @@ var init_playwright_driver = __esm({
|
|
|
175
247
|
const tick = () => {
|
|
176
248
|
try {
|
|
177
249
|
if (scanTick++ % 3 === 0) scanRules(); // every ~3 frames \u2014 cheap, and a 1-frame leak beats a 1-step leak
|
|
178
|
-
const tagged = new Set(
|
|
250
|
+
const tagged = new Set(deepQuery('[data-playhead-mask]'));
|
|
179
251
|
for (const [el, box] of boxes) {
|
|
180
252
|
if (!tagged.has(el) || !el.isConnected) { box.remove(); boxes.delete(el); }
|
|
181
253
|
}
|
|
@@ -325,9 +397,23 @@ var init_playwright_driver = __esm({
|
|
|
325
397
|
tagCounter = 0;
|
|
326
398
|
typingMaskLoc = null;
|
|
327
399
|
async launch(opts) {
|
|
400
|
+
try {
|
|
401
|
+
await access(chromium.executablePath());
|
|
402
|
+
} catch {
|
|
403
|
+
throw new InfraError(
|
|
404
|
+
"Chromium is not installed (one-time setup) \u2014 run: npx playwright install chromium"
|
|
405
|
+
);
|
|
406
|
+
}
|
|
328
407
|
const env = opts.environment;
|
|
329
|
-
this.
|
|
408
|
+
this.forcedDsf = opts.dpr > 1 && process.env.PLAYHEAD_CAPTURE !== "screenshot" ? opts.dpr : 1;
|
|
409
|
+
this.browser = await chromium.launch({
|
|
410
|
+
headless: opts.headless ?? true,
|
|
411
|
+
...this.forcedDsf > 1 ? { args: [`--force-device-scale-factor=${this.forcedDsf}`] } : {}
|
|
412
|
+
});
|
|
413
|
+
const chromeMajor = this.browser.version().split(".")[0] ?? this.browser.version();
|
|
414
|
+
const uaPlatform = process.platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
|
|
330
415
|
this.context = await this.browser.newContext({
|
|
416
|
+
userAgent: `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`,
|
|
331
417
|
viewport: { width: opts.viewport.w, height: opts.viewport.h },
|
|
332
418
|
deviceScaleFactor: opts.dpr,
|
|
333
419
|
colorScheme: env?.colorScheme ?? "light",
|
|
@@ -366,7 +452,10 @@ var init_playwright_driver = __esm({
|
|
|
366
452
|
});
|
|
367
453
|
this.page.on("console", (msg) => this.pushConsole(msg.type(), msg.text()));
|
|
368
454
|
this.page.on("pageerror", (err) => this.pushConsole("pageerror", err.message));
|
|
369
|
-
this.page.on("crash", () =>
|
|
455
|
+
this.page.on("crash", () => {
|
|
456
|
+
this.pushConsole("crash", "page crashed");
|
|
457
|
+
this.crashed = true;
|
|
458
|
+
});
|
|
370
459
|
const dialogPolicy = opts.dialogs ?? "accept";
|
|
371
460
|
this.page.on("dialog", (dialog) => {
|
|
372
461
|
this.pushConsole("dialog", `${dialog.type()}("${dialog.message()}") \u2192 ${dialogPolicy}`);
|
|
@@ -383,6 +472,12 @@ var init_playwright_driver = __esm({
|
|
|
383
472
|
await this.page.mouse.move(this.cursor.x, this.cursor.y);
|
|
384
473
|
}
|
|
385
474
|
consoleBuf = [];
|
|
475
|
+
/** Browser-level forced device scale (--force-device-scale-factor); 1 = not forced. */
|
|
476
|
+
forcedDsf = 1;
|
|
477
|
+
crashed = false;
|
|
478
|
+
assertAlive() {
|
|
479
|
+
if (this.crashed) throw new Error("page crashed \u2014 the browser renderer died (see console.json)");
|
|
480
|
+
}
|
|
386
481
|
pushConsole(type, text) {
|
|
387
482
|
this.consoleBuf.push({ t: Date.now(), type, text: text.slice(0, 500) });
|
|
388
483
|
if (this.consoleBuf.length > 500) this.consoleBuf.shift();
|
|
@@ -500,6 +595,7 @@ var init_playwright_driver = __esm({
|
|
|
500
595
|
return l;
|
|
501
596
|
}
|
|
502
597
|
async resolveTarget(loc, timeoutMs) {
|
|
598
|
+
this.assertAlive();
|
|
503
599
|
const locator = this.toLocator(loc);
|
|
504
600
|
try {
|
|
505
601
|
await locator.waitFor({ state: "visible", timeout: timeoutMs });
|
|
@@ -563,6 +659,8 @@ var init_playwright_driver = __esm({
|
|
|
563
659
|
if (loc.frames && loc.frames.length > 0) {
|
|
564
660
|
const box = await withTimeout(locator.boundingBox(), 3e3).catch(() => null);
|
|
565
661
|
if (box) geom.bbox = { x: box.x, y: box.y, w: box.width, h: box.height };
|
|
662
|
+
const main = await withTimeout(this.page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })), 2e3).catch(() => null);
|
|
663
|
+
if (main) geom.scroll = main;
|
|
566
664
|
}
|
|
567
665
|
let role = null;
|
|
568
666
|
let name = null;
|
|
@@ -654,12 +752,69 @@ var init_playwright_driver = __esm({
|
|
|
654
752
|
* through the checks a raw coordinate click bypasses (a toast/sticky header drifting under
|
|
655
753
|
* the point between measure and click silently redirects a coordinate click).
|
|
656
754
|
*/
|
|
755
|
+
async setInputFiles(loc, filePath) {
|
|
756
|
+
await this.toLocator(loc).setInputFiles(filePath, { timeout: INPUT_TIMEOUT_MS * 2 });
|
|
757
|
+
}
|
|
758
|
+
/** Real drag: hover the source, press, glide to the destination in eased steps (recording
|
|
759
|
+
* pressed-cursor waypoints for the film), release over the target's current position. */
|
|
760
|
+
async dragTo(from, to, timeoutMs) {
|
|
761
|
+
const src = this.toLocator(from);
|
|
762
|
+
const dst = this.toLocator(to);
|
|
763
|
+
await src.hover({ timeout: timeoutMs });
|
|
764
|
+
const a = await src.boundingBox();
|
|
765
|
+
if (!a) throw new Error(`drag source vanished: ${from.raw}`);
|
|
766
|
+
const start = { x: a.x + a.width / 2, y: a.y + a.height / 2 };
|
|
767
|
+
const tDown = Date.now();
|
|
768
|
+
await withTimeout(this.page.mouse.down(), INPUT_TIMEOUT_MS);
|
|
769
|
+
const path = [{ x: start.x, y: start.y, t: tDown }];
|
|
770
|
+
const b = await dst.boundingBox();
|
|
771
|
+
if (!b) {
|
|
772
|
+
await this.page.mouse.up().catch(() => {
|
|
773
|
+
});
|
|
774
|
+
throw new Error(`drag destination not found: ${to.raw}`);
|
|
775
|
+
}
|
|
776
|
+
const end = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
777
|
+
const STEPS = 24;
|
|
778
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
779
|
+
const u = minJerk(i / STEPS);
|
|
780
|
+
const p = { x: start.x + (end.x - start.x) * u, y: start.y + (end.y - start.y) * u };
|
|
781
|
+
await withTimeout(this.page.mouse.move(p.x, p.y), INPUT_TIMEOUT_MS);
|
|
782
|
+
path.push({ x: p.x, y: p.y, t: Date.now() });
|
|
783
|
+
await sleep(18);
|
|
784
|
+
}
|
|
785
|
+
const tUp = Date.now();
|
|
786
|
+
await withTimeout(this.page.mouse.up(), INPUT_TIMEOUT_MS);
|
|
787
|
+
this.cursor = end;
|
|
788
|
+
return { tDown, tUp, path };
|
|
789
|
+
}
|
|
657
790
|
async actClick(loc, opts) {
|
|
658
791
|
const locator = this.toLocator(loc);
|
|
659
|
-
|
|
792
|
+
await locator.hover({ timeout: opts?.timeoutMs ?? 1e4 }).catch(() => {
|
|
793
|
+
});
|
|
794
|
+
await withTimeout(
|
|
795
|
+
this.page.evaluate(() => new Promise(requestAnimationFrame).then(() => new Promise(requestAnimationFrame))),
|
|
796
|
+
1200
|
|
797
|
+
).catch(() => {
|
|
798
|
+
});
|
|
799
|
+
await this.captureNow();
|
|
800
|
+
const before = Date.now();
|
|
801
|
+
await locator.evaluate((el) => {
|
|
802
|
+
const w = el.ownerDocument.defaultView;
|
|
803
|
+
if (w) {
|
|
804
|
+
w.__phTDown = null;
|
|
805
|
+
el.addEventListener("pointerdown", () => w.__phTDown = Date.now(), { once: true, capture: true });
|
|
806
|
+
}
|
|
807
|
+
}).catch(() => {
|
|
808
|
+
});
|
|
660
809
|
if (opts?.double) await locator.dblclick({ delay: 60, timeout: opts?.timeoutMs ?? 1e4 });
|
|
661
|
-
else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4 });
|
|
662
|
-
|
|
810
|
+
else await locator.click({ delay: 70, timeout: opts?.timeoutMs ?? 1e4, ...opts?.button ? { button: opts.button } : {} });
|
|
811
|
+
const tUp = Date.now();
|
|
812
|
+
const browserTDown = await withTimeout(
|
|
813
|
+
locator.evaluate((el) => el.ownerDocument.defaultView?.__phTDown ?? null),
|
|
814
|
+
1500
|
|
815
|
+
).catch(() => null);
|
|
816
|
+
const tDown = typeof browserTDown === "number" ? browserTDown - this.clockOffset : Math.max(before, tUp - 90);
|
|
817
|
+
return { tDown, tUp };
|
|
663
818
|
}
|
|
664
819
|
async selectOption(loc, value) {
|
|
665
820
|
const locator = this.toLocator(loc);
|
|
@@ -668,8 +823,6 @@ var init_playwright_driver = __esm({
|
|
|
668
823
|
} catch {
|
|
669
824
|
await locator.selectOption(value);
|
|
670
825
|
}
|
|
671
|
-
await withTimeout(this.page.keyboard.press("Escape"), INPUT_TIMEOUT_MS).catch(() => {
|
|
672
|
-
});
|
|
673
826
|
await withTimeout(
|
|
674
827
|
locator.evaluate((el) => el.blur?.()),
|
|
675
828
|
2e3
|
|
@@ -705,6 +858,22 @@ var init_playwright_driver = __esm({
|
|
|
705
858
|
}
|
|
706
859
|
}
|
|
707
860
|
async expectState(loc, opts, timeoutMs) {
|
|
861
|
+
if (opts.url !== void 0) {
|
|
862
|
+
const want = opts.url;
|
|
863
|
+
const matches = (u) => {
|
|
864
|
+
const m = /^\/(.+)\/([a-z]*)$/.exec(want);
|
|
865
|
+
return m ? new RegExp(m[1], m[2]).test(u) : u.includes(want);
|
|
866
|
+
};
|
|
867
|
+
const deadlineUrl = Date.now() + timeoutMs;
|
|
868
|
+
while (!matches(this.page.url())) {
|
|
869
|
+
if (Date.now() > deadlineUrl) {
|
|
870
|
+
throw new Error(`Expectation not met for URL: expected ${want}, got ${this.page.url()}`);
|
|
871
|
+
}
|
|
872
|
+
await sleep(100);
|
|
873
|
+
}
|
|
874
|
+
if (!loc) return;
|
|
875
|
+
}
|
|
876
|
+
if (!loc) return;
|
|
708
877
|
const locator = this.toLocator(loc);
|
|
709
878
|
const deadline = Date.now() + timeoutMs;
|
|
710
879
|
let lastErr = "condition not met";
|
|
@@ -720,6 +889,18 @@ var init_playwright_driver = __esm({
|
|
|
720
889
|
const visible = await locator.first().isVisible();
|
|
721
890
|
if (opts.visible !== void 0 && visible !== opts.visible) {
|
|
722
891
|
lastErr = `expected visible=${opts.visible}, got ${visible}`;
|
|
892
|
+
} else if (opts.value !== void 0) {
|
|
893
|
+
const v = await locator.inputValue({ timeout: 1e3 }).catch(() => null);
|
|
894
|
+
if (v === opts.value) return;
|
|
895
|
+
lastErr = `expected value ${JSON.stringify(opts.value)}, got ${JSON.stringify(v)}`;
|
|
896
|
+
} else if (opts.disabled !== void 0) {
|
|
897
|
+
const d = await locator.isDisabled({ timeout: 1e3 }).catch(() => null);
|
|
898
|
+
if (d === opts.disabled) return;
|
|
899
|
+
lastErr = `expected disabled=${opts.disabled}, got ${d}`;
|
|
900
|
+
} else if (opts.checked !== void 0) {
|
|
901
|
+
const c = await locator.isChecked({ timeout: 1e3 }).catch(() => null);
|
|
902
|
+
if (c === opts.checked) return;
|
|
903
|
+
lastErr = `expected checked=${opts.checked}, got ${c}`;
|
|
723
904
|
} else if (opts.text !== void 0) {
|
|
724
905
|
const content = visible ? await locator.first().innerText() : "";
|
|
725
906
|
if (!content.includes(opts.text)) {
|
|
@@ -761,6 +942,7 @@ var init_playwright_driver = __esm({
|
|
|
761
942
|
}
|
|
762
943
|
static LONG_REQUEST_MS = 2e3;
|
|
763
944
|
async settle(opts) {
|
|
945
|
+
this.assertAlive();
|
|
764
946
|
this.installNetTracking();
|
|
765
947
|
const start = Date.now();
|
|
766
948
|
let capped = true;
|
|
@@ -803,7 +985,20 @@ var init_playwright_driver = __esm({
|
|
|
803
985
|
})
|
|
804
986
|
),
|
|
805
987
|
1800
|
|
806
|
-
).catch(() => {
|
|
988
|
+
).catch(async (e) => {
|
|
989
|
+
if (/context.*destroyed|navigat/i.test(e.message ?? "")) {
|
|
990
|
+
await this.page.waitForLoadState("load", { timeout: 5e3 }).catch(() => {
|
|
991
|
+
});
|
|
992
|
+
await withTimeout(
|
|
993
|
+
this.page.evaluate(
|
|
994
|
+
() => new Promise((resolve2) => {
|
|
995
|
+
setTimeout(resolve2, 400);
|
|
996
|
+
})
|
|
997
|
+
),
|
|
998
|
+
1e3
|
|
999
|
+
).catch(() => {
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
807
1002
|
});
|
|
808
1003
|
await this.nextFrame();
|
|
809
1004
|
}
|
|
@@ -860,10 +1055,56 @@ var init_playwright_driver = __esm({
|
|
|
860
1055
|
// Headless Chromium's CDP screencast is hard-locked to CSS-pixel resolution and ignores
|
|
861
1056
|
// deviceScaleFactor, so a paced Page.captureScreenshot loop with clip.scale is what actually
|
|
862
1057
|
// yields 2x frames (the zoom headroom the camera planner needs).
|
|
1058
|
+
screencastActive = false;
|
|
863
1059
|
async startCapture(onFrame, opts) {
|
|
864
1060
|
this.captureOnFrame = onFrame;
|
|
865
1061
|
this.captureOpts = opts;
|
|
866
1062
|
this.captureActive = true;
|
|
1063
|
+
if (process.env.PLAYHEAD_CAPTURE !== "screenshot") {
|
|
1064
|
+
try {
|
|
1065
|
+
const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
|
|
1066
|
+
const expectedW = Math.round(vp.width * opts.scale);
|
|
1067
|
+
let sizeChecked = false;
|
|
1068
|
+
this.captureCdp.on("Page.screencastFrame", (ev) => {
|
|
1069
|
+
void this.captureCdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {
|
|
1070
|
+
});
|
|
1071
|
+
if (!this.captureActive || !this.captureOnFrame || !this.screencastActive) return;
|
|
1072
|
+
const data = Buffer.from(ev.data, "base64");
|
|
1073
|
+
if (!sizeChecked) {
|
|
1074
|
+
sizeChecked = true;
|
|
1075
|
+
const w = imageWidth(data, opts.format);
|
|
1076
|
+
if (w !== null && w < expectedW * 0.9) {
|
|
1077
|
+
log.warn(
|
|
1078
|
+
`screencast emits ${w}px-wide frames (need ${expectedW} for zoom headroom) \u2014 reverting to the 2x screenshot loop`
|
|
1079
|
+
);
|
|
1080
|
+
this.screencastActive = false;
|
|
1081
|
+
void withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
|
|
1082
|
+
});
|
|
1083
|
+
this.startScreenshotLoop(opts);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const tNodeMs = ev.metadata?.timestamp ? ev.metadata.timestamp * 1e3 - this.clockOffset : Date.now();
|
|
1088
|
+
this.lastFrameNodeMs = tNodeMs;
|
|
1089
|
+
this.captureOnFrame({ data, tNodeMs });
|
|
1090
|
+
});
|
|
1091
|
+
await this.captureCdp.send("Page.startScreencast", {
|
|
1092
|
+
format: opts.format,
|
|
1093
|
+
quality: opts.quality,
|
|
1094
|
+
maxWidth: expectedW,
|
|
1095
|
+
maxHeight: Math.round(vp.height * opts.scale),
|
|
1096
|
+
// Compositor paints at up to ~60; halve toward the requested rate.
|
|
1097
|
+
everyNthFrame: Math.max(1, Math.round(60 / Math.max(15, opts.fps * 1.25)))
|
|
1098
|
+
});
|
|
1099
|
+
this.screencastActive = true;
|
|
1100
|
+
return;
|
|
1101
|
+
} catch (e) {
|
|
1102
|
+
log.warn(`screencast unavailable (${e.message.split("\n")[0]}) \u2014 falling back to the paced screenshot loop`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
this.startScreenshotLoop(opts);
|
|
1106
|
+
}
|
|
1107
|
+
startScreenshotLoop(opts) {
|
|
867
1108
|
const intervalMs = 1e3 / opts.fps;
|
|
868
1109
|
this.captureLoop = (async () => {
|
|
869
1110
|
while (this.captureActive) {
|
|
@@ -874,7 +1115,8 @@ var init_playwright_driver = __esm({
|
|
|
874
1115
|
}
|
|
875
1116
|
})();
|
|
876
1117
|
}
|
|
877
|
-
/** Grab a frame, coalescing on any in-flight grab (so callers can await the current one).
|
|
1118
|
+
/** Grab a frame, coalescing on any in-flight grab (so callers can await the current one).
|
|
1119
|
+
* Resolves true iff a frame was actually stored. */
|
|
878
1120
|
grabFrame() {
|
|
879
1121
|
if (this.inflightGrab) return this.inflightGrab;
|
|
880
1122
|
this.inflightGrab = this.doGrab().finally(() => {
|
|
@@ -884,7 +1126,7 @@ var init_playwright_driver = __esm({
|
|
|
884
1126
|
}
|
|
885
1127
|
grabFailures = 0;
|
|
886
1128
|
async doGrab() {
|
|
887
|
-
if (!this.captureOpts || !this.captureOnFrame) return;
|
|
1129
|
+
if (!this.captureOpts || !this.captureOnFrame) return false;
|
|
888
1130
|
try {
|
|
889
1131
|
const opts = this.captureOpts;
|
|
890
1132
|
const vp = this.page.viewportSize() ?? { width: 1280, height: 720 };
|
|
@@ -893,7 +1135,9 @@ var init_playwright_driver = __esm({
|
|
|
893
1135
|
this.captureCdp.send("Page.captureScreenshot", {
|
|
894
1136
|
format: opts.format,
|
|
895
1137
|
quality: opts.quality,
|
|
896
|
-
|
|
1138
|
+
// Under --force-device-scale-factor the surface is already scaled — divide it out
|
|
1139
|
+
// or screenshots come back double-scaled (5120-wide).
|
|
1140
|
+
clip: { x: scroll.x, y: scroll.y, width: vp.width, height: vp.height, scale: opts.scale / this.forcedDsf },
|
|
897
1141
|
captureBeyondViewport: false
|
|
898
1142
|
}),
|
|
899
1143
|
1500
|
|
@@ -902,7 +1146,9 @@ var init_playwright_driver = __esm({
|
|
|
902
1146
|
this.lastFrameNodeMs = tNodeMs;
|
|
903
1147
|
this.grabFailures = 0;
|
|
904
1148
|
this.captureOnFrame({ data: Buffer.from(shot.data, "base64"), tNodeMs });
|
|
905
|
-
|
|
1149
|
+
return true;
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
log.debug(`frame grab failed: ${err.message.split("\n")[0]}`);
|
|
906
1152
|
this.grabFailures += 1;
|
|
907
1153
|
if (this.grabFailures >= 2) {
|
|
908
1154
|
try {
|
|
@@ -914,6 +1160,7 @@ var init_playwright_driver = __esm({
|
|
|
914
1160
|
} catch {
|
|
915
1161
|
}
|
|
916
1162
|
}
|
|
1163
|
+
return false;
|
|
917
1164
|
}
|
|
918
1165
|
}
|
|
919
1166
|
async readScroll() {
|
|
@@ -927,6 +1174,11 @@ var init_playwright_driver = __esm({
|
|
|
927
1174
|
}
|
|
928
1175
|
async stopCapture() {
|
|
929
1176
|
this.captureActive = false;
|
|
1177
|
+
if (this.screencastActive) {
|
|
1178
|
+
this.screencastActive = false;
|
|
1179
|
+
await withTimeout(this.captureCdp.send("Page.stopScreencast"), 2e3).catch(() => {
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
930
1182
|
await this.captureLoop?.catch(() => {
|
|
931
1183
|
});
|
|
932
1184
|
this.captureLoop = null;
|
|
@@ -935,19 +1187,45 @@ var init_playwright_driver = __esm({
|
|
|
935
1187
|
return this.lastFrameNodeMs;
|
|
936
1188
|
}
|
|
937
1189
|
async captureNow() {
|
|
938
|
-
if (this.inflightGrab) await this.inflightGrab.catch(() =>
|
|
939
|
-
|
|
940
|
-
|
|
1190
|
+
if (this.inflightGrab) await this.inflightGrab.catch(() => false);
|
|
1191
|
+
for (let i = 0; i < 3; i++) {
|
|
1192
|
+
if (await this.grabFrame()) return;
|
|
1193
|
+
await sleep(120);
|
|
1194
|
+
}
|
|
1195
|
+
log.warn("captureNow: no frame stored after 3 attempts \u2014 footage may hold a stale state here");
|
|
941
1196
|
}
|
|
942
1197
|
};
|
|
943
1198
|
}
|
|
944
1199
|
});
|
|
945
1200
|
|
|
946
1201
|
// src/spec/locators.ts
|
|
1202
|
+
function splitSegments(raw) {
|
|
1203
|
+
const parts = [];
|
|
1204
|
+
let cur = "";
|
|
1205
|
+
let quote2 = null;
|
|
1206
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1207
|
+
const ch = raw[i];
|
|
1208
|
+
if (quote2) {
|
|
1209
|
+
cur += ch;
|
|
1210
|
+
if (ch === quote2) quote2 = null;
|
|
1211
|
+
} else if (ch === '"' || ch === "'") {
|
|
1212
|
+
quote2 = ch;
|
|
1213
|
+
cur += ch;
|
|
1214
|
+
} else if (ch === ">" && raw[i + 1] === ">") {
|
|
1215
|
+
parts.push(cur.trim());
|
|
1216
|
+
cur = "";
|
|
1217
|
+
i += 1;
|
|
1218
|
+
} else {
|
|
1219
|
+
cur += ch;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
parts.push(cur.trim());
|
|
1223
|
+
return parts;
|
|
1224
|
+
}
|
|
947
1225
|
function parseLocator(input) {
|
|
948
1226
|
const raw = input.trim();
|
|
949
1227
|
let nth;
|
|
950
|
-
const parts = raw
|
|
1228
|
+
const parts = splitSegments(raw);
|
|
951
1229
|
const frames = [];
|
|
952
1230
|
while (parts.length > 0 && parts[0].startsWith("frame=")) {
|
|
953
1231
|
const sel = unquote(parts.shift().slice("frame=".length).trim());
|
|
@@ -1088,7 +1366,7 @@ function kindOf(el) {
|
|
|
1088
1366
|
function quote(s) {
|
|
1089
1367
|
const clean = s.replace(/\s+/g, " ").trim();
|
|
1090
1368
|
if (clean.includes('"')) {
|
|
1091
|
-
return `/${escapeRegex(clean).replace(/\//g, "\\/")}/`;
|
|
1369
|
+
return `/${escapeRegex(clean).replace(/\//g, "\\/").replace(/>/g, "\\x3e")}/`;
|
|
1092
1370
|
}
|
|
1093
1371
|
return `"${clean}"`;
|
|
1094
1372
|
}
|
|
@@ -1121,7 +1399,12 @@ async function snapshotPage(driver) {
|
|
|
1121
1399
|
let unique = false;
|
|
1122
1400
|
let ambiguous = null;
|
|
1123
1401
|
for (const cand of candidates) {
|
|
1124
|
-
|
|
1402
|
+
let count = 0;
|
|
1403
|
+
try {
|
|
1404
|
+
count = await driver.countMatches(parseLocator(cand)).catch(() => 0);
|
|
1405
|
+
} catch {
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1125
1408
|
if (count === 1) {
|
|
1126
1409
|
chosen = cand;
|
|
1127
1410
|
unique = true;
|
|
@@ -1327,7 +1610,7 @@ function flattenSteps(spec) {
|
|
|
1327
1610
|
});
|
|
1328
1611
|
return out;
|
|
1329
1612
|
}
|
|
1330
|
-
var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
|
|
1613
|
+
var dimensions, locatorString, focusHint, EXTRA_KEYS, stepExtras, targetOrShorthand, gotoStep, pointerStep, typeStep, pressStep, rightclickStep, uploadStep, dragStep, selectStep, scrollStep, expectStep, waitStep, stepSchema, ACTION_KEYS, authoredStep, specSchema, ASPECTS;
|
|
1331
1614
|
var init_schema = __esm({
|
|
1332
1615
|
"src/spec/schema.ts"() {
|
|
1333
1616
|
"use strict";
|
|
@@ -1344,7 +1627,7 @@ var init_schema = __esm({
|
|
|
1344
1627
|
}
|
|
1345
1628
|
});
|
|
1346
1629
|
focusHint = z.union([z.literal("target"), z.literal("wide"), locatorString]);
|
|
1347
|
-
EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout"];
|
|
1630
|
+
EXTRA_KEYS = ["caption", "narration", "focus", "mask", "shot", "timeout", "optional"];
|
|
1348
1631
|
stepExtras = {
|
|
1349
1632
|
caption: z.string().optional(),
|
|
1350
1633
|
/** The SPOKEN line for TTS narration — unconstrained by the caption card's size. Resolution:
|
|
@@ -1356,7 +1639,10 @@ var init_schema = __esm({
|
|
|
1356
1639
|
* scene structure (scenes stay narrative). */
|
|
1357
1640
|
shot: z.enum(["cut", "continue"]).optional(),
|
|
1358
1641
|
/** Per-step budget override (ms) for finding the target / meeting the expectation. */
|
|
1359
|
-
timeout: z.number().int().positive().optional()
|
|
1642
|
+
timeout: z.number().int().positive().optional(),
|
|
1643
|
+
/** A failing optional step is SKIPPED (warned, unfilmed beat) instead of killing the whole
|
|
1644
|
+
* capture — for cookie banners, A/B'd tooltips, and other environment noise. */
|
|
1645
|
+
optional: z.boolean().optional()
|
|
1360
1646
|
};
|
|
1361
1647
|
targetOrShorthand = z.union([
|
|
1362
1648
|
locatorString,
|
|
@@ -1373,9 +1659,15 @@ var init_schema = __esm({
|
|
|
1373
1659
|
target: locatorString,
|
|
1374
1660
|
text: z.string(),
|
|
1375
1661
|
mask: z.boolean().default(false),
|
|
1662
|
+
/** Select-all + overwrite instead of appending — editing a pre-filled field without this
|
|
1663
|
+
* produces "Janenew value". */
|
|
1664
|
+
clear: z.boolean().default(false),
|
|
1376
1665
|
...stepExtras
|
|
1377
1666
|
}).strict();
|
|
1378
1667
|
pressStep = z.object({ action: z.literal("press"), keys: z.string(), ...stepExtras }).strict();
|
|
1668
|
+
rightclickStep = z.object({ action: z.literal("rightclick"), target: locatorString, ...stepExtras }).strict();
|
|
1669
|
+
uploadStep = z.object({ action: z.literal("upload"), target: locatorString, file: z.string(), ...stepExtras }).strict();
|
|
1670
|
+
dragStep = z.object({ action: z.literal("drag"), target: locatorString, to: locatorString, ...stepExtras }).strict();
|
|
1379
1671
|
selectStep = z.object({
|
|
1380
1672
|
action: z.literal("select"),
|
|
1381
1673
|
target: locatorString,
|
|
@@ -1390,13 +1682,24 @@ var init_schema = __esm({
|
|
|
1390
1682
|
}).strict();
|
|
1391
1683
|
expectStep = z.object({
|
|
1392
1684
|
action: z.literal("expect"),
|
|
1393
|
-
target: locatorString,
|
|
1685
|
+
target: locatorString.optional(),
|
|
1394
1686
|
visible: z.boolean().optional(),
|
|
1395
1687
|
text: z.string().optional(),
|
|
1396
1688
|
/** Exact number of matching elements (e.g. rows in a filtered table). */
|
|
1397
1689
|
count: z.number().int().min(0).optional(),
|
|
1690
|
+
/** Current page URL must CONTAIN this substring (or match when wrapped /like this/). */
|
|
1691
|
+
url: z.string().optional(),
|
|
1692
|
+
/** Form control's current value. */
|
|
1693
|
+
value: z.string().optional(),
|
|
1694
|
+
/** Element enabled/disabled and checked state. */
|
|
1695
|
+
disabled: z.boolean().optional(),
|
|
1696
|
+
checked: z.boolean().optional(),
|
|
1398
1697
|
...stepExtras
|
|
1399
|
-
}).strict()
|
|
1698
|
+
}).strict().refine((s) => s.target !== void 0 || s.url !== void 0, {
|
|
1699
|
+
message: "expect needs a target locator (element assertions) and/or a url"
|
|
1700
|
+
}).refine((s) => s.target !== void 0 || s.visible === void 0 && s.text === void 0 && s.count === void 0 && s.value === void 0 && s.disabled === void 0 && s.checked === void 0, {
|
|
1701
|
+
message: "element assertions (visible/text/count/value/disabled/checked) need a target"
|
|
1702
|
+
});
|
|
1400
1703
|
waitStep = z.object({
|
|
1401
1704
|
action: z.literal("wait"),
|
|
1402
1705
|
ms: z.number().int().positive().optional(),
|
|
@@ -1411,12 +1714,15 @@ var init_schema = __esm({
|
|
|
1411
1714
|
pointerStep,
|
|
1412
1715
|
typeStep,
|
|
1413
1716
|
pressStep,
|
|
1717
|
+
rightclickStep,
|
|
1718
|
+
uploadStep,
|
|
1719
|
+
dragStep,
|
|
1414
1720
|
selectStep,
|
|
1415
1721
|
scrollStep,
|
|
1416
1722
|
expectStep,
|
|
1417
1723
|
waitStep
|
|
1418
1724
|
]);
|
|
1419
|
-
ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "select", "scroll", "expect", "wait"];
|
|
1725
|
+
ACTION_KEYS = ["goto", "click", "dblclick", "hover", "type", "press", "rightclick", "upload", "drag", "select", "scroll", "expect", "wait"];
|
|
1420
1726
|
authoredStep = z.record(z.string(), z.unknown()).superRefine((obj, ctx2) => {
|
|
1421
1727
|
const actions = ACTION_KEYS.filter((k) => k in obj);
|
|
1422
1728
|
if (actions.length !== 1) {
|
|
@@ -1519,7 +1825,10 @@ var init_schema = __esm({
|
|
|
1519
1825
|
}).strict().refine(
|
|
1520
1826
|
(a) => a.provider !== "kokoro" || a.voice === void 0 || ["heart", "af_heart", "michael", "am_michael"].includes(a.voice),
|
|
1521
1827
|
{ message: "kokoro voice must be 'heart' (default) or 'michael'", path: ["voice"] }
|
|
1522
|
-
).
|
|
1828
|
+
).refine((a) => a.provider !== "kokoro" || a.rate === void 0 || a.rate >= 80 && a.rate <= 140, {
|
|
1829
|
+
message: "kokoro rate is playback speed \xD7100 (100 = normal, sensible range 80\u2013140) \u2014 a say-style words-per-minute value like 178 would speak absurdly fast",
|
|
1830
|
+
path: ["rate"]
|
|
1831
|
+
}).optional(),
|
|
1523
1832
|
/** Closing card. When set, the video ends on a title-card-styled end card. */
|
|
1524
1833
|
endCard: z.object({
|
|
1525
1834
|
title: z.string().min(1),
|
|
@@ -1538,13 +1847,24 @@ var init_schema = __esm({
|
|
|
1538
1847
|
style: z.enum(["solid", "blur"]).default("solid")
|
|
1539
1848
|
}).strict()
|
|
1540
1849
|
).default([]),
|
|
1850
|
+
/** Steps that run BEFORE recording starts and never appear on film — dismiss a cookie-consent
|
|
1851
|
+
* banner, close a first-run tour, prime app state. Same step grammar as scenes. */
|
|
1852
|
+
setup: z.array(authoredStep.pipe(stepSchema)).default([]),
|
|
1541
1853
|
scenes: z.array(
|
|
1542
1854
|
z.object({
|
|
1543
1855
|
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "scene ids are lowercase kebab-case"),
|
|
1544
1856
|
title: z.string().optional(),
|
|
1545
1857
|
steps: z.array(authoredStep.pipe(stepSchema)).min(1)
|
|
1546
1858
|
}).strict()
|
|
1547
|
-
).min(1)
|
|
1859
|
+
).min(1).superRefine((scenes, ctx2) => {
|
|
1860
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1861
|
+
scenes.forEach((s, i) => {
|
|
1862
|
+
if (seen.has(s.id)) {
|
|
1863
|
+
ctx2.addIssue({ code: "custom", path: [i, "id"], message: `duplicate scene id "${s.id}" \u2014 ids must be unique (check the extended base spec too)` });
|
|
1864
|
+
}
|
|
1865
|
+
seen.add(s.id);
|
|
1866
|
+
});
|
|
1867
|
+
})
|
|
1548
1868
|
}).strict();
|
|
1549
1869
|
ASPECTS = {
|
|
1550
1870
|
"16:9": { viewport: { w: 1280, h: 720 }, resolution: { w: 1920, h: 1080 } },
|
|
@@ -1554,108 +1874,442 @@ var init_schema = __esm({
|
|
|
1554
1874
|
}
|
|
1555
1875
|
});
|
|
1556
1876
|
|
|
1557
|
-
// src/
|
|
1558
|
-
var
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
OK: 0,
|
|
1564
|
-
FLOW: 1,
|
|
1565
|
-
QUALITY: 2,
|
|
1566
|
-
INFRA: 3,
|
|
1567
|
-
USAGE: 4
|
|
1568
|
-
};
|
|
1569
|
-
FlowError = class extends Error {
|
|
1570
|
-
exitCode = EXIT.FLOW;
|
|
1571
|
-
};
|
|
1572
|
-
InfraError = class extends Error {
|
|
1573
|
-
exitCode = EXIT.INFRA;
|
|
1574
|
-
};
|
|
1575
|
-
}
|
|
1576
|
-
});
|
|
1577
|
-
|
|
1578
|
-
// src/authoring/validate.ts
|
|
1579
|
-
var validate_exports = {};
|
|
1580
|
-
__export(validate_exports, {
|
|
1581
|
-
formatValidateResult: () => formatValidateResult,
|
|
1582
|
-
validateLive: () => validateLive
|
|
1877
|
+
// src/spec/parse.ts
|
|
1878
|
+
var parse_exports = {};
|
|
1879
|
+
__export(parse_exports, {
|
|
1880
|
+
SpecError: () => SpecError,
|
|
1881
|
+
loadSpec: () => loadSpec,
|
|
1882
|
+
parseSpec: () => parseSpec
|
|
1583
1883
|
});
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
let
|
|
1589
|
-
let
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
});
|
|
1884
|
+
import { readFile } from "fs/promises";
|
|
1885
|
+
import { dirname as dirname2, resolve as resolvePath } from "path";
|
|
1886
|
+
import { parseDocument, LineCounter } from "yaml";
|
|
1887
|
+
function suggest(key) {
|
|
1888
|
+
let best = null;
|
|
1889
|
+
let bestD = 3;
|
|
1890
|
+
for (const k of KNOWN_KEYS) {
|
|
1891
|
+
const d = editDistance(key.toLowerCase(), k.toLowerCase());
|
|
1892
|
+
if (d > 0 && d < bestD) {
|
|
1893
|
+
bestD = d;
|
|
1894
|
+
best = k;
|
|
1895
|
+
}
|
|
1597
1896
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
if (n > 0) {
|
|
1650
|
-
const idx = issues.findIndex((i) => i.stepRef === stepRef && i.locator === focus && i.severity === "warn");
|
|
1651
|
-
if (idx >= 0) issues.splice(idx, 1);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1897
|
+
return best;
|
|
1898
|
+
}
|
|
1899
|
+
function parseSpec(yamlText, sourcePath = "<inline>") {
|
|
1900
|
+
const lineCounter = new LineCounter();
|
|
1901
|
+
const doc = parseDocument(yamlText, { lineCounter, keepSourceTokens: true });
|
|
1902
|
+
if (doc.errors.length > 0) {
|
|
1903
|
+
const first = doc.errors[0];
|
|
1904
|
+
throw new SpecError(`${sourcePath}: not valid YAML \u2014 ${first.message}`);
|
|
1905
|
+
}
|
|
1906
|
+
const raw = doc.toJS();
|
|
1907
|
+
if (raw && typeof raw === "object" && "extends" in raw) {
|
|
1908
|
+
throw new SpecError(`${sourcePath}: "extends" needs a file on disk \u2014 load the spec with loadSpec/the CLI, not inline text`);
|
|
1909
|
+
}
|
|
1910
|
+
return validateResolved(interpolate(raw, sourcePath), sourcePath, doc, lineCounter);
|
|
1911
|
+
}
|
|
1912
|
+
async function loadSpec(path) {
|
|
1913
|
+
const merged = await loadRaw(resolvePath(path), 0);
|
|
1914
|
+
const text = await readFile(resolvePath(path), "utf8");
|
|
1915
|
+
const lineCounter = new LineCounter();
|
|
1916
|
+
const doc = parseDocument(text, { lineCounter });
|
|
1917
|
+
const leaf = doc.toJS() ?? {};
|
|
1918
|
+
const arrLen = (v) => Array.isArray(v) ? v.length : 0;
|
|
1919
|
+
const offsets = {
|
|
1920
|
+
scenes: arrLen(merged.scenes) - arrLen(leaf.scenes),
|
|
1921
|
+
masking: arrLen(merged.masking) - arrLen(leaf.masking)
|
|
1922
|
+
};
|
|
1923
|
+
return validateResolved(interpolate(merged, path), path, doc, lineCounter, offsets);
|
|
1924
|
+
}
|
|
1925
|
+
async function loadRaw(path, depth) {
|
|
1926
|
+
if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
|
|
1927
|
+
const text = await readFile(path, "utf8");
|
|
1928
|
+
const doc = parseDocument(text);
|
|
1929
|
+
if (doc.errors.length > 0) throw new SpecError(`${path}: not valid YAML \u2014 ${doc.errors[0].message}`);
|
|
1930
|
+
const raw = doc.toJS() ?? {};
|
|
1931
|
+
const base = raw.extends;
|
|
1932
|
+
delete raw.extends;
|
|
1933
|
+
if (base === void 0) return raw;
|
|
1934
|
+
if (typeof base !== "string") throw new SpecError(`${path}: "extends" must be a path string`);
|
|
1935
|
+
const baseObj = await loadRaw(resolvePath(dirname2(path), base), depth + 1);
|
|
1936
|
+
return mergeSpecs(baseObj, raw);
|
|
1937
|
+
}
|
|
1938
|
+
function mergeSpecs(base, child) {
|
|
1939
|
+
const out = { ...base };
|
|
1940
|
+
for (const [k, v] of Object.entries(child)) {
|
|
1941
|
+
const b = out[k];
|
|
1942
|
+
if (Array.isArray(b) && Array.isArray(v) && (k === "masking" || k === "scenes")) {
|
|
1943
|
+
out[k] = [...b, ...v];
|
|
1944
|
+
} else if (isPlainObject(b) && isPlainObject(v)) {
|
|
1945
|
+
out[k] = mergeSpecs(b, v);
|
|
1946
|
+
} else {
|
|
1947
|
+
out[k] = v;
|
|
1654
1948
|
}
|
|
1655
|
-
} finally {
|
|
1656
|
-
if (owned) await driver.close();
|
|
1657
1949
|
}
|
|
1658
|
-
return
|
|
1950
|
+
return out;
|
|
1951
|
+
}
|
|
1952
|
+
function isPlainObject(v) {
|
|
1953
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1954
|
+
}
|
|
1955
|
+
function interpolate(raw, sourcePath) {
|
|
1956
|
+
if (!isPlainObject(raw)) return raw;
|
|
1957
|
+
const varsIn = isPlainObject(raw.vars) ? raw.vars : {};
|
|
1958
|
+
const vars = /* @__PURE__ */ new Map();
|
|
1959
|
+
for (const [name, value] of Object.entries(varsIn)) {
|
|
1960
|
+
vars.set(name, resolveEnv(String(value), sourcePath));
|
|
1961
|
+
}
|
|
1962
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
1963
|
+
let changed = false;
|
|
1964
|
+
for (const [name, value] of vars) {
|
|
1965
|
+
const next = value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (m, ref) => {
|
|
1966
|
+
if (ref === name) throw new SpecError(`${sourcePath}: variable {{${name}}} references itself`);
|
|
1967
|
+
const v = vars.get(ref);
|
|
1968
|
+
return v !== void 0 && !v.includes(`{{${name}}}`) ? v : m;
|
|
1969
|
+
});
|
|
1970
|
+
if (next !== value) {
|
|
1971
|
+
vars.set(name, next);
|
|
1972
|
+
changed = true;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (!changed) break;
|
|
1976
|
+
if (pass === 5) throw new SpecError(`${sourcePath}: variable references did not resolve after 6 passes \u2014 circular vars?`);
|
|
1977
|
+
}
|
|
1978
|
+
for (const [name, value] of vars) {
|
|
1979
|
+
const m = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(value);
|
|
1980
|
+
if (m && vars.has(m[1])) {
|
|
1981
|
+
throw new SpecError(`${sourcePath}: circular variable reference \u2014 {{${name}}} and {{${m[1]}}} depend on each other`);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
const doc = { ...raw };
|
|
1985
|
+
delete doc.vars;
|
|
1986
|
+
const seen = (s) => {
|
|
1987
|
+
const withEnv = resolveEnv(s, sourcePath);
|
|
1988
|
+
return withEnv.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, name) => {
|
|
1989
|
+
const v = vars.get(name);
|
|
1990
|
+
if (v === void 0) {
|
|
1991
|
+
const near = suggest(name) ?? [...vars.keys()].find((k) => editDistance(k, name) <= 2);
|
|
1992
|
+
throw new SpecError(
|
|
1993
|
+
`${sourcePath}: unknown variable {{${name}}}${near ? ` \u2014 did you mean {{${near}}}?` : ""} (declared vars: ${[...vars.keys()].join(", ") || "none"})`
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
return v;
|
|
1997
|
+
});
|
|
1998
|
+
};
|
|
1999
|
+
const walk = (v) => {
|
|
2000
|
+
if (typeof v === "string") return seen(v);
|
|
2001
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
2002
|
+
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, walk(val)]));
|
|
2003
|
+
return v;
|
|
2004
|
+
};
|
|
2005
|
+
return walk(doc);
|
|
2006
|
+
}
|
|
2007
|
+
function resolveEnv(s, sourcePath) {
|
|
2008
|
+
return s.replace(/\$\{env\.([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, dflt) => {
|
|
2009
|
+
const v = process.env[name];
|
|
2010
|
+
if (v !== void 0) return v;
|
|
2011
|
+
if (dflt !== void 0) return dflt;
|
|
2012
|
+
throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
function validateResolved(resolved, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
|
|
2016
|
+
const result = specSchema.safeParse(resolved);
|
|
2017
|
+
if (!result.success) {
|
|
2018
|
+
const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter, offsets));
|
|
2019
|
+
throw new SpecError(`${sourcePath}: invalid spec
|
|
2020
|
+
- ${issues.join("\n - ")}`, issues);
|
|
2021
|
+
}
|
|
2022
|
+
return result.data;
|
|
2023
|
+
}
|
|
2024
|
+
function formatIssue(iss, sourcePath, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
|
|
2025
|
+
const where = iss.path.length ? humanPath(iss.path) : "spec";
|
|
2026
|
+
const pos = positionOf(iss.path, doc, lineCounter, offsets);
|
|
2027
|
+
const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
|
|
2028
|
+
if (iss.code === "unrecognized_keys") {
|
|
2029
|
+
const keys = iss.keys;
|
|
2030
|
+
const hints = keys.map((k) => ({ k, near: suggest(k) })).map((h) => h.near ? `"${h.k}" (did you mean "${h.near}"?)` : `"${h.k}"`).join(", ");
|
|
2031
|
+
return `${at}${where}: unknown key${keys.length > 1 ? "s" : ""} ${hints}`;
|
|
2032
|
+
}
|
|
2033
|
+
return `${at}${where}: ${iss.message}`;
|
|
2034
|
+
}
|
|
2035
|
+
function positionOf(path, doc, lineCounter, offsets = { scenes: 0, masking: 0 }) {
|
|
2036
|
+
if ((path[0] === "scenes" || path[0] === "masking") && typeof path[1] === "number") {
|
|
2037
|
+
const off = offsets[path[0]];
|
|
2038
|
+
if (off > 0) {
|
|
2039
|
+
const adjusted = path[1] - off;
|
|
2040
|
+
if (adjusted < 0) return null;
|
|
2041
|
+
path = [path[0], adjusted, ...path.slice(2)];
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
for (let depth = path.length; depth > 0; depth--) {
|
|
2045
|
+
try {
|
|
2046
|
+
const node = doc.getIn(path.slice(0, depth), true);
|
|
2047
|
+
if (node?.range) {
|
|
2048
|
+
const pos = lineCounter.linePos(node.range[0]);
|
|
2049
|
+
return { line: pos.line, col: pos.col };
|
|
2050
|
+
}
|
|
2051
|
+
} catch {
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
function humanPath(path) {
|
|
2057
|
+
return path.map((seg, i) => typeof seg === "number" ? `[${seg}]` : i === 0 ? String(seg) : `.${String(seg)}`).join("");
|
|
2058
|
+
}
|
|
2059
|
+
var SpecError, KNOWN_KEYS;
|
|
2060
|
+
var init_parse = __esm({
|
|
2061
|
+
"src/spec/parse.ts"() {
|
|
2062
|
+
"use strict";
|
|
2063
|
+
init_schema();
|
|
2064
|
+
SpecError = class extends Error {
|
|
2065
|
+
constructor(message, issues = []) {
|
|
2066
|
+
super(message);
|
|
2067
|
+
this.issues = issues;
|
|
2068
|
+
}
|
|
2069
|
+
issues;
|
|
2070
|
+
};
|
|
2071
|
+
KNOWN_KEYS = [
|
|
2072
|
+
"playhead",
|
|
2073
|
+
"title",
|
|
2074
|
+
"subtitle",
|
|
2075
|
+
"app",
|
|
2076
|
+
"output",
|
|
2077
|
+
"theme",
|
|
2078
|
+
"masking",
|
|
2079
|
+
"scenes",
|
|
2080
|
+
"vars",
|
|
2081
|
+
"extends",
|
|
2082
|
+
"setup",
|
|
2083
|
+
"url",
|
|
2084
|
+
"viewport",
|
|
2085
|
+
"storageState",
|
|
2086
|
+
"compareUrl",
|
|
2087
|
+
"assertLoggedIn",
|
|
2088
|
+
"environment",
|
|
2089
|
+
"network",
|
|
2090
|
+
"dialogs",
|
|
2091
|
+
"settle",
|
|
2092
|
+
"timezone",
|
|
2093
|
+
"locale",
|
|
2094
|
+
"colorScheme",
|
|
2095
|
+
"fixedTime",
|
|
2096
|
+
"reducedMotion",
|
|
2097
|
+
"block",
|
|
2098
|
+
"stub",
|
|
2099
|
+
"har",
|
|
2100
|
+
"status",
|
|
2101
|
+
"body",
|
|
2102
|
+
"contentType",
|
|
2103
|
+
"idleMs",
|
|
2104
|
+
"capMs",
|
|
2105
|
+
"kind",
|
|
2106
|
+
"aspect",
|
|
2107
|
+
"resolution",
|
|
2108
|
+
"fps",
|
|
2109
|
+
"pacing",
|
|
2110
|
+
"dwellScale",
|
|
2111
|
+
"audio",
|
|
2112
|
+
"endCard",
|
|
2113
|
+
"narration",
|
|
2114
|
+
"provider",
|
|
2115
|
+
"voice",
|
|
2116
|
+
"rate",
|
|
2117
|
+
"sfx",
|
|
2118
|
+
"music",
|
|
2119
|
+
"file",
|
|
2120
|
+
"gainDb",
|
|
2121
|
+
"accent",
|
|
2122
|
+
"captions",
|
|
2123
|
+
"target",
|
|
2124
|
+
"style",
|
|
2125
|
+
"id",
|
|
2126
|
+
"steps",
|
|
2127
|
+
"shot",
|
|
2128
|
+
"timeout",
|
|
2129
|
+
"goto",
|
|
2130
|
+
"click",
|
|
2131
|
+
"dblclick",
|
|
2132
|
+
"hover",
|
|
2133
|
+
"type",
|
|
2134
|
+
"press",
|
|
2135
|
+
"select",
|
|
2136
|
+
"scroll",
|
|
2137
|
+
"expect",
|
|
2138
|
+
"wait",
|
|
2139
|
+
"caption",
|
|
2140
|
+
"focus",
|
|
2141
|
+
"mask",
|
|
2142
|
+
"text",
|
|
2143
|
+
"value",
|
|
2144
|
+
"keys",
|
|
2145
|
+
"ms",
|
|
2146
|
+
"by",
|
|
2147
|
+
"visible",
|
|
2148
|
+
"hidden",
|
|
2149
|
+
"count",
|
|
2150
|
+
"for",
|
|
2151
|
+
"state",
|
|
2152
|
+
"x",
|
|
2153
|
+
"y",
|
|
2154
|
+
"optional",
|
|
2155
|
+
"clear",
|
|
2156
|
+
"to",
|
|
2157
|
+
"disabled",
|
|
2158
|
+
"checked",
|
|
2159
|
+
"rightclick",
|
|
2160
|
+
"upload",
|
|
2161
|
+
"drag"
|
|
2162
|
+
];
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
// src/authoring/validate.ts
|
|
2167
|
+
var validate_exports = {};
|
|
2168
|
+
__export(validate_exports, {
|
|
2169
|
+
formatValidateResult: () => formatValidateResult,
|
|
2170
|
+
validateLive: () => validateLive
|
|
2171
|
+
});
|
|
2172
|
+
async function validateLive(spec, opts) {
|
|
2173
|
+
const driver = opts?.driver ?? new PlaywrightDriver();
|
|
2174
|
+
const owned = !opts?.driver;
|
|
2175
|
+
const issues = [];
|
|
2176
|
+
let checked = 0;
|
|
2177
|
+
let stateDiverged = false;
|
|
2178
|
+
if (owned) {
|
|
2179
|
+
await driver.launch({
|
|
2180
|
+
viewport: resolveViewport(spec),
|
|
2181
|
+
dpr: 1,
|
|
2182
|
+
headless: opts?.headless ?? true,
|
|
2183
|
+
...spec.app.storageState ? { storageStatePath: spec.app.storageState } : {},
|
|
2184
|
+
...spec.app.environment ? { environment: spec.app.environment } : {},
|
|
2185
|
+
...spec.app.network ? { network: spec.app.network } : {},
|
|
2186
|
+
...spec.app.dialogs ? { dialogs: spec.app.dialogs } : {}
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
const settleCfg = { idleMs: spec.app.settle?.idleMs ?? 300, capMs: spec.app.settle?.capMs ?? 5e3 };
|
|
2190
|
+
const vp = resolveViewport(spec);
|
|
2191
|
+
const res = resolveResolution(spec);
|
|
2192
|
+
const plane2 = { vpW: vp.w, vpH: vp.h, outW: res.w, outH: res.h };
|
|
2193
|
+
try {
|
|
2194
|
+
await driver.goto(spec.app.url);
|
|
2195
|
+
await driver.settle(settleCfg);
|
|
2196
|
+
if (spec.app.assertLoggedIn) {
|
|
2197
|
+
const n = await driver.countMatches(parseLocator(spec.app.assertLoggedIn)).catch(() => 0);
|
|
2198
|
+
if (n === 0) {
|
|
2199
|
+
issues.push({
|
|
2200
|
+
stepRef: "(setup)",
|
|
2201
|
+
step: "assertLoggedIn",
|
|
2202
|
+
locator: spec.app.assertLoggedIn,
|
|
2203
|
+
severity: "error",
|
|
2204
|
+
message: "assertLoggedIn matches nothing \u2014 storageState stale? Re-run: playhead login",
|
|
2205
|
+
suggestions: []
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
for (const m of spec.masking) {
|
|
2210
|
+
checked += 1;
|
|
2211
|
+
const n = await driver.countMatches(parseLocator(m.target)).catch(() => 0);
|
|
2212
|
+
if (n === 0) {
|
|
2213
|
+
issues.push({
|
|
2214
|
+
stepRef: "(masking)",
|
|
2215
|
+
step: "mask",
|
|
2216
|
+
locator: m.target,
|
|
2217
|
+
severity: "warn",
|
|
2218
|
+
message: "masking rule matches nothing on the initial screen (fine if the element appears later \u2014 verify the render)",
|
|
2219
|
+
suggestions: []
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
const setupSteps = spec.setup.map((step, i) => ({
|
|
2224
|
+
sceneId: "setup",
|
|
2225
|
+
sceneIndex: -1,
|
|
2226
|
+
stepIndex: i,
|
|
2227
|
+
ordinal: 0,
|
|
2228
|
+
step
|
|
2229
|
+
}));
|
|
2230
|
+
for (const a of [...setupSteps, ...flattenSteps(spec)]) {
|
|
2231
|
+
const stepRef = `${a.sceneId}/${a.stepIndex}`;
|
|
2232
|
+
const step = a.step;
|
|
2233
|
+
const push = async (locator, severity, message) => {
|
|
2234
|
+
issues.push({
|
|
2235
|
+
stepRef,
|
|
2236
|
+
step: step.action,
|
|
2237
|
+
locator,
|
|
2238
|
+
severity,
|
|
2239
|
+
message: stateDiverged ? `${message} (after an earlier failure \u2014 state may have diverged)` : message,
|
|
2240
|
+
suggestions: severity === "error" ? await suggestFor(driver, locator) : []
|
|
2241
|
+
});
|
|
2242
|
+
};
|
|
2243
|
+
const locators = [];
|
|
2244
|
+
if ("target" in step && step.target) locators.push({ value: step.target, kind: "target" });
|
|
2245
|
+
if (step.action === "drag") locators.push({ value: step.to, kind: "target" });
|
|
2246
|
+
if (step.action === "wait" && step.for) locators.push({ value: step.for, kind: "wait" });
|
|
2247
|
+
if (step.focus && step.focus !== "target" && step.focus !== "wide") {
|
|
2248
|
+
locators.push({ value: step.focus, kind: "focus" });
|
|
2249
|
+
}
|
|
2250
|
+
for (const l of locators) {
|
|
2251
|
+
checked += 1;
|
|
2252
|
+
const count = await driver.countMatches(parseLocator(l.value)).catch(() => 0);
|
|
2253
|
+
if (count === 0 && l.kind === "target") {
|
|
2254
|
+
await push(l.value, "error", "no matching element on the current screen");
|
|
2255
|
+
} else if (count === 0 && l.kind !== "target") {
|
|
2256
|
+
await push(l.value, "warn", `${l.kind} locator matches nothing yet (may appear after the action)`);
|
|
2257
|
+
} else if (count > 1 && !l.value.includes(">> nth=")) {
|
|
2258
|
+
await push(l.value, "warn", `ambiguous: ${count} matches \u2014 add '>> nth=N' to pick one`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (a.sceneIndex >= 0 && "target" in step && step.target && FRAMED_ACTIONS.has(step.action)) {
|
|
2262
|
+
try {
|
|
2263
|
+
const t = await driver.resolveTarget(parseLocator(step.target), 3e3);
|
|
2264
|
+
const violation = framingViolation(t.bbox, plane2);
|
|
2265
|
+
if (violation) await push(step.target, "warn", violation);
|
|
2266
|
+
} catch {
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
try {
|
|
2270
|
+
await fastExecute(driver, step);
|
|
2271
|
+
} catch (e) {
|
|
2272
|
+
if ("target" in step && step.target) {
|
|
2273
|
+
const already = issues.some((i) => i.stepRef === stepRef && i.severity === "error");
|
|
2274
|
+
if (!already) await push(step.target ?? "(none)", "error", `step failed to execute: ${firstLine2(e.message)}`);
|
|
2275
|
+
}
|
|
2276
|
+
stateDiverged = true;
|
|
2277
|
+
}
|
|
2278
|
+
const focus = step.focus;
|
|
2279
|
+
if (focus && focus !== "target" && focus !== "wide") {
|
|
2280
|
+
const loc = parseLocator(focus);
|
|
2281
|
+
let n = 0;
|
|
2282
|
+
for (let i = 0; i < 8 && n === 0; i++) {
|
|
2283
|
+
n = await driver.countMatches(loc).catch(() => 0);
|
|
2284
|
+
if (n === 0) await sleep3(250);
|
|
2285
|
+
}
|
|
2286
|
+
if (n === 0) await push(focus, "error", "focus locator never appeared after the action");
|
|
2287
|
+
if (n > 0) {
|
|
2288
|
+
const idx = issues.findIndex((i) => i.stepRef === stepRef && i.locator === focus && i.severity === "warn");
|
|
2289
|
+
if (idx >= 0) issues.splice(idx, 1);
|
|
2290
|
+
try {
|
|
2291
|
+
const t = await driver.resolveTarget(loc, 3e3);
|
|
2292
|
+
const violation = framingViolation(t.bbox, plane2);
|
|
2293
|
+
if (violation) await push(focus, "warn", violation);
|
|
2294
|
+
} catch {
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
} finally {
|
|
2300
|
+
if (owned) await driver.close();
|
|
2301
|
+
}
|
|
2302
|
+
return { ok: !issues.some((i) => i.severity === "error"), checked, issues };
|
|
2303
|
+
}
|
|
2304
|
+
function framingViolation(bbox, plane2) {
|
|
2305
|
+
const cam = clampCamera(
|
|
2306
|
+
{ cx: bbox.x + bbox.w / 2, cy: bbox.y + bbox.h / 2, zoom: minZoom(plane2) },
|
|
2307
|
+
plane2
|
|
2308
|
+
);
|
|
2309
|
+
const proj = projectRect(bbox, cam, plane2);
|
|
2310
|
+
if (rectContains({ x: 0, y: 0, w: plane2.outW, h: plane2.outH }, proj, 2)) return null;
|
|
2311
|
+
const fullBleed = bbox.x <= 2 || bbox.y <= 2 || bbox.x + bbox.w >= plane2.vpW - 2 || bbox.y + bbox.h >= plane2.vpH - 2;
|
|
2312
|
+
return fullBleed ? "full-bleed target (touches the viewport edge) \u2014 no camera can frame it with the 2px margin verification requires; target a smaller element inside it, or make this a scroll/wait step" : "target is larger than the widest camera view \u2014 verification's target-in-frame check will fail; use focus: on a smaller payoff element";
|
|
1659
2313
|
}
|
|
1660
2314
|
async function fastExecute(driver, step) {
|
|
1661
2315
|
const FAST_TIMEOUT = step.timeout ?? 8e3;
|
|
@@ -1691,13 +2345,28 @@ async function fastExecute(driver, step) {
|
|
|
1691
2345
|
else if (step.by) await driver.scrollBy(step.by);
|
|
1692
2346
|
await driver.waitForScrollSettle(1500);
|
|
1693
2347
|
break;
|
|
2348
|
+
case "rightclick": {
|
|
2349
|
+
await driver.resolveTarget(parseLocator(step.target), FAST_TIMEOUT);
|
|
2350
|
+
await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: FAST_TIMEOUT });
|
|
2351
|
+
break;
|
|
2352
|
+
}
|
|
2353
|
+
case "upload":
|
|
2354
|
+
await driver.setInputFiles(parseLocator(step.target), step.file);
|
|
2355
|
+
break;
|
|
2356
|
+
case "drag":
|
|
2357
|
+
await driver.dragTo(parseLocator(step.target), parseLocator(step.to), FAST_TIMEOUT);
|
|
2358
|
+
break;
|
|
1694
2359
|
case "expect":
|
|
1695
2360
|
await driver.expectState(
|
|
1696
|
-
parseLocator(step.target),
|
|
2361
|
+
step.target ? parseLocator(step.target) : null,
|
|
1697
2362
|
{
|
|
1698
2363
|
...step.visible !== void 0 ? { visible: step.visible } : {},
|
|
1699
2364
|
...step.text !== void 0 ? { text: step.text } : {},
|
|
1700
|
-
...step.count !== void 0 ? { count: step.count } : {}
|
|
2365
|
+
...step.count !== void 0 ? { count: step.count } : {},
|
|
2366
|
+
...step.url !== void 0 ? { url: step.url } : {},
|
|
2367
|
+
...step.value !== void 0 ? { value: step.value } : {},
|
|
2368
|
+
...step.disabled !== void 0 ? { disabled: step.disabled } : {},
|
|
2369
|
+
...step.checked !== void 0 ? { checked: step.checked } : {}
|
|
1701
2370
|
},
|
|
1702
2371
|
FAST_TIMEOUT
|
|
1703
2372
|
);
|
|
@@ -1745,6 +2414,7 @@ function formatValidateResult(res) {
|
|
|
1745
2414
|
);
|
|
1746
2415
|
return lines.join("\n");
|
|
1747
2416
|
}
|
|
2417
|
+
var FRAMED_ACTIONS;
|
|
1748
2418
|
var init_validate = __esm({
|
|
1749
2419
|
"src/authoring/validate.ts"() {
|
|
1750
2420
|
"use strict";
|
|
@@ -1754,6 +2424,7 @@ var init_validate = __esm({
|
|
|
1754
2424
|
init_schema();
|
|
1755
2425
|
init_explore();
|
|
1756
2426
|
init_geometry();
|
|
2427
|
+
FRAMED_ACTIONS = /* @__PURE__ */ new Set(["click", "dblclick", "hover", "type", "select", "expect"]);
|
|
1757
2428
|
}
|
|
1758
2429
|
});
|
|
1759
2430
|
|
|
@@ -1769,8 +2440,8 @@ __export(jira_exports, {
|
|
|
1769
2440
|
reportToJira: () => reportToJira
|
|
1770
2441
|
});
|
|
1771
2442
|
import { readFile as readFile5, stat } from "fs/promises";
|
|
1772
|
-
import { join as
|
|
1773
|
-
import { existsSync as
|
|
2443
|
+
import { join as join10, basename } from "path";
|
|
2444
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1774
2445
|
function jiraConfigFromEnv() {
|
|
1775
2446
|
const baseUrl = process.env.JIRA_BASE_URL;
|
|
1776
2447
|
const email = process.env.JIRA_EMAIL;
|
|
@@ -1834,17 +2505,17 @@ async function addComment(cfg, issueKey, adfBody) {
|
|
|
1834
2505
|
}
|
|
1835
2506
|
async function collectEvidence(outDir) {
|
|
1836
2507
|
const candidates = [
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2508
|
+
join10(outDir, "out.mp4"),
|
|
2509
|
+
join10(outDir, "failure.mp4"),
|
|
2510
|
+
join10(outDir, "verify", "verdict.json"),
|
|
2511
|
+
join10(outDir, "verify", "contact-sheet.png"),
|
|
2512
|
+
join10(outDir, "junit.xml"),
|
|
2513
|
+
join10(outDir, "capture", "failure.json"),
|
|
2514
|
+
join10(outDir, "capture", "console.json")
|
|
1844
2515
|
];
|
|
1845
2516
|
const files = [];
|
|
1846
2517
|
for (const f of candidates) {
|
|
1847
|
-
if (!
|
|
2518
|
+
if (!existsSync5(f)) continue;
|
|
1848
2519
|
const s = await stat(f);
|
|
1849
2520
|
if (s.size > 95 * 1024 * 1024) {
|
|
1850
2521
|
log.warn(`skipping ${basename(f)} (${(s.size / 1e6).toFixed(0)}MB \u2014 larger than Jira's usual attachment ceiling)`);
|
|
@@ -1856,11 +2527,11 @@ async function collectEvidence(outDir) {
|
|
|
1856
2527
|
throw new InfraError(`no Playhead artifacts found under ${outDir} \u2014 expected out.mp4/failure.mp4, verify/verdict.json, \u2026`);
|
|
1857
2528
|
}
|
|
1858
2529
|
let verdict;
|
|
1859
|
-
const verdictPath =
|
|
1860
|
-
if (
|
|
2530
|
+
const verdictPath = join10(outDir, "verify", "verdict.json");
|
|
2531
|
+
if (existsSync5(verdictPath)) verdict = JSON.parse(await readFile5(verdictPath, "utf8"));
|
|
1861
2532
|
let failure;
|
|
1862
|
-
const failurePath =
|
|
1863
|
-
if (
|
|
2533
|
+
const failurePath = join10(outDir, "capture", "failure.json");
|
|
2534
|
+
if (existsSync5(failurePath)) failure = JSON.parse(await readFile5(failurePath, "utf8"));
|
|
1864
2535
|
const outcome = failure ? "flow-failed" : verdict?.verdict ?? "unknown";
|
|
1865
2536
|
return { outcome, ...verdict ? { verdict } : {}, ...failure ? { failure } : {}, files };
|
|
1866
2537
|
}
|
|
@@ -1929,12 +2600,33 @@ import { mkdtemp, writeFile as writeFile7, mkdir as mkdir5, readFile as readFile
|
|
|
1929
2600
|
|
|
1930
2601
|
// src/shared/version.ts
|
|
1931
2602
|
import { createRequire } from "module";
|
|
1932
|
-
|
|
2603
|
+
import { fileURLToPath } from "url";
|
|
2604
|
+
import { dirname, join } from "path";
|
|
2605
|
+
import { existsSync, readFileSync } from "fs";
|
|
2606
|
+
function resolveOwnPackageJson() {
|
|
2607
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
2608
|
+
for (let i = 0; i < 6; i++) {
|
|
2609
|
+
const p = join(dir, "package.json");
|
|
2610
|
+
if (existsSync(p)) {
|
|
2611
|
+
const pkg = JSON.parse(readFileSync(p, "utf8"));
|
|
2612
|
+
if (pkg.name === "playhead-cli" || pkg.name === "playhead") return { version: pkg.version ?? "0.0.0" };
|
|
2613
|
+
}
|
|
2614
|
+
const parent = dirname(dir);
|
|
2615
|
+
if (parent === dir) break;
|
|
2616
|
+
dir = parent;
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
return createRequire(import.meta.url)("../../package.json");
|
|
2620
|
+
} catch {
|
|
2621
|
+
return { version: "0.0.0" };
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
var PLAYHEAD_VERSION = resolveOwnPackageJson().version;
|
|
1933
2625
|
|
|
1934
2626
|
// src/mcp/server.ts
|
|
1935
2627
|
init_explore();
|
|
1936
2628
|
import { tmpdir } from "os";
|
|
1937
|
-
import { join as
|
|
2629
|
+
import { join as join11, resolve, isAbsolute } from "path";
|
|
1938
2630
|
import { z as z2 } from "zod";
|
|
1939
2631
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1940
2632
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -2037,25 +2729,46 @@ async function authorSpec(opts) {
|
|
|
2037
2729
|
const owned = !opts.driver;
|
|
2038
2730
|
const steps = [];
|
|
2039
2731
|
log.info(`authoring "${opts.goal}" against ${opts.url}`);
|
|
2040
|
-
if (owned)
|
|
2732
|
+
if (owned)
|
|
2733
|
+
await driver.launch({
|
|
2734
|
+
viewport: opts.viewport,
|
|
2735
|
+
dpr: 2,
|
|
2736
|
+
headless: opts.headless,
|
|
2737
|
+
...opts.storageStatePath ? { storageStatePath: opts.storageStatePath } : {}
|
|
2738
|
+
});
|
|
2041
2739
|
try {
|
|
2042
2740
|
await driver.goto(opts.url);
|
|
2043
2741
|
await driver.settle(SETTLE);
|
|
2742
|
+
let feedback;
|
|
2743
|
+
let consecutiveFailures = 0;
|
|
2044
2744
|
for (let i = 0; i < opts.maxSteps; i++) {
|
|
2045
2745
|
const snap = await snapshotPage(driver);
|
|
2046
|
-
const decision = await decide(opts.goal, snap, steps);
|
|
2746
|
+
const decision = await decide(opts.goal, snap, steps, feedback);
|
|
2047
2747
|
if (decision.done || !decision.action) {
|
|
2048
|
-
log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
|
|
2748
|
+
if (decision.done) log.ok(`agent finished: ${decision.reason ?? "goal shown"}`);
|
|
2749
|
+
else log.warn(`agent returned no action (${decision.reason ?? "no reason"}) \u2014 stopping`);
|
|
2049
2750
|
break;
|
|
2050
2751
|
}
|
|
2752
|
+
if (decision.locator && !snap.catalog.some((e) => e.locator === decision.locator)) {
|
|
2753
|
+
feedback = `Your locator ${decision.locator} is NOT in the catalog \u2014 copy one verbatim from the list.`;
|
|
2754
|
+
consecutiveFailures += 1;
|
|
2755
|
+
log.warn(`agent invented a locator (${decision.locator}); asking it to pick from the catalog`);
|
|
2756
|
+
if (consecutiveFailures >= 3) throw new Error("authoring stuck: 3 consecutive invalid steps \u2014 the goal may not be reachable from this screen");
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2051
2759
|
const step = toAuthoredStep(decision);
|
|
2052
2760
|
log.step(`${steps.length + 1}. ${describe(step)}${decision.reason ? ` \u2014 ${decision.reason}` : ""}`);
|
|
2053
2761
|
try {
|
|
2054
2762
|
await perform(driver, step);
|
|
2055
2763
|
await driver.settle(SETTLE);
|
|
2056
2764
|
steps.push(step);
|
|
2765
|
+
feedback = void 0;
|
|
2766
|
+
consecutiveFailures = 0;
|
|
2057
2767
|
} catch (e) {
|
|
2058
|
-
|
|
2768
|
+
feedback = `Your last step FAILED: ${describe(step)} \u2014 ${e.message.split("\n")[0]}. Choose a different step.`;
|
|
2769
|
+
consecutiveFailures += 1;
|
|
2770
|
+
log.warn(`step failed (${e.message.split("\n")[0]}); feeding the failure back to the agent`);
|
|
2771
|
+
if (consecutiveFailures >= 3) throw new Error(`authoring stuck: 3 consecutive step failures (last: ${describe(step)})`);
|
|
2059
2772
|
}
|
|
2060
2773
|
}
|
|
2061
2774
|
if (steps.length === 0) throw new Error("authoring produced no steps \u2014 the goal may not be reachable from this URL");
|
|
@@ -2063,11 +2776,20 @@ async function authorSpec(opts) {
|
|
|
2063
2776
|
title: opts.title ?? capitalize(opts.goal),
|
|
2064
2777
|
url: opts.url,
|
|
2065
2778
|
viewport: opts.viewport,
|
|
2066
|
-
kind: "walkthrough",
|
|
2779
|
+
kind: opts.kind ?? "walkthrough",
|
|
2067
2780
|
steps
|
|
2068
2781
|
};
|
|
2069
|
-
|
|
2070
|
-
|
|
2782
|
+
const yaml = serializeSpec(spec);
|
|
2783
|
+
const { parseSpec: parseSpec2 } = await Promise.resolve().then(() => (init_parse(), parse_exports));
|
|
2784
|
+
try {
|
|
2785
|
+
parseSpec2(yaml, opts.outPath);
|
|
2786
|
+
} catch (e) {
|
|
2787
|
+
await writeFile(opts.outPath, yaml);
|
|
2788
|
+
throw new Error(`authored spec failed validation \u2014 written to ${opts.outPath} for inspection:
|
|
2789
|
+
${e.message}`);
|
|
2790
|
+
}
|
|
2791
|
+
await writeFile(opts.outPath, yaml);
|
|
2792
|
+
log.ok(`wrote ${steps.length}-step spec \u2192 ${opts.outPath} (validated)`);
|
|
2071
2793
|
log.info(`next: playhead render ${opts.outPath}`);
|
|
2072
2794
|
return { specPath: opts.outPath, steps };
|
|
2073
2795
|
} finally {
|
|
@@ -2084,9 +2806,16 @@ function claudeDecider() {
|
|
|
2084
2806
|
if (!clientPromise) clientPromise = import("@anthropic-ai/sdk").then((m) => new m.default());
|
|
2085
2807
|
return clientPromise;
|
|
2086
2808
|
};
|
|
2087
|
-
return async (goal, snap, soFar) => {
|
|
2809
|
+
return async (goal, snap, soFar, feedback) => {
|
|
2088
2810
|
const client = await getClient();
|
|
2089
|
-
|
|
2811
|
+
let catalog = snap.catalog.map((e) => ` ${e.locator}${e.options ? ` (options: ${e.options.join(" | ")})` : ""}${e.unique ? "" : " [ambiguous]"}`).join("\n");
|
|
2812
|
+
const CATALOG_CAP = 12e3;
|
|
2813
|
+
if (catalog.length > CATALOG_CAP) {
|
|
2814
|
+
const kept = catalog.slice(0, CATALOG_CAP);
|
|
2815
|
+
const dropped = catalog.slice(CATALOG_CAP).split("\n").length;
|
|
2816
|
+
catalog = kept + `
|
|
2817
|
+
\u2026 (catalog truncated \u2014 ${dropped} more elements not shown)`;
|
|
2818
|
+
}
|
|
2090
2819
|
const history = soFar.length ? soFar.map((s, i) => ` ${i + 1}. ${describe(s)}`).join("\n") : " (none yet)";
|
|
2091
2820
|
const userMsg = [
|
|
2092
2821
|
`GOAL: ${goal}`,
|
|
@@ -2097,6 +2826,7 @@ function claudeDecider() {
|
|
|
2097
2826
|
``,
|
|
2098
2827
|
`STEPS SO FAR:`,
|
|
2099
2828
|
history,
|
|
2829
|
+
...feedback ? [``, `IMPORTANT \u2014 PREVIOUS ATTEMPT: ${feedback}`] : [],
|
|
2100
2830
|
``,
|
|
2101
2831
|
`Emit the next step (or done=true if the goal is fully shown).`
|
|
2102
2832
|
].join("\n");
|
|
@@ -2163,259 +2893,31 @@ function describe(s) {
|
|
|
2163
2893
|
return `type "${s.mask ? "\u2022\u2022\u2022\u2022" : s.text}" into ${s.locator}`;
|
|
2164
2894
|
case "select":
|
|
2165
2895
|
return `select "${s.value}" in ${s.locator}`;
|
|
2166
|
-
case "press":
|
|
2167
|
-
return `press ${s.keys}`;
|
|
2168
|
-
case "expect":
|
|
2169
|
-
return `expect ${s.locator}`;
|
|
2170
|
-
default:
|
|
2171
|
-
return `${s.action} ${s.locator}`;
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
function capitalize(s) {
|
|
2175
|
-
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
// src/spec/parse.ts
|
|
2179
|
-
init_schema();
|
|
2180
|
-
import { readFile } from "fs/promises";
|
|
2181
|
-
import { dirname, resolve as resolvePath } from "path";
|
|
2182
|
-
import { parseDocument, LineCounter } from "yaml";
|
|
2183
|
-
var SpecError = class extends Error {
|
|
2184
|
-
constructor(message, issues = []) {
|
|
2185
|
-
super(message);
|
|
2186
|
-
this.issues = issues;
|
|
2187
|
-
}
|
|
2188
|
-
issues;
|
|
2189
|
-
};
|
|
2190
|
-
var KNOWN_KEYS = [
|
|
2191
|
-
"playhead",
|
|
2192
|
-
"title",
|
|
2193
|
-
"subtitle",
|
|
2194
|
-
"app",
|
|
2195
|
-
"output",
|
|
2196
|
-
"theme",
|
|
2197
|
-
"masking",
|
|
2198
|
-
"scenes",
|
|
2199
|
-
"vars",
|
|
2200
|
-
"extends",
|
|
2201
|
-
"url",
|
|
2202
|
-
"viewport",
|
|
2203
|
-
"storageState",
|
|
2204
|
-
"compareUrl",
|
|
2205
|
-
"assertLoggedIn",
|
|
2206
|
-
"environment",
|
|
2207
|
-
"network",
|
|
2208
|
-
"dialogs",
|
|
2209
|
-
"settle",
|
|
2210
|
-
"timezone",
|
|
2211
|
-
"locale",
|
|
2212
|
-
"colorScheme",
|
|
2213
|
-
"fixedTime",
|
|
2214
|
-
"reducedMotion",
|
|
2215
|
-
"block",
|
|
2216
|
-
"stub",
|
|
2217
|
-
"har",
|
|
2218
|
-
"status",
|
|
2219
|
-
"body",
|
|
2220
|
-
"contentType",
|
|
2221
|
-
"idleMs",
|
|
2222
|
-
"capMs",
|
|
2223
|
-
"kind",
|
|
2224
|
-
"aspect",
|
|
2225
|
-
"resolution",
|
|
2226
|
-
"fps",
|
|
2227
|
-
"pacing",
|
|
2228
|
-
"dwellScale",
|
|
2229
|
-
"audio",
|
|
2230
|
-
"endCard",
|
|
2231
|
-
"narration",
|
|
2232
|
-
"provider",
|
|
2233
|
-
"voice",
|
|
2234
|
-
"rate",
|
|
2235
|
-
"sfx",
|
|
2236
|
-
"music",
|
|
2237
|
-
"file",
|
|
2238
|
-
"gainDb",
|
|
2239
|
-
"accent",
|
|
2240
|
-
"captions",
|
|
2241
|
-
"target",
|
|
2242
|
-
"style",
|
|
2243
|
-
"id",
|
|
2244
|
-
"steps",
|
|
2245
|
-
"shot",
|
|
2246
|
-
"timeout",
|
|
2247
|
-
"goto",
|
|
2248
|
-
"click",
|
|
2249
|
-
"dblclick",
|
|
2250
|
-
"hover",
|
|
2251
|
-
"type",
|
|
2252
|
-
"press",
|
|
2253
|
-
"select",
|
|
2254
|
-
"scroll",
|
|
2255
|
-
"expect",
|
|
2256
|
-
"wait",
|
|
2257
|
-
"caption",
|
|
2258
|
-
"focus",
|
|
2259
|
-
"mask",
|
|
2260
|
-
"text",
|
|
2261
|
-
"value",
|
|
2262
|
-
"keys",
|
|
2263
|
-
"ms",
|
|
2264
|
-
"by",
|
|
2265
|
-
"visible",
|
|
2266
|
-
"hidden",
|
|
2267
|
-
"count",
|
|
2268
|
-
"for",
|
|
2269
|
-
"state",
|
|
2270
|
-
"x",
|
|
2271
|
-
"y"
|
|
2272
|
-
];
|
|
2273
|
-
function suggest(key) {
|
|
2274
|
-
let best = null;
|
|
2275
|
-
let bestD = 3;
|
|
2276
|
-
for (const k of KNOWN_KEYS) {
|
|
2277
|
-
const d = editDistance(key.toLowerCase(), k.toLowerCase());
|
|
2278
|
-
if (d > 0 && d < bestD) {
|
|
2279
|
-
bestD = d;
|
|
2280
|
-
best = k;
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
return best;
|
|
2284
|
-
}
|
|
2285
|
-
function parseSpec(yamlText, sourcePath = "<inline>") {
|
|
2286
|
-
const lineCounter = new LineCounter();
|
|
2287
|
-
const doc = parseDocument(yamlText, { lineCounter, keepSourceTokens: true });
|
|
2288
|
-
if (doc.errors.length > 0) {
|
|
2289
|
-
const first = doc.errors[0];
|
|
2290
|
-
throw new SpecError(`${sourcePath}: not valid YAML \u2014 ${first.message}`);
|
|
2291
|
-
}
|
|
2292
|
-
const raw = doc.toJS();
|
|
2293
|
-
if (raw && typeof raw === "object" && "extends" in raw) {
|
|
2294
|
-
throw new SpecError(`${sourcePath}: "extends" needs a file on disk \u2014 load the spec with loadSpec/the CLI, not inline text`);
|
|
2295
|
-
}
|
|
2296
|
-
return validateResolved(interpolate(raw, sourcePath), sourcePath, doc, lineCounter);
|
|
2297
|
-
}
|
|
2298
|
-
async function loadSpec(path) {
|
|
2299
|
-
const merged = await loadRaw(resolvePath(path), 0);
|
|
2300
|
-
const text = await readFile(resolvePath(path), "utf8");
|
|
2301
|
-
const lineCounter = new LineCounter();
|
|
2302
|
-
const doc = parseDocument(text, { lineCounter });
|
|
2303
|
-
return validateResolved(interpolate(merged, path), path, doc, lineCounter);
|
|
2304
|
-
}
|
|
2305
|
-
async function loadRaw(path, depth) {
|
|
2306
|
-
if (depth > 4) throw new SpecError(`${path}: extends chain deeper than 4 \u2014 check for a cycle`);
|
|
2307
|
-
const text = await readFile(path, "utf8");
|
|
2308
|
-
const doc = parseDocument(text);
|
|
2309
|
-
if (doc.errors.length > 0) throw new SpecError(`${path}: not valid YAML \u2014 ${doc.errors[0].message}`);
|
|
2310
|
-
const raw = doc.toJS() ?? {};
|
|
2311
|
-
const base = raw.extends;
|
|
2312
|
-
delete raw.extends;
|
|
2313
|
-
if (base === void 0) return raw;
|
|
2314
|
-
if (typeof base !== "string") throw new SpecError(`${path}: "extends" must be a path string`);
|
|
2315
|
-
const baseObj = await loadRaw(resolvePath(dirname(path), base), depth + 1);
|
|
2316
|
-
return mergeSpecs(baseObj, raw);
|
|
2317
|
-
}
|
|
2318
|
-
function mergeSpecs(base, child) {
|
|
2319
|
-
const out = { ...base };
|
|
2320
|
-
for (const [k, v] of Object.entries(child)) {
|
|
2321
|
-
const b = out[k];
|
|
2322
|
-
if (Array.isArray(b) && Array.isArray(v) && (k === "masking" || k === "scenes")) {
|
|
2323
|
-
out[k] = [...b, ...v];
|
|
2324
|
-
} else if (isPlainObject(b) && isPlainObject(v)) {
|
|
2325
|
-
out[k] = mergeSpecs(b, v);
|
|
2326
|
-
} else {
|
|
2327
|
-
out[k] = v;
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
2330
|
-
return out;
|
|
2331
|
-
}
|
|
2332
|
-
function isPlainObject(v) {
|
|
2333
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2334
|
-
}
|
|
2335
|
-
function interpolate(raw, sourcePath) {
|
|
2336
|
-
if (!isPlainObject(raw)) return raw;
|
|
2337
|
-
const varsIn = isPlainObject(raw.vars) ? raw.vars : {};
|
|
2338
|
-
const vars = /* @__PURE__ */ new Map();
|
|
2339
|
-
for (const [name, value] of Object.entries(varsIn)) {
|
|
2340
|
-
vars.set(name, resolveEnv(String(value), sourcePath));
|
|
2341
|
-
}
|
|
2342
|
-
const doc = { ...raw };
|
|
2343
|
-
delete doc.vars;
|
|
2344
|
-
const seen = (s) => {
|
|
2345
|
-
const withEnv = resolveEnv(s, sourcePath);
|
|
2346
|
-
return withEnv.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, name) => {
|
|
2347
|
-
const v = vars.get(name);
|
|
2348
|
-
if (v === void 0) {
|
|
2349
|
-
const near = suggest(name) ?? [...vars.keys()].find((k) => editDistance(k, name) <= 2);
|
|
2350
|
-
throw new SpecError(
|
|
2351
|
-
`${sourcePath}: unknown variable {{${name}}}${near ? ` \u2014 did you mean {{${near}}}?` : ""} (declared vars: ${[...vars.keys()].join(", ") || "none"})`
|
|
2352
|
-
);
|
|
2353
|
-
}
|
|
2354
|
-
return v;
|
|
2355
|
-
});
|
|
2356
|
-
};
|
|
2357
|
-
const walk = (v) => {
|
|
2358
|
-
if (typeof v === "string") return seen(v);
|
|
2359
|
-
if (Array.isArray(v)) return v.map(walk);
|
|
2360
|
-
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, val]) => [k, walk(val)]));
|
|
2361
|
-
return v;
|
|
2362
|
-
};
|
|
2363
|
-
return walk(doc);
|
|
2364
|
-
}
|
|
2365
|
-
function resolveEnv(s, sourcePath) {
|
|
2366
|
-
return s.replace(/\$\{env\.([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_, name, dflt) => {
|
|
2367
|
-
const v = process.env[name];
|
|
2368
|
-
if (v !== void 0) return v;
|
|
2369
|
-
if (dflt !== void 0) return dflt;
|
|
2370
|
-
throw new SpecError(`${sourcePath}: environment variable ${name} is not set and has no default (use \${env.${name}:-fallback})`);
|
|
2371
|
-
});
|
|
2372
|
-
}
|
|
2373
|
-
function validateResolved(resolved, sourcePath, doc, lineCounter) {
|
|
2374
|
-
const result = specSchema.safeParse(resolved);
|
|
2375
|
-
if (!result.success) {
|
|
2376
|
-
const issues = result.error.issues.map((iss) => formatIssue(iss, sourcePath, doc, lineCounter));
|
|
2377
|
-
throw new SpecError(`${sourcePath}: invalid spec
|
|
2378
|
-
- ${issues.join("\n - ")}`, issues);
|
|
2379
|
-
}
|
|
2380
|
-
return result.data;
|
|
2381
|
-
}
|
|
2382
|
-
function formatIssue(iss, sourcePath, doc, lineCounter) {
|
|
2383
|
-
const where = iss.path.length ? humanPath(iss.path) : "spec";
|
|
2384
|
-
const pos = positionOf(iss.path, doc, lineCounter);
|
|
2385
|
-
const at = pos ? `${sourcePath}:${pos.line}:${pos.col} ` : "";
|
|
2386
|
-
if (iss.code === "unrecognized_keys") {
|
|
2387
|
-
const keys = iss.keys;
|
|
2388
|
-
const hints = keys.map((k) => ({ k, near: suggest(k) })).map((h) => h.near ? `"${h.k}" (did you mean "${h.near}"?)` : `"${h.k}"`).join(", ");
|
|
2389
|
-
return `${at}${where}: unknown key${keys.length > 1 ? "s" : ""} ${hints}`;
|
|
2390
|
-
}
|
|
2391
|
-
return `${at}${where}: ${iss.message}`;
|
|
2392
|
-
}
|
|
2393
|
-
function positionOf(path, doc, lineCounter) {
|
|
2394
|
-
for (let depth = path.length; depth > 0; depth--) {
|
|
2395
|
-
try {
|
|
2396
|
-
const node = doc.getIn(path.slice(0, depth), true);
|
|
2397
|
-
if (node?.range) {
|
|
2398
|
-
const pos = lineCounter.linePos(node.range[0]);
|
|
2399
|
-
return { line: pos.line, col: pos.col };
|
|
2400
|
-
}
|
|
2401
|
-
} catch {
|
|
2402
|
-
}
|
|
2896
|
+
case "press":
|
|
2897
|
+
return `press ${s.keys}`;
|
|
2898
|
+
case "expect":
|
|
2899
|
+
return `expect ${s.locator}`;
|
|
2900
|
+
default:
|
|
2901
|
+
return `${s.action} ${s.locator}`;
|
|
2403
2902
|
}
|
|
2404
|
-
return null;
|
|
2405
2903
|
}
|
|
2406
|
-
function
|
|
2407
|
-
return
|
|
2904
|
+
function capitalize(s) {
|
|
2905
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2408
2906
|
}
|
|
2409
2907
|
|
|
2908
|
+
// src/mcp/server.ts
|
|
2909
|
+
init_parse();
|
|
2910
|
+
|
|
2410
2911
|
// src/capture/executor.ts
|
|
2411
2912
|
init_schema();
|
|
2412
2913
|
init_locators();
|
|
2413
2914
|
init_playwright_driver();
|
|
2414
|
-
import { join as
|
|
2915
|
+
import { join as join3 } from "path";
|
|
2415
2916
|
|
|
2416
2917
|
// src/bundle/writer.ts
|
|
2417
2918
|
import { mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
2418
|
-
import {
|
|
2919
|
+
import { createHash as createHash2 } from "crypto";
|
|
2920
|
+
import { join as join2 } from "path";
|
|
2419
2921
|
import sharp from "sharp";
|
|
2420
2922
|
|
|
2421
2923
|
// src/bundle/hash.ts
|
|
@@ -2432,11 +2934,12 @@ function sha256File(path) {
|
|
|
2432
2934
|
}
|
|
2433
2935
|
|
|
2434
2936
|
// src/bundle/writer.ts
|
|
2937
|
+
init_log();
|
|
2435
2938
|
var BundleWriter = class {
|
|
2436
2939
|
constructor(dir, captureFormat) {
|
|
2437
2940
|
this.dir = dir;
|
|
2438
2941
|
this.captureFormat = captureFormat;
|
|
2439
|
-
this.ready = mkdir(
|
|
2942
|
+
this.ready = mkdir(join2(dir, "frames"), { recursive: true }).then(() => {
|
|
2440
2943
|
});
|
|
2441
2944
|
}
|
|
2442
2945
|
dir;
|
|
@@ -2445,14 +2948,22 @@ var BundleWriter = class {
|
|
|
2445
2948
|
pendingWrites = [];
|
|
2446
2949
|
maskSamples = [];
|
|
2447
2950
|
counter = 0;
|
|
2951
|
+
failedFrames = /* @__PURE__ */ new Set();
|
|
2952
|
+
writeError;
|
|
2448
2953
|
ready;
|
|
2449
|
-
/** Queue a frame write. `t` is capture-relative ms.
|
|
2954
|
+
/** Queue a frame write. `t` is capture-relative ms. Each frame's BYTES are hashed at write
|
|
2955
|
+
* time — the index entry carries the digest, so the framesIndex hash transitively covers
|
|
2956
|
+
* every pixel in the bundle. (Round-2 audit: filename+timestamp hashing left frame images
|
|
2957
|
+
* freely swappable under a passing attest.) */
|
|
2450
2958
|
addFrame(data, t) {
|
|
2451
2959
|
this.counter += 1;
|
|
2452
2960
|
const name = `${String(this.counter).padStart(6, "0")}.${this.captureFormat === "jpeg" ? "jpg" : "png"}`;
|
|
2453
|
-
this.frames.push({ f: name, t: round1(t) });
|
|
2961
|
+
this.frames.push({ f: name, t: round1(t), h: createHash2("sha256").update(data).digest("hex") });
|
|
2454
2962
|
this.pendingWrites.push(
|
|
2455
|
-
this.ready.then(() => writeFile2(
|
|
2963
|
+
this.ready.then(() => writeFile2(join2(this.dir, "frames", name), data)).catch((e) => {
|
|
2964
|
+
this.failedFrames.add(name);
|
|
2965
|
+
this.writeError ??= e;
|
|
2966
|
+
})
|
|
2456
2967
|
);
|
|
2457
2968
|
}
|
|
2458
2969
|
addMaskSample(sample) {
|
|
@@ -2464,21 +2975,28 @@ var BundleWriter = class {
|
|
|
2464
2975
|
async finish(meta) {
|
|
2465
2976
|
await this.ready;
|
|
2466
2977
|
await Promise.all(this.pendingWrites);
|
|
2978
|
+
if (this.failedFrames.size > 0) {
|
|
2979
|
+
this.frames = this.frames.filter((f) => !this.failedFrames.has(f.f));
|
|
2980
|
+
log.warn(`${this.failedFrames.size} frame write(s) failed (${this.writeError?.message?.split("\n")[0]}) \u2014 those frames dropped from the index`);
|
|
2981
|
+
}
|
|
2467
2982
|
this.frames.sort((a, b) => a.t - b.t);
|
|
2468
2983
|
let frameW = meta.viewport.w * meta.dpr;
|
|
2469
2984
|
let frameH = meta.viewport.h * meta.dpr;
|
|
2470
2985
|
const first = this.frames[0];
|
|
2471
2986
|
if (first) {
|
|
2472
|
-
const info = await sharp(
|
|
2987
|
+
const info = await sharp(join2(this.dir, "frames", first.f)).metadata();
|
|
2473
2988
|
if (info.width && info.height) {
|
|
2474
2989
|
frameW = info.width;
|
|
2475
2990
|
frameH = info.height;
|
|
2476
2991
|
}
|
|
2477
2992
|
}
|
|
2993
|
+
meta.events.meta.frameW = frameW;
|
|
2994
|
+
meta.events.meta.frameH = frameH;
|
|
2995
|
+
const spec = redactMaskedText(meta.spec);
|
|
2478
2996
|
const manifest = {
|
|
2479
2997
|
schema: "playhead/bundle@1",
|
|
2480
2998
|
playheadVersion: PLAYHEAD_VERSION,
|
|
2481
|
-
specHash: sha256Json(
|
|
2999
|
+
specHash: sha256Json(spec),
|
|
2482
3000
|
appUrl: meta.appUrl,
|
|
2483
3001
|
viewport: meta.viewport,
|
|
2484
3002
|
dpr: meta.dpr,
|
|
@@ -2497,15 +3015,24 @@ var BundleWriter = class {
|
|
|
2497
3015
|
...meta.failure ? { failure: meta.failure } : {}
|
|
2498
3016
|
};
|
|
2499
3017
|
await Promise.all([
|
|
2500
|
-
writeFile2(
|
|
2501
|
-
writeFile2(
|
|
2502
|
-
writeFile2(
|
|
2503
|
-
writeFile2(
|
|
2504
|
-
writeFile2(
|
|
3018
|
+
writeFile2(join2(this.dir, "frames", "index.json"), JSON.stringify(this.frames)),
|
|
3019
|
+
writeFile2(join2(this.dir, "events.json"), JSON.stringify(meta.events, null, 2)),
|
|
3020
|
+
writeFile2(join2(this.dir, "masks.json"), JSON.stringify(this.maskSamples, null, 2)),
|
|
3021
|
+
writeFile2(join2(this.dir, "spec.resolved.json"), JSON.stringify(spec, null, 2)),
|
|
3022
|
+
writeFile2(join2(this.dir, "manifest.json"), JSON.stringify(manifest, null, 2))
|
|
2505
3023
|
]);
|
|
2506
3024
|
return manifest;
|
|
2507
3025
|
}
|
|
2508
3026
|
};
|
|
3027
|
+
function redactMaskedText(spec) {
|
|
3028
|
+
const copy = JSON.parse(JSON.stringify(spec));
|
|
3029
|
+
for (const scene of copy.scenes) {
|
|
3030
|
+
for (const step of scene.steps) {
|
|
3031
|
+
if (step.action === "type" && step.mask && step.text) step.text = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
return copy;
|
|
3035
|
+
}
|
|
2509
3036
|
function round1(n) {
|
|
2510
3037
|
return Math.round(n * 10) / 10;
|
|
2511
3038
|
}
|
|
@@ -2521,7 +3048,7 @@ var TYPE_DELAY_MS = 45;
|
|
|
2521
3048
|
var STEP_BUDGET_MS = 45e3;
|
|
2522
3049
|
var CAPTURE_DEADLINE_MS = 10 * 6e4;
|
|
2523
3050
|
async function capture(spec, opts) {
|
|
2524
|
-
const bundleDir =
|
|
3051
|
+
const bundleDir = join3(opts.outDir, "capture");
|
|
2525
3052
|
const driver = opts.driver ?? new PlaywrightDriver();
|
|
2526
3053
|
const dpr = 2;
|
|
2527
3054
|
const format = opts.captureFormat ?? "jpeg";
|
|
@@ -2555,18 +3082,20 @@ async function capture(spec, opts) {
|
|
|
2555
3082
|
try {
|
|
2556
3083
|
clockOffsetMs = await driver.measureClockOffset();
|
|
2557
3084
|
t0 = Date.now();
|
|
3085
|
+
let recordEvents = !opts.fromScene;
|
|
2558
3086
|
driver.onNavigation((url, tNode) => {
|
|
2559
|
-
events.push({ type: "navigation", url, t: rel(tNode) });
|
|
3087
|
+
if (recordEvents) events.push({ type: "navigation", url, t: rel(tNode) });
|
|
2560
3088
|
});
|
|
2561
3089
|
const startFilming = () => driver.startCapture((frame) => writer.addFrame(frame.data, rel(frame.tNodeMs)), {
|
|
2562
3090
|
format,
|
|
2563
3091
|
quality: opts.quality ?? 82,
|
|
2564
3092
|
scale: dpr,
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2567
|
-
fps:
|
|
3093
|
+
// The screencast source delivers paint-driven frames up to ~30fps during motion; the
|
|
3094
|
+
// paced screenshot fallback (PLAYHEAD_CAPTURE=screenshot) tops out ~20.
|
|
3095
|
+
fps: 30
|
|
2568
3096
|
});
|
|
2569
|
-
|
|
3097
|
+
const preRoll = spec.setup.length > 0 || Boolean(opts.fromScene);
|
|
3098
|
+
if (!preRoll) await startFilming();
|
|
2570
3099
|
await driver.installMaskRules(maskRules);
|
|
2571
3100
|
await driver.goto(spec.app.url);
|
|
2572
3101
|
await applyMasksAndSample(driver, maskRules, writer, now);
|
|
@@ -2578,7 +3107,15 @@ async function capture(spec, opts) {
|
|
|
2578
3107
|
);
|
|
2579
3108
|
});
|
|
2580
3109
|
}
|
|
2581
|
-
if (!
|
|
3110
|
+
if (!preRoll) await driver.captureNow();
|
|
3111
|
+
if (spec.setup.length > 0) {
|
|
3112
|
+
log.info(`running ${spec.setup.length} setup step(s) (state only, not filmed)`);
|
|
3113
|
+
for (const [i, s] of spec.setup.entries()) {
|
|
3114
|
+
currentStepRef = `setup/${i}`;
|
|
3115
|
+
await fastForwardStep(driver, s);
|
|
3116
|
+
}
|
|
3117
|
+
currentStepRef = "";
|
|
3118
|
+
}
|
|
2582
3119
|
let steps = flattenSteps(spec);
|
|
2583
3120
|
const totalSteps = steps.length;
|
|
2584
3121
|
if (opts.fromScene) {
|
|
@@ -2591,7 +3128,10 @@ async function capture(spec, opts) {
|
|
|
2591
3128
|
}
|
|
2592
3129
|
currentStepRef = "";
|
|
2593
3130
|
steps = steps.slice(idx);
|
|
3131
|
+
}
|
|
3132
|
+
if (preRoll) {
|
|
2594
3133
|
await driver.settle(settleCfg);
|
|
3134
|
+
recordEvents = true;
|
|
2595
3135
|
await startFilming();
|
|
2596
3136
|
await driver.captureNow();
|
|
2597
3137
|
}
|
|
@@ -2601,11 +3141,20 @@ async function capture(spec, opts) {
|
|
|
2601
3141
|
throw new Error(`capture watchdog: exceeded ${CAPTURE_DEADLINE_MS / 6e4} minutes at step ${currentStepRef}`);
|
|
2602
3142
|
}
|
|
2603
3143
|
log.step(`${addressed.ordinal}/${totalSteps} ${describeStep(addressed)}`);
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
3144
|
+
let event;
|
|
3145
|
+
try {
|
|
3146
|
+
event = await withStepBudget(
|
|
3147
|
+
executeStep(driver, addressed, { rel, now }, spec.app.url),
|
|
3148
|
+
STEP_BUDGET_MS,
|
|
3149
|
+
currentStepRef
|
|
3150
|
+
);
|
|
3151
|
+
} catch (e) {
|
|
3152
|
+
if (addressed.step.optional) {
|
|
3153
|
+
log.warn(`optional step ${currentStepRef} skipped: ${e.message.split("\n")[0]}`);
|
|
3154
|
+
continue;
|
|
3155
|
+
}
|
|
3156
|
+
throw e;
|
|
3157
|
+
}
|
|
2609
3158
|
await applyMasksAndSample(driver, maskRules, writer, now);
|
|
2610
3159
|
await driver.settle(settleCfg);
|
|
2611
3160
|
const focus = "focus" in addressed.step ? addressed.step.focus : void 0;
|
|
@@ -2643,10 +3192,10 @@ async function capture(spec, opts) {
|
|
|
2643
3192
|
const url = driver.currentUrl();
|
|
2644
3193
|
const { writeFile: writeFile8 } = await import("fs/promises");
|
|
2645
3194
|
await writeFile8(
|
|
2646
|
-
|
|
3195
|
+
join3(bundleDir, "failure.json"),
|
|
2647
3196
|
JSON.stringify({ stepRef: failure.stepRef, message, url, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
2648
3197
|
);
|
|
2649
|
-
if (aria) await writeFile8(
|
|
3198
|
+
if (aria) await writeFile8(join3(bundleDir, "failure-aria.txt"), aria);
|
|
2650
3199
|
} catch {
|
|
2651
3200
|
}
|
|
2652
3201
|
throw new FlowError(
|
|
@@ -2661,7 +3210,7 @@ async function capture(spec, opts) {
|
|
|
2661
3210
|
if (consoleLog.length > 0) {
|
|
2662
3211
|
const { writeFile: writeFile8 } = await import("fs/promises");
|
|
2663
3212
|
await writeFile8(
|
|
2664
|
-
|
|
3213
|
+
join3(bundleDir, "console.json"),
|
|
2665
3214
|
JSON.stringify(consoleLog.map((c) => ({ ...c, t: rel(c.t) })), null, 2)
|
|
2666
3215
|
);
|
|
2667
3216
|
}
|
|
@@ -2713,13 +3262,15 @@ async function fastForwardStep(driver, step) {
|
|
|
2713
3262
|
break;
|
|
2714
3263
|
case "click":
|
|
2715
3264
|
case "dblclick": {
|
|
2716
|
-
|
|
2717
|
-
await driver.
|
|
3265
|
+
await driver.resolveTarget(parseLocator(step.target), T);
|
|
3266
|
+
await driver.actClick(parseLocator(step.target), { double: step.action === "dblclick", timeoutMs: T });
|
|
2718
3267
|
break;
|
|
2719
3268
|
}
|
|
2720
|
-
case "hover":
|
|
3269
|
+
case "hover": {
|
|
3270
|
+
const t = await driver.resolveTarget(parseLocator(step.target), T);
|
|
3271
|
+
await driver.moveCursor(rectCenter(t.bbox), 60);
|
|
2721
3272
|
break;
|
|
2722
|
-
|
|
3273
|
+
}
|
|
2723
3274
|
case "type": {
|
|
2724
3275
|
const t = await driver.resolveTarget(parseLocator(step.target), T);
|
|
2725
3276
|
await driver.clickAt(rectCenter(t.bbox));
|
|
@@ -2737,8 +3288,23 @@ async function fastForwardStep(driver, step) {
|
|
|
2737
3288
|
else if (step.by) await driver.scrollBy(step.by);
|
|
2738
3289
|
await driver.waitForScrollSettle(1500);
|
|
2739
3290
|
break;
|
|
3291
|
+
case "rightclick": {
|
|
3292
|
+
await driver.resolveTarget(parseLocator(step.target), T);
|
|
3293
|
+
await driver.actClick(parseLocator(step.target), { button: "right", timeoutMs: T });
|
|
3294
|
+
break;
|
|
3295
|
+
}
|
|
3296
|
+
case "upload":
|
|
3297
|
+
await driver.setInputFiles(parseLocator(step.target), step.file);
|
|
3298
|
+
break;
|
|
3299
|
+
case "drag":
|
|
3300
|
+
await driver.dragTo(parseLocator(step.target), parseLocator(step.to), T);
|
|
3301
|
+
break;
|
|
2740
3302
|
case "expect":
|
|
2741
|
-
await driver.expectState(
|
|
3303
|
+
await driver.expectState(
|
|
3304
|
+
step.target ? parseLocator(step.target) : null,
|
|
3305
|
+
{ ...step.visible !== void 0 ? { visible: step.visible } : {}, ...step.url !== void 0 ? { url: step.url } : {} },
|
|
3306
|
+
T
|
|
3307
|
+
).catch(() => {
|
|
2742
3308
|
});
|
|
2743
3309
|
break;
|
|
2744
3310
|
case "wait":
|
|
@@ -2824,6 +3390,9 @@ async function executeStep(driver, addressed, clock, appUrl) {
|
|
|
2824
3390
|
base.targetPre = pre;
|
|
2825
3391
|
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
2826
3392
|
await driver.actClick(loc, { timeoutMs: budgetMs });
|
|
3393
|
+
if (step.clear) {
|
|
3394
|
+
await driver.press("ControlOrMeta+A");
|
|
3395
|
+
}
|
|
2827
3396
|
if (step.mask) {
|
|
2828
3397
|
await driver.setTypingMask(loc);
|
|
2829
3398
|
}
|
|
@@ -2853,6 +3422,42 @@ async function executeStep(driver, addressed, clock, appUrl) {
|
|
|
2853
3422
|
base.tActionEnd = clock.now();
|
|
2854
3423
|
return base;
|
|
2855
3424
|
}
|
|
3425
|
+
case "rightclick": {
|
|
3426
|
+
base.locator = step.target;
|
|
3427
|
+
const loc = parseLocator(step.target);
|
|
3428
|
+
const pre = await driver.resolveTarget(loc, budgetMs);
|
|
3429
|
+
base.targetPre = pre;
|
|
3430
|
+
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
3431
|
+
const { tDown, tUp } = await driver.actClick(loc, { button: "right", timeoutMs: budgetMs });
|
|
3432
|
+
base.tAction = clock.rel(tDown);
|
|
3433
|
+
base.tActionEnd = clock.rel(tUp);
|
|
3434
|
+
return base;
|
|
3435
|
+
}
|
|
3436
|
+
case "upload": {
|
|
3437
|
+
base.locator = step.target;
|
|
3438
|
+
const loc = parseLocator(step.target);
|
|
3439
|
+
base.targetPre = await driver.tryMeasure(loc);
|
|
3440
|
+
if (base.targetPre) base.cursorPath = await moveToTarget(driver, base.targetPre, clock);
|
|
3441
|
+
base.tAction = clock.now();
|
|
3442
|
+
await driver.setInputFiles(loc, step.file);
|
|
3443
|
+
base.typedText = step.file.split(/[\\/]/).pop() ?? step.file;
|
|
3444
|
+
base.tActionEnd = clock.now();
|
|
3445
|
+
return base;
|
|
3446
|
+
}
|
|
3447
|
+
case "drag": {
|
|
3448
|
+
base.locator = step.target;
|
|
3449
|
+
const from = parseLocator(step.target);
|
|
3450
|
+
const to = parseLocator(step.to);
|
|
3451
|
+
const pre = await driver.resolveTarget(from, budgetMs);
|
|
3452
|
+
base.targetPre = pre;
|
|
3453
|
+
base.cursorPath = await moveToTarget(driver, pre, clock);
|
|
3454
|
+
const { tDown, tUp, path } = await driver.dragTo(from, to, budgetMs);
|
|
3455
|
+
base.cursorPath = [...base.cursorPath, ...path.map((w) => ({ ...w, t: clock.rel(w.t) }))];
|
|
3456
|
+
base.tAction = clock.rel(tDown);
|
|
3457
|
+
base.tActionEnd = clock.rel(tUp);
|
|
3458
|
+
base.targetPost = await driver.tryMeasure(to);
|
|
3459
|
+
return base;
|
|
3460
|
+
}
|
|
2856
3461
|
case "scroll": {
|
|
2857
3462
|
base.tAction = clock.now();
|
|
2858
3463
|
if (step.target) {
|
|
@@ -2869,14 +3474,22 @@ async function executeStep(driver, addressed, clock, appUrl) {
|
|
|
2869
3474
|
return base;
|
|
2870
3475
|
}
|
|
2871
3476
|
case "expect": {
|
|
2872
|
-
base.locator = step.target;
|
|
2873
|
-
const loc = parseLocator(step.target);
|
|
3477
|
+
base.locator = step.target ?? step.url ?? "";
|
|
3478
|
+
const loc = step.target ? parseLocator(step.target) : null;
|
|
2874
3479
|
await driver.expectState(
|
|
2875
3480
|
loc,
|
|
2876
|
-
{
|
|
3481
|
+
{
|
|
3482
|
+
...step.visible !== void 0 ? { visible: step.visible } : {},
|
|
3483
|
+
...step.text !== void 0 ? { text: step.text } : {},
|
|
3484
|
+
...step.count !== void 0 ? { count: step.count } : {},
|
|
3485
|
+
...step.url !== void 0 ? { url: step.url } : {},
|
|
3486
|
+
...step.value !== void 0 ? { value: step.value } : {},
|
|
3487
|
+
...step.disabled !== void 0 ? { disabled: step.disabled } : {},
|
|
3488
|
+
...step.checked !== void 0 ? { checked: step.checked } : {}
|
|
3489
|
+
},
|
|
2877
3490
|
step.timeout ?? EXPECT_TIMEOUT_MS
|
|
2878
3491
|
);
|
|
2879
|
-
base.targetPre = await driver.tryMeasure(loc);
|
|
3492
|
+
if (loc) base.targetPre = await driver.tryMeasure(loc);
|
|
2880
3493
|
base.tAction = clock.now();
|
|
2881
3494
|
base.tActionEnd = base.tAction;
|
|
2882
3495
|
return base;
|
|
@@ -2902,6 +3515,7 @@ async function moveToTarget(driver, target, clock) {
|
|
|
2902
3515
|
const to = rectCenter(target.bbox);
|
|
2903
3516
|
const travel = clamp(250 + dist(driver.cursorPos(), to) * 0.5, 300, 700);
|
|
2904
3517
|
const waypoints = await driver.moveCursor(to, travel);
|
|
3518
|
+
await driver.captureNow();
|
|
2905
3519
|
return waypoints.map((w) => ({ x: round12(w.x), y: round12(w.y), t: round12(clock.rel(w.t)) }));
|
|
2906
3520
|
}
|
|
2907
3521
|
async function ensurePostActionFrame(driver, tAction, rel) {
|
|
@@ -2948,13 +3562,13 @@ function round12(n) {
|
|
|
2948
3562
|
|
|
2949
3563
|
// src/bundle/reader.ts
|
|
2950
3564
|
import { readFile as readFile2 } from "fs/promises";
|
|
2951
|
-
import { join as
|
|
3565
|
+
import { join as join4 } from "path";
|
|
2952
3566
|
async function openBundle(dir) {
|
|
2953
3567
|
const [manifestRaw, eventsRaw, framesRaw, masksRaw] = await Promise.all([
|
|
2954
|
-
readFile2(
|
|
2955
|
-
readFile2(
|
|
2956
|
-
readFile2(
|
|
2957
|
-
readFile2(
|
|
3568
|
+
readFile2(join4(dir, "manifest.json"), "utf8"),
|
|
3569
|
+
readFile2(join4(dir, "events.json"), "utf8"),
|
|
3570
|
+
readFile2(join4(dir, "frames", "index.json"), "utf8"),
|
|
3571
|
+
readFile2(join4(dir, "masks.json"), "utf8").catch(() => "[]")
|
|
2958
3572
|
]);
|
|
2959
3573
|
const manifest = JSON.parse(manifestRaw);
|
|
2960
3574
|
if (manifest.schema !== "playhead/bundle@1") {
|
|
@@ -2979,17 +3593,17 @@ function frameIndexForTime(frames, tMs) {
|
|
|
2979
3593
|
return lo;
|
|
2980
3594
|
}
|
|
2981
3595
|
function framePath(bundle, index) {
|
|
2982
|
-
return
|
|
3596
|
+
return join4(bundle.dir, "frames", bundle.frames[index].f);
|
|
2983
3597
|
}
|
|
2984
3598
|
|
|
2985
3599
|
// src/compose/index.ts
|
|
2986
3600
|
init_schema();
|
|
2987
|
-
import { join as
|
|
3601
|
+
import { join as join8 } from "path";
|
|
2988
3602
|
import { writeFile as writeFile4 } from "fs/promises";
|
|
2989
3603
|
|
|
2990
3604
|
// src/theme/index.ts
|
|
2991
|
-
import { existsSync } from "fs";
|
|
2992
|
-
import { join as
|
|
3605
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3606
|
+
import { join as join5, dirname as dirname3 } from "path";
|
|
2993
3607
|
import { createRequire as createRequire2 } from "module";
|
|
2994
3608
|
import { GlobalFonts } from "@napi-rs/canvas";
|
|
2995
3609
|
var DEFAULT_THEME = {
|
|
@@ -3023,7 +3637,7 @@ function registerFonts() {
|
|
|
3023
3637
|
const require5 = createRequire2(import.meta.url);
|
|
3024
3638
|
let pkgDir;
|
|
3025
3639
|
try {
|
|
3026
|
-
pkgDir =
|
|
3640
|
+
pkgDir = dirname3(require5.resolve("@expo-google-fonts/inter/package.json"));
|
|
3027
3641
|
} catch {
|
|
3028
3642
|
throw new Error("Font package @expo-google-fonts/inter not found \u2014 run npm install");
|
|
3029
3643
|
}
|
|
@@ -3034,8 +3648,8 @@ function registerFonts() {
|
|
|
3034
3648
|
["700Bold/Inter_700Bold.ttf", "Inter Bold"]
|
|
3035
3649
|
];
|
|
3036
3650
|
for (const [rel, family] of faces) {
|
|
3037
|
-
const p =
|
|
3038
|
-
if (
|
|
3651
|
+
const p = join5(pkgDir, rel);
|
|
3652
|
+
if (existsSync2(p)) GlobalFonts.registerFromPath(p, family);
|
|
3039
3653
|
}
|
|
3040
3654
|
}
|
|
3041
3655
|
|
|
@@ -3043,17 +3657,24 @@ function registerFonts() {
|
|
|
3043
3657
|
function stageContent(profile) {
|
|
3044
3658
|
return profile.stage?.content ?? { x: 0, y: 0, w: profile.width, h: profile.height };
|
|
3045
3659
|
}
|
|
3046
|
-
function withStage(profile, chrome) {
|
|
3660
|
+
function withStage(profile, chrome, viewportAspect) {
|
|
3047
3661
|
const { width, height } = profile;
|
|
3662
|
+
const va = viewportAspect ?? width / height;
|
|
3663
|
+
const ui = Math.min(width, height) / 1080;
|
|
3048
3664
|
const marginY = Math.round(height * 0.055);
|
|
3049
|
-
const
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3665
|
+
const marginX = Math.round(width * 0.05);
|
|
3666
|
+
const chromeH = chrome ? Math.round(44 * ui) : 0;
|
|
3667
|
+
let contentH = height - 2 * marginY - chromeH;
|
|
3668
|
+
let contentW = Math.round(contentH * va);
|
|
3669
|
+
if (contentW > width - 2 * marginX) {
|
|
3670
|
+
contentW = width - 2 * marginX;
|
|
3671
|
+
contentH = Math.round(contentW / va);
|
|
3672
|
+
}
|
|
3052
3673
|
const x = Math.round((width - contentW) / 2);
|
|
3053
|
-
const y =
|
|
3674
|
+
const y = Math.round((height - contentH - chromeH) / 2) + chromeH;
|
|
3054
3675
|
return {
|
|
3055
3676
|
...profile,
|
|
3056
|
-
stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 *
|
|
3677
|
+
stage: { content: { x, y, w: contentW, h: contentH }, chromeH, radius: Math.round(14 * ui) }
|
|
3057
3678
|
};
|
|
3058
3679
|
}
|
|
3059
3680
|
var PROFILE_16x9 = {
|
|
@@ -3063,7 +3684,8 @@ var PROFILE_16x9 = {
|
|
|
3063
3684
|
safeArea: { top: 54, right: 96, bottom: 160, left: 96 },
|
|
3064
3685
|
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
3065
3686
|
minFocusPx: 110,
|
|
3066
|
-
captionMaxWidth: 1040
|
|
3687
|
+
captionMaxWidth: 1040,
|
|
3688
|
+
uiScale: 1
|
|
3067
3689
|
};
|
|
3068
3690
|
var PROFILE_9x16 = {
|
|
3069
3691
|
width: 1080,
|
|
@@ -3072,7 +3694,8 @@ var PROFILE_9x16 = {
|
|
|
3072
3694
|
safeArea: { top: 230, right: 56, bottom: 320, left: 56 },
|
|
3073
3695
|
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
3074
3696
|
minFocusPx: 96,
|
|
3075
|
-
captionMaxWidth: 980
|
|
3697
|
+
captionMaxWidth: 980,
|
|
3698
|
+
uiScale: 1
|
|
3076
3699
|
};
|
|
3077
3700
|
var PROFILE_1x1 = {
|
|
3078
3701
|
width: 1080,
|
|
@@ -3081,7 +3704,8 @@ var PROFILE_1x1 = {
|
|
|
3081
3704
|
safeArea: { top: 80, right: 72, bottom: 200, left: 72 },
|
|
3082
3705
|
zoomQuantums: [1, 1.15, 1.3, 1.5, 1.7, 2],
|
|
3083
3706
|
minFocusPx: 100,
|
|
3084
|
-
captionMaxWidth: 940
|
|
3707
|
+
captionMaxWidth: 940,
|
|
3708
|
+
uiScale: 1
|
|
3085
3709
|
};
|
|
3086
3710
|
function profileForAspect(aspect, resolution, fps) {
|
|
3087
3711
|
const base = aspect === "9:16" ? PROFILE_9x16 : aspect === "1:1" ? PROFILE_1x1 : PROFILE_16x9;
|
|
@@ -3098,7 +3722,8 @@ function profileForAspect(aspect, resolution, fps) {
|
|
|
3098
3722
|
left: Math.round(base.safeArea.left * scaleX),
|
|
3099
3723
|
right: Math.round(base.safeArea.right * scaleX)
|
|
3100
3724
|
},
|
|
3101
|
-
captionMaxWidth: Math.round(base.captionMaxWidth * scaleX)
|
|
3725
|
+
captionMaxWidth: Math.round(base.captionMaxWidth * scaleX),
|
|
3726
|
+
uiScale: Math.min(resolution.w, resolution.h) / 1080
|
|
3102
3727
|
};
|
|
3103
3728
|
}
|
|
3104
3729
|
function actReactWindows(step) {
|
|
@@ -3186,6 +3811,9 @@ function pacingConfig(mode, kind) {
|
|
|
3186
3811
|
type: 450 * factor,
|
|
3187
3812
|
press: 450 * factor,
|
|
3188
3813
|
select: 600 * factor,
|
|
3814
|
+
rightclick: 700 * factor,
|
|
3815
|
+
upload: 800 * factor,
|
|
3816
|
+
drag: 700 * factor,
|
|
3189
3817
|
scroll: 350 * factor,
|
|
3190
3818
|
expect: 800 * factor,
|
|
3191
3819
|
wait: 150
|
|
@@ -3210,10 +3838,37 @@ function buildTimeline(log2, cfg, audioMs) {
|
|
|
3210
3838
|
const srcEnd = Math.max(e.tSettled, e.tActionEnd, e.tStart + 1);
|
|
3211
3839
|
const spanSrc = srcEnd - e.tStart;
|
|
3212
3840
|
let spanOut = spanSrc;
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3841
|
+
let outBeat;
|
|
3842
|
+
if (e.kind === "type" && spanSrc > cfg.maxTypeOutMs) {
|
|
3843
|
+
const HEAD_MS = 1200;
|
|
3844
|
+
const TAIL_MS = 700;
|
|
3845
|
+
const headSrc = Math.min(HEAD_MS, spanSrc * 0.4);
|
|
3846
|
+
const tailSrc = Math.min(TAIL_MS, spanSrc * 0.25);
|
|
3847
|
+
const midSrc = spanSrc - headSrc - tailSrc;
|
|
3848
|
+
const midOut = Math.max(250, cfg.maxTypeOutMs - headSrc - tailSrc);
|
|
3849
|
+
segments.push({ kind: "source", outStart: out, outEnd: out + headSrc, srcStart: e.tStart, srcEnd: e.tStart + headSrc });
|
|
3850
|
+
segments.push({
|
|
3851
|
+
kind: "source",
|
|
3852
|
+
outStart: out + headSrc,
|
|
3853
|
+
outEnd: out + headSrc + midOut,
|
|
3854
|
+
srcStart: e.tStart + headSrc,
|
|
3855
|
+
srcEnd: e.tStart + headSrc + midSrc
|
|
3856
|
+
});
|
|
3857
|
+
segments.push({
|
|
3858
|
+
kind: "source",
|
|
3859
|
+
outStart: out + headSrc + midOut,
|
|
3860
|
+
outEnd: out + headSrc + midOut + tailSrc,
|
|
3861
|
+
srcStart: srcEnd - tailSrc,
|
|
3862
|
+
srcEnd
|
|
3863
|
+
});
|
|
3864
|
+
spanOut = headSrc + midOut + tailSrc;
|
|
3865
|
+
outBeat = out + Math.min(e.tAction - e.tStart, headSrc);
|
|
3866
|
+
out += spanOut;
|
|
3867
|
+
} else {
|
|
3868
|
+
segments.push({ kind: "source", outStart: out, outEnd: out + spanOut, srcStart: e.tStart, srcEnd });
|
|
3869
|
+
outBeat = out + (e.tAction - e.tStart) * (spanOut / spanSrc);
|
|
3870
|
+
out += spanOut;
|
|
3871
|
+
}
|
|
3217
3872
|
let hold = cfg.holds[e.kind];
|
|
3218
3873
|
if (out + hold - stepOutStart < cfg.minStepMs) hold = cfg.minStepMs - (out - stepOutStart);
|
|
3219
3874
|
const aud = audioMs?.get(`${e.sceneId}/${e.stepIndex}`);
|
|
@@ -3242,6 +3897,7 @@ function buildTimeline(log2, cfg, audioMs) {
|
|
|
3242
3897
|
...e.focus ? { focus: e.focus } : {},
|
|
3243
3898
|
...e.shot ? { shot: e.shot } : {},
|
|
3244
3899
|
...e.focusTarget ? { focusRectVp: e.focusTarget.bbox } : {},
|
|
3900
|
+
...e.targetPre && !e.targetPost ? { targetGone: true } : {},
|
|
3245
3901
|
outStart: stepOutStart,
|
|
3246
3902
|
outBeat,
|
|
3247
3903
|
outEnd: out,
|
|
@@ -3268,6 +3924,18 @@ function sampleSource(segments, tOut) {
|
|
|
3268
3924
|
}
|
|
3269
3925
|
}
|
|
3270
3926
|
}
|
|
3927
|
+
function outTimeForSrc(segments, tSrc) {
|
|
3928
|
+
for (const seg of segments) {
|
|
3929
|
+
if (seg.kind === "source" && tSrc >= seg.srcStart && tSrc <= seg.srcEnd) {
|
|
3930
|
+
const u = (tSrc - seg.srcStart) / Math.max(1e-6, seg.srcEnd - seg.srcStart);
|
|
3931
|
+
return seg.outStart + u * (seg.outEnd - seg.outStart);
|
|
3932
|
+
}
|
|
3933
|
+
if (seg.kind === "freeze" && Math.abs(seg.srcAt - tSrc) < 400) {
|
|
3934
|
+
return seg.outStart + (seg.outEnd - seg.outStart) / 2;
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return null;
|
|
3938
|
+
}
|
|
3271
3939
|
function segmentAt(segments, tOut) {
|
|
3272
3940
|
let lo = 0;
|
|
3273
3941
|
let hi = segments.length - 1;
|
|
@@ -3306,13 +3974,48 @@ function sampleCamera(keyframes, tOut) {
|
|
|
3306
3974
|
}
|
|
3307
3975
|
const a = keyframes[lo];
|
|
3308
3976
|
const b = keyframes[hi];
|
|
3309
|
-
const
|
|
3310
|
-
const
|
|
3311
|
-
return {
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3977
|
+
const dt = Math.max(1e-6, b.tOut - a.tOut);
|
|
3978
|
+
const u = (tOut - a.tOut) / dt;
|
|
3979
|
+
if (sameState(a.state, b.state)) return { ...a.state };
|
|
3980
|
+
const va = [a.state.cx, a.state.cy, Math.log(a.state.zoom)];
|
|
3981
|
+
const vb = [b.state.cx, b.state.cy, Math.log(b.state.zoom)];
|
|
3982
|
+
const TENSION = 0.5;
|
|
3983
|
+
const ma = tangentAt(keyframes, lo, TENSION);
|
|
3984
|
+
const mb = tangentAt(keyframes, hi, TENSION);
|
|
3985
|
+
const isolated = ma.every((m) => m === 0) && mb.every((m) => m === 0);
|
|
3986
|
+
if (isolated) {
|
|
3987
|
+
const e = cubicBezier(b.ease, u);
|
|
3988
|
+
return {
|
|
3989
|
+
cx: va[0] + (vb[0] - va[0]) * e,
|
|
3990
|
+
cy: va[1] + (vb[1] - va[1]) * e,
|
|
3991
|
+
zoom: Math.exp(va[2] + (vb[2] - va[2]) * e)
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3994
|
+
const u2 = u * u;
|
|
3995
|
+
const u3 = u2 * u;
|
|
3996
|
+
const h00 = 2 * u3 - 3 * u2 + 1;
|
|
3997
|
+
const h10 = u3 - 2 * u2 + u;
|
|
3998
|
+
const h01 = -2 * u3 + 3 * u2;
|
|
3999
|
+
const h11 = u3 - u2;
|
|
4000
|
+
const out = [0, 0, 0];
|
|
4001
|
+
for (let c = 0; c < 3; c++) {
|
|
4002
|
+
out[c] = h00 * va[c] + h10 * dt * ma[c] + h01 * vb[c] + h11 * dt * mb[c];
|
|
4003
|
+
}
|
|
4004
|
+
return { cx: out[0], cy: out[1], zoom: Math.exp(out[2]) };
|
|
4005
|
+
}
|
|
4006
|
+
function tangentAt(keyframes, i, tension) {
|
|
4007
|
+
const cur = keyframes[i];
|
|
4008
|
+
const prev = i > 0 ? keyframes[i - 1] : null;
|
|
4009
|
+
const next = i < keyframes.length - 1 ? keyframes[i + 1] : null;
|
|
4010
|
+
if (!prev || !next) return [0, 0, 0];
|
|
4011
|
+
if (sameState(prev.state, cur.state) || sameState(cur.state, next.state)) return [0, 0, 0];
|
|
4012
|
+
const dt = Math.max(1e-6, next.tOut - prev.tOut);
|
|
4013
|
+
const vp = [prev.state.cx, prev.state.cy, Math.log(prev.state.zoom)];
|
|
4014
|
+
const vn = [next.state.cx, next.state.cy, Math.log(next.state.zoom)];
|
|
4015
|
+
return [0, 1, 2].map((c) => tension * (vn[c] - vp[c]) / dt);
|
|
4016
|
+
}
|
|
4017
|
+
function sameState(a, b) {
|
|
4018
|
+
return Math.abs(a.cx - b.cx) < 0.5 && Math.abs(a.cy - b.cy) < 0.5 && Math.abs(a.zoom - b.zoom) < 1e-3;
|
|
3316
4019
|
}
|
|
3317
4020
|
|
|
3318
4021
|
// src/compose/camera/planner.ts
|
|
@@ -3339,7 +4042,8 @@ function planCamera(steps, log2, profile, durationMs, opts) {
|
|
|
3339
4042
|
if (quantums.length === 0) quantums.push(1);
|
|
3340
4043
|
const navTimes = navigationEvents(log2).map((n) => n.t);
|
|
3341
4044
|
const shots = groupShots(steps, navTimes, plane2, durationMs);
|
|
3342
|
-
|
|
4045
|
+
const maxAttempts = Math.max(8, shots.length * (quantums.length + 1));
|
|
4046
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3343
4047
|
for (const shot2 of shots) shot2.state = shotState(shot2, quantums, plane2, profile, emphasis);
|
|
3344
4048
|
const keyframes = emitKeyframes(shots, plane2, durationMs);
|
|
3345
4049
|
const violation = checkConstraints(keyframes, steps, plane2, profile);
|
|
@@ -3356,7 +4060,9 @@ function planCamera(steps, log2, profile, durationMs, opts) {
|
|
|
3356
4060
|
fallbacks.push(`${violation.stepRef}: reduced shot zoom to satisfy framing constraints`);
|
|
3357
4061
|
}
|
|
3358
4062
|
}
|
|
4063
|
+
for (const shot of shots) shot.wide = true;
|
|
3359
4064
|
for (const shot of shots) shot.state = shotState(shot, quantums, plane2, profile, emphasis);
|
|
4065
|
+
fallbacks.push("camera planner exhausted its attempt budget \u2014 all shots forced wide (check for conflicting focus hints)");
|
|
3360
4066
|
return { keyframes: emitKeyframes(shots, plane2, durationMs), fallbacks };
|
|
3361
4067
|
}
|
|
3362
4068
|
function groupShots(steps, navTimes, plane2, durationMs) {
|
|
@@ -3431,6 +4137,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
|
|
|
3431
4137
|
continue;
|
|
3432
4138
|
}
|
|
3433
4139
|
}
|
|
4140
|
+
const arriveBeat = step.kind === "goto" && rect ? Math.min(step.outEnd - 400, step.outBeat + Math.max(200, step.srcSettled - step.srcAction) + 150) : step.outBeat;
|
|
3434
4141
|
current = {
|
|
3435
4142
|
stepRefs: [step.stepRef],
|
|
3436
4143
|
sceneId: step.sceneId,
|
|
@@ -3438,7 +4145,7 @@ function groupShots(steps, navTimes, plane2, durationMs) {
|
|
|
3438
4145
|
minTargetDim: rect ? Math.min(rect.w, rect.h) : Math.min(plane2.vpW, plane2.vpH),
|
|
3439
4146
|
wide,
|
|
3440
4147
|
outStart: step.outStart,
|
|
3441
|
-
firstBeat:
|
|
4148
|
+
firstBeat: arriveBeat,
|
|
3442
4149
|
outEnd: step.outEnd,
|
|
3443
4150
|
zoomIndex: Number.MAX_SAFE_INTEGER
|
|
3444
4151
|
// resolved in shotState
|
|
@@ -3671,6 +4378,12 @@ function writeCaption(event, texts) {
|
|
|
3671
4378
|
return label ? `Enter the ${lowerFirst(label)}` : "Enter a value";
|
|
3672
4379
|
case "press":
|
|
3673
4380
|
return `Press ${event.locator ?? "the key"}`;
|
|
4381
|
+
case "rightclick":
|
|
4382
|
+
return label ? `Right-click \u201C${label}\u201D` : "Right-click the element";
|
|
4383
|
+
case "upload":
|
|
4384
|
+
return label ? `Upload a file to ${lowerFirst(label)}` : "Upload the file";
|
|
4385
|
+
case "drag":
|
|
4386
|
+
return label ? `Drag \u201C${label}\u201D into place` : "Drag the item into place";
|
|
3674
4387
|
case "select":
|
|
3675
4388
|
return label && event.typedText ? `Choose \u201C${event.typedText}\u201D under ${label}` : label ? `Choose an option under ${label}` : "Choose an option";
|
|
3676
4389
|
case "goto":
|
|
@@ -3816,29 +4529,46 @@ function buildOverlays(steps, log2, theme, profile, title, subtitle, titleCardEn
|
|
|
3816
4529
|
const text = kind.captionStyle === "factual" ? `Step ${number} \u2014 ${lowerFirst2(base)}` : base;
|
|
3817
4530
|
const fontFor = (px) => `${px}px Inter Medium`;
|
|
3818
4531
|
const showBadge = kind.numberedCaptions && kind.captionStyle !== "factual";
|
|
3819
|
-
const
|
|
4532
|
+
const ui = profile.uiScale;
|
|
4533
|
+
const padX = Math.round(CARD_PAD_X * ui);
|
|
4534
|
+
const padY = Math.round(CARD_PAD_Y * ui);
|
|
4535
|
+
const badgeFontPx = Math.max(11, Math.round(15 * ui));
|
|
3820
4536
|
const badgeText = `${number}/${total}`;
|
|
3821
|
-
const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + 20 : 0;
|
|
3822
|
-
const badgeGap = showBadge ? BADGE_GAP : 0;
|
|
3823
|
-
const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) -
|
|
3824
|
-
const wrap = wrapText(
|
|
4537
|
+
const badgeW = showBadge ? Math.ceil(measureText(badgeText, `600 ${badgeFontPx}px Inter SemiBold`)) + Math.round(20 * ui) : 0;
|
|
4538
|
+
const badgeGap = showBadge ? Math.round(BADGE_GAP * ui) : 0;
|
|
4539
|
+
const maxTextWidth = Math.min(profile.captionMaxWidth, profile.width - 2 * profile.safeArea.left) - padX * 2 - badgeW - badgeGap;
|
|
4540
|
+
const wrap = wrapText(
|
|
4541
|
+
text,
|
|
4542
|
+
fontFor,
|
|
4543
|
+
maxTextWidth,
|
|
4544
|
+
Math.round(theme.captionFontPx * ui),
|
|
4545
|
+
Math.round(theme.captionMinFontPx * ui),
|
|
4546
|
+
theme.captionMaxLines
|
|
4547
|
+
);
|
|
3825
4548
|
if (wrap.truncated) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "truncated" });
|
|
3826
4549
|
else if (wrap.shrunk) diagnostics.overflows.push({ stepRef: step.stepRef, text, action: "shrunk" });
|
|
3827
4550
|
const lineH = Math.round(wrap.fontPx * 1.35);
|
|
3828
|
-
const cardW =
|
|
3829
|
-
const cardH =
|
|
4551
|
+
const cardW = padX * 2 + badgeW + badgeGap + Math.ceil(wrap.widest);
|
|
4552
|
+
const cardH = padY * 2 + wrap.lines.length * lineH;
|
|
3830
4553
|
const x = (profile.width - cardW) / 2;
|
|
3831
|
-
let y = theme.captionPosition === "bottom" ? profile.height - CARD_BOTTOM_MARGIN - cardH : profile.safeArea.top;
|
|
4554
|
+
let y = theme.captionPosition === "bottom" ? profile.height - Math.round(CARD_BOTTOM_MARGIN * ui) - cardH : profile.safeArea.top;
|
|
3832
4555
|
if (camera && theme.captionPosition === "bottom") {
|
|
3833
|
-
const
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
4556
|
+
const content = stageContent(profile);
|
|
4557
|
+
const capPlane = { vpW: log2.meta.viewport.w, vpH: log2.meta.viewport.h, outW: content.w, outH: content.h };
|
|
4558
|
+
const capBox = { x, y, w: cardW, h: cardH };
|
|
4559
|
+
const handoff = actReactWindows(step);
|
|
4560
|
+
const probes = handoff ? [
|
|
4561
|
+
{ rect: handoff.act.rect, t: Math.min(step.outBeat + 250, handoff.act.to) },
|
|
4562
|
+
{ rect: handoff.react.rect, t: handoff.react.from + 50 }
|
|
4563
|
+
] : step.focusRectVp ?? step.targetRectVp ? [{ rect: step.focusRectVp ?? step.targetRectVp, t: Math.min(step.outBeat + 250, step.outEnd) }] : [];
|
|
4564
|
+
for (const probe of probes) {
|
|
4565
|
+
const cam = sampleCamera(camera, probe.t);
|
|
4566
|
+
const proj = projectRect(probe.rect, cam, capPlane);
|
|
3839
4567
|
const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
|
|
3840
|
-
|
|
3841
|
-
|
|
4568
|
+
if (overlaps(projFrame, capBox)) {
|
|
4569
|
+
y = profile.safeArea.top;
|
|
4570
|
+
break;
|
|
4571
|
+
}
|
|
3842
4572
|
}
|
|
3843
4573
|
}
|
|
3844
4574
|
const next = captioned[i + 1];
|
|
@@ -3967,19 +4697,38 @@ function drawOverlays(ctx2, overlays, tOut, theme, profile) {
|
|
|
3967
4697
|
if (bottomCaptions.length > 0) {
|
|
3968
4698
|
const a = Math.max(...bottomCaptions.map((o) => captionAlpha(o, tOut)));
|
|
3969
4699
|
if (a > 0) {
|
|
3970
|
-
const
|
|
4700
|
+
const scrimH = Math.round(SCRIM_H * profile.uiScale);
|
|
4701
|
+
const g = ctx2.createLinearGradient(0, profile.height - scrimH, 0, profile.height);
|
|
3971
4702
|
g.addColorStop(0, "rgba(8,10,16,0)");
|
|
3972
4703
|
g.addColorStop(1, `rgba(8,10,16,${(0.55 * a).toFixed(3)})`);
|
|
3973
4704
|
ctx2.fillStyle = g;
|
|
3974
|
-
ctx2.fillRect(0, profile.height -
|
|
4705
|
+
ctx2.fillRect(0, profile.height - scrimH, profile.width, scrimH);
|
|
3975
4706
|
}
|
|
3976
4707
|
}
|
|
4708
|
+
const activeCaps = overlays.filter(
|
|
4709
|
+
(o) => o.kind === "caption" && tOut >= o.tStart && tOut <= o.tEnd && captionAlpha(o, tOut) > 0
|
|
4710
|
+
);
|
|
4711
|
+
if (activeCaps.length === 2 && activeCaps[0].box.y === activeCaps[1].box.y) {
|
|
4712
|
+
const [a, b] = activeCaps[0].tStart <= activeCaps[1].tStart ? [activeCaps[0], activeCaps[1]] : [activeCaps[1], activeCaps[0]];
|
|
4713
|
+
const u = captionAlpha(b, tOut);
|
|
4714
|
+
const box = {
|
|
4715
|
+
x: a.box.x + (b.box.x - a.box.x) * u,
|
|
4716
|
+
y: a.box.y,
|
|
4717
|
+
w: a.box.w + (b.box.w - a.box.w) * u,
|
|
4718
|
+
h: Math.max(a.box.h, b.box.h)
|
|
4719
|
+
};
|
|
4720
|
+
drawCaptionCard(ctx2, box, 1, theme);
|
|
4721
|
+
drawCaptionContent(ctx2, a, box, 1 - u, theme);
|
|
4722
|
+
drawCaptionContent(ctx2, b, box, u, theme);
|
|
4723
|
+
} else {
|
|
4724
|
+
for (const o of activeCaps) drawCaption(ctx2, o, tOut, theme);
|
|
4725
|
+
}
|
|
3977
4726
|
for (const o of overlays) {
|
|
3978
4727
|
if (tOut < o.tStart || tOut > o.tEnd) continue;
|
|
3979
4728
|
switch (o.kind) {
|
|
3980
4729
|
case "caption":
|
|
3981
|
-
drawCaption(ctx2, o, tOut, theme);
|
|
3982
4730
|
break;
|
|
4731
|
+
// handled by the grouped pass above
|
|
3983
4732
|
case "dip": {
|
|
3984
4733
|
const u = (tOut - o.tStart) / Math.max(1, o.tEnd - o.tStart);
|
|
3985
4734
|
ctx2.save();
|
|
@@ -4005,13 +4754,14 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
|
|
|
4005
4754
|
const ms = Math.max(0, tOut - zeroAtMs);
|
|
4006
4755
|
const s = Math.floor(ms / 1e3);
|
|
4007
4756
|
const label = `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}.${String(Math.floor(ms % 1e3 / 100))}`;
|
|
4008
|
-
const
|
|
4757
|
+
const ui = profile.uiScale;
|
|
4758
|
+
const font = `600 ${Math.round(26 * ui)}px Inter SemiBold`;
|
|
4009
4759
|
ctx2.save();
|
|
4010
4760
|
ctx2.font = font;
|
|
4011
4761
|
const textW = ctx2.measureText(label).width;
|
|
4012
|
-
const padX = 16;
|
|
4013
|
-
const w = textW + padX * 2 + 30;
|
|
4014
|
-
const h = 44;
|
|
4762
|
+
const padX = 16 * ui;
|
|
4763
|
+
const w = textW + padX * 2 + 30 * ui;
|
|
4764
|
+
const h = 44 * ui;
|
|
4015
4765
|
const x = profile.width - profile.safeArea.right - w;
|
|
4016
4766
|
const y = profile.safeArea.top;
|
|
4017
4767
|
ctx2.fillStyle = "rgba(18,22,31,0.82)";
|
|
@@ -4019,13 +4769,13 @@ function drawElapsedClock(ctx2, zeroAtMs, tOut, theme, profile) {
|
|
|
4019
4769
|
ctx2.fill();
|
|
4020
4770
|
ctx2.fillStyle = "#ef4444";
|
|
4021
4771
|
ctx2.beginPath();
|
|
4022
|
-
ctx2.arc(x + padX + 6, y + h / 2, 6, 0, Math.PI * 2);
|
|
4772
|
+
ctx2.arc(x + padX + 6 * ui, y + h / 2, 6 * ui, 0, Math.PI * 2);
|
|
4023
4773
|
ctx2.fill();
|
|
4024
4774
|
ctx2.fillStyle = "#ffffff";
|
|
4025
4775
|
ctx2.textAlign = "left";
|
|
4026
4776
|
ctx2.textBaseline = "middle";
|
|
4027
4777
|
ctx2.font = font;
|
|
4028
|
-
ctx2.fillText(label, x + padX + 24, y + h / 2 + 1);
|
|
4778
|
+
ctx2.fillText(label, x + padX + 24 * ui, y + h / 2 + 1);
|
|
4029
4779
|
ctx2.restore();
|
|
4030
4780
|
}
|
|
4031
4781
|
function drawChapterLabel(ctx2, o, tOut, theme, profile) {
|
|
@@ -4033,23 +4783,24 @@ function drawChapterLabel(ctx2, o, tOut, theme, profile) {
|
|
|
4033
4783
|
if (alpha <= 0) return;
|
|
4034
4784
|
ctx2.save();
|
|
4035
4785
|
ctx2.globalAlpha = alpha;
|
|
4036
|
-
const
|
|
4786
|
+
const ui = profile.uiScale;
|
|
4787
|
+
const font = `700 ${Math.round(30 * ui)}px Inter Bold`;
|
|
4037
4788
|
ctx2.font = font;
|
|
4038
4789
|
const textW = ctx2.measureText(o.title).width;
|
|
4039
|
-
const barW = textW + 96;
|
|
4040
|
-
const barH = 56;
|
|
4790
|
+
const barW = textW + 96 * ui;
|
|
4791
|
+
const barH = 56 * ui;
|
|
4041
4792
|
const x = (profile.width - barW) / 2;
|
|
4042
4793
|
const y = profile.stage ? profile.stage.content.y + 14 : profile.safeArea.top + 8;
|
|
4043
4794
|
ctx2.fillStyle = theme.accent;
|
|
4044
4795
|
roundRect(ctx2, x, y, barW, barH, 12);
|
|
4045
4796
|
ctx2.fill();
|
|
4046
4797
|
ctx2.fillStyle = "#ffffff";
|
|
4047
|
-
ctx2.font =
|
|
4798
|
+
ctx2.font = `600 ${Math.round(16 * ui)}px Inter SemiBold`;
|
|
4048
4799
|
ctx2.textAlign = "left";
|
|
4049
4800
|
ctx2.textBaseline = "middle";
|
|
4050
|
-
ctx2.fillText(String(o.ordinal), x + 22, y + barH / 2 + 1);
|
|
4801
|
+
ctx2.fillText(String(o.ordinal), x + 22 * ui, y + barH / 2 + 1);
|
|
4051
4802
|
ctx2.font = font;
|
|
4052
|
-
ctx2.fillText(o.title, x + 48, y + barH / 2 + 1);
|
|
4803
|
+
ctx2.fillText(o.title, x + 48 * ui, y + barH / 2 + 1);
|
|
4053
4804
|
ctx2.restore();
|
|
4054
4805
|
}
|
|
4055
4806
|
function captionAlpha(o, tOut) {
|
|
@@ -4058,23 +4809,36 @@ function captionAlpha(o, tOut) {
|
|
|
4058
4809
|
function drawCaption(ctx2, o, tOut, theme) {
|
|
4059
4810
|
const alpha = captionAlpha(o, tOut);
|
|
4060
4811
|
if (alpha <= 0) return;
|
|
4812
|
+
ctx2.save();
|
|
4813
|
+
ctx2.globalAlpha = alpha;
|
|
4814
|
+
drawCaptionCard(ctx2, o.box, 1, theme);
|
|
4815
|
+
ctx2.restore();
|
|
4816
|
+
drawCaptionContent(ctx2, o, o.box, alpha, theme);
|
|
4817
|
+
}
|
|
4818
|
+
function drawCaptionCard(ctx2, box, alpha, theme) {
|
|
4061
4819
|
ctx2.save();
|
|
4062
4820
|
ctx2.globalAlpha = alpha;
|
|
4063
4821
|
ctx2.shadowColor = "rgba(0,0,0,0.30)";
|
|
4064
4822
|
ctx2.shadowBlur = 18;
|
|
4065
4823
|
ctx2.shadowOffsetY = 4;
|
|
4066
4824
|
ctx2.fillStyle = theme.surface;
|
|
4067
|
-
roundRect(ctx2,
|
|
4825
|
+
roundRect(ctx2, box.x, box.y, box.w, box.h, theme.radius);
|
|
4068
4826
|
ctx2.fill();
|
|
4069
|
-
ctx2.
|
|
4827
|
+
ctx2.restore();
|
|
4828
|
+
}
|
|
4829
|
+
function drawCaptionContent(ctx2, o, box, alpha, theme) {
|
|
4830
|
+
if (alpha <= 0) return;
|
|
4831
|
+
ctx2.save();
|
|
4832
|
+
ctx2.globalAlpha = alpha;
|
|
4070
4833
|
let badgeW = 0;
|
|
4071
|
-
const bx =
|
|
4834
|
+
const bx = box.x + 26;
|
|
4072
4835
|
if (o.showBadge) {
|
|
4073
4836
|
const badgeText = `${o.ordinal}/${o.totalSteps}`;
|
|
4074
|
-
const
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
const
|
|
4837
|
+
const bScale = o.fontPx / 36;
|
|
4838
|
+
const badgeFont = `600 ${Math.max(11, Math.round(15 * bScale))}px Inter SemiBold`;
|
|
4839
|
+
badgeW = Math.ceil(measureText(badgeText, badgeFont)) + Math.round(20 * bScale);
|
|
4840
|
+
const badgeH = Math.round(26 * bScale);
|
|
4841
|
+
const by = box.y + box.h / 2 - badgeH / 2;
|
|
4078
4842
|
ctx2.fillStyle = theme.accent;
|
|
4079
4843
|
roundRect(ctx2, bx, by, badgeW, badgeH, 13);
|
|
4080
4844
|
ctx2.fill();
|
|
@@ -4086,7 +4850,7 @@ function drawCaption(ctx2, o, tOut, theme) {
|
|
|
4086
4850
|
}
|
|
4087
4851
|
const lineH = Math.round(o.fontPx * 1.35);
|
|
4088
4852
|
const textX = o.showBadge ? bx + badgeW + 14 : bx;
|
|
4089
|
-
const textTop =
|
|
4853
|
+
const textTop = box.y + (box.h - o.lines.length * lineH) / 2;
|
|
4090
4854
|
ctx2.fillStyle = theme.onSurface;
|
|
4091
4855
|
ctx2.font = `${o.fontPx}px Inter Medium`;
|
|
4092
4856
|
ctx2.textAlign = "left";
|
|
@@ -4110,16 +4874,17 @@ function drawFailureCard(ctx2, card, tOut, theme, profile) {
|
|
|
4110
4874
|
ctx2.fillStyle = "#ef4444";
|
|
4111
4875
|
roundRect(ctx2, width / 2 - 32, height / 2 - 150, 64, 8, 4);
|
|
4112
4876
|
ctx2.fill();
|
|
4877
|
+
const uiF = profile.uiScale;
|
|
4113
4878
|
ctx2.fillStyle = "#ffffff";
|
|
4114
|
-
ctx2.font =
|
|
4879
|
+
ctx2.font = `${Math.round(52 * uiF)}px Inter Bold`;
|
|
4115
4880
|
ctx2.textAlign = "center";
|
|
4116
4881
|
ctx2.textBaseline = "middle";
|
|
4117
4882
|
ctx2.fillText("Flow failed", width / 2, height / 2 - 70);
|
|
4118
4883
|
ctx2.fillStyle = "#fca5a5";
|
|
4119
|
-
ctx2.font = `600
|
|
4884
|
+
ctx2.font = `600 ${Math.round(30 * uiF)}px Inter SemiBold`;
|
|
4120
4885
|
ctx2.fillText(`at step ${card.stepRef}`, width / 2, height / 2 - 12);
|
|
4121
4886
|
ctx2.fillStyle = "rgba(255,255,255,0.75)";
|
|
4122
|
-
ctx2.font =
|
|
4887
|
+
ctx2.font = `${Math.round(24 * uiF)}px Inter`;
|
|
4123
4888
|
const maxW = Math.min(1200, width - 200);
|
|
4124
4889
|
const words = card.message.replace(/\s+/g, " ").split(" ");
|
|
4125
4890
|
const lines = [];
|
|
@@ -4160,14 +4925,15 @@ function drawTitleCard(ctx2, card, tOut, theme, profile) {
|
|
|
4160
4925
|
ctx2.fill();
|
|
4161
4926
|
ctx2.globalAlpha = title.alpha;
|
|
4162
4927
|
ctx2.fillStyle = "#ffffff";
|
|
4163
|
-
|
|
4928
|
+
const uiT = profile.uiScale;
|
|
4929
|
+
ctx2.font = `${Math.round(56 * uiT)}px Inter Bold`;
|
|
4164
4930
|
ctx2.textAlign = "center";
|
|
4165
4931
|
ctx2.textBaseline = "middle";
|
|
4166
4932
|
ctx2.fillText(card.title, width / 2, height / 2 - 20 + title.rise);
|
|
4167
4933
|
if (card.subtitle) {
|
|
4168
4934
|
ctx2.globalAlpha = sub.alpha;
|
|
4169
4935
|
ctx2.fillStyle = theme.onSurfaceDim;
|
|
4170
|
-
ctx2.font =
|
|
4936
|
+
ctx2.font = `${Math.round(26 * uiT)}px Inter`;
|
|
4171
4937
|
ctx2.fillText(card.subtitle, width / 2, height / 2 + 44 + sub.rise);
|
|
4172
4938
|
}
|
|
4173
4939
|
ctx2.restore();
|
|
@@ -4190,7 +4956,8 @@ var FrameStore = class {
|
|
|
4190
4956
|
async frameForTime(tMs) {
|
|
4191
4957
|
const idx = frameIndexForTime(this.bundle.frames, tMs);
|
|
4192
4958
|
const canvas = await this.get(idx);
|
|
4193
|
-
if (idx + 1 < this.bundle.frames.length)
|
|
4959
|
+
if (idx + 1 < this.bundle.frames.length) this.get(idx + 1).catch(() => {
|
|
4960
|
+
});
|
|
4194
4961
|
return canvas;
|
|
4195
4962
|
}
|
|
4196
4963
|
/**
|
|
@@ -4217,6 +4984,7 @@ var FrameStore = class {
|
|
|
4217
4984
|
return hit;
|
|
4218
4985
|
}
|
|
4219
4986
|
const promise = this.decode(idx);
|
|
4987
|
+
promise.catch(() => this.cache.delete(idx));
|
|
4220
4988
|
this.cache.set(idx, promise);
|
|
4221
4989
|
while (this.cache.size > LRU_SIZE) {
|
|
4222
4990
|
const oldest = this.cache.keys().next().value;
|
|
@@ -4342,12 +5110,19 @@ var Encoder = class {
|
|
|
4342
5110
|
this.child.on("error", reject);
|
|
4343
5111
|
this.child.on("close", (code) => resolve2(code ?? -1));
|
|
4344
5112
|
});
|
|
5113
|
+
this.child.stdin.on("error", () => {
|
|
5114
|
+
});
|
|
4345
5115
|
}
|
|
4346
5116
|
async write(rgba) {
|
|
4347
5117
|
const stdin = this.child.stdin;
|
|
4348
5118
|
if (!stdin.writable) throw new Error(`ffmpeg closed early: ${this.stderrTail.join("")}`);
|
|
4349
5119
|
if (!stdin.write(rgba)) {
|
|
4350
|
-
await
|
|
5120
|
+
await Promise.race([
|
|
5121
|
+
once(stdin, "drain"),
|
|
5122
|
+
this.exit.then((code) => {
|
|
5123
|
+
throw new Error(`ffmpeg exited (code ${code}) while the encoder awaited drain: ${this.stderrTail.join("")}`);
|
|
5124
|
+
})
|
|
5125
|
+
]);
|
|
4351
5126
|
}
|
|
4352
5127
|
}
|
|
4353
5128
|
async finish() {
|
|
@@ -4364,6 +5139,7 @@ init_log();
|
|
|
4364
5139
|
var SPOTLIGHT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
|
|
4365
5140
|
var SPOTLIGHT_LEAD_MS = 200;
|
|
4366
5141
|
var SPOTLIGHT_TAIL_MS = 650;
|
|
5142
|
+
var SPOTLIGHT_TAIL_GONE_MS = 160;
|
|
4367
5143
|
var SPOTLIGHT_MAX_DIM = 0.34;
|
|
4368
5144
|
async function renderVideo(bundle, manifest, outPath) {
|
|
4369
5145
|
registerFonts();
|
|
@@ -4395,7 +5171,7 @@ async function renderVideo(bundle, manifest, outPath) {
|
|
|
4395
5171
|
);
|
|
4396
5172
|
const transitions = manifest.transitions ?? [];
|
|
4397
5173
|
const spotlightSteps = manifest.steps.filter(
|
|
4398
|
-
(s) => SPOTLIGHT_KINDS.has(s.kind) && (s.
|
|
5174
|
+
(s) => SPOTLIGHT_KINDS.has(s.kind) && (s.targetRectVp ?? s.focusRectVp)
|
|
4399
5175
|
);
|
|
4400
5176
|
const start = Date.now();
|
|
4401
5177
|
for (let i = 0; i < manifest.totalFrames; i++) {
|
|
@@ -4436,7 +5212,8 @@ async function renderVideo(bundle, manifest, outPath) {
|
|
|
4436
5212
|
ctx2.globalAlpha = 1;
|
|
4437
5213
|
};
|
|
4438
5214
|
const pair = await store.framePairForTime(srcAt);
|
|
4439
|
-
|
|
5215
|
+
const rate = segment.kind === "source" ? (segment.srcEnd - segment.srcStart) / Math.max(1, segment.outEnd - segment.outStart) : Infinity;
|
|
5216
|
+
if (segment.kind === "source" && rate < 1.5 && pair.b && pair.mix > 0.12 && pair.gapMs < 400) {
|
|
4440
5217
|
drawSrc(pair.a, 1);
|
|
4441
5218
|
drawSrc(pair.b, pair.mix);
|
|
4442
5219
|
} else {
|
|
@@ -4452,7 +5229,7 @@ async function renderVideo(bundle, manifest, outPath) {
|
|
|
4452
5229
|
}
|
|
4453
5230
|
const spot = activeSpotlight(spotlightSteps, tOut);
|
|
4454
5231
|
if (spot) {
|
|
4455
|
-
const rect = spot.step.
|
|
5232
|
+
const rect = spot.step.targetRectVp ?? spot.step.focusRectVp;
|
|
4456
5233
|
const proj = projectRect(rect, cam, plane2);
|
|
4457
5234
|
drawSpotlight(spotCtx, content, proj, spot.alpha);
|
|
4458
5235
|
ctx2.drawImage(spotCanvas, content.x, content.y);
|
|
@@ -4548,11 +5325,12 @@ function displayUrl(appUrl) {
|
|
|
4548
5325
|
}
|
|
4549
5326
|
function activeSpotlight(steps, tOut) {
|
|
4550
5327
|
for (const step of steps) {
|
|
5328
|
+
const tail = step.targetGone ? SPOTLIGHT_TAIL_GONE_MS : SPOTLIGHT_TAIL_MS;
|
|
4551
5329
|
const from = step.outBeat - SPOTLIGHT_LEAD_MS;
|
|
4552
|
-
const to = step.outBeat +
|
|
5330
|
+
const to = step.outBeat + tail;
|
|
4553
5331
|
if (tOut < from || tOut > to) continue;
|
|
4554
5332
|
const rampIn = clamp((tOut - from) / 180, 0, 1);
|
|
4555
|
-
const rampOut = clamp((to - tOut) / 250, 0, 1);
|
|
5333
|
+
const rampOut = clamp((to - tOut) / Math.min(250, tail), 0, 1);
|
|
4556
5334
|
return { step, alpha: SPOTLIGHT_MAX_DIM * Math.min(rampIn, rampOut) };
|
|
4557
5335
|
}
|
|
4558
5336
|
return null;
|
|
@@ -4575,7 +5353,152 @@ function cursorGlyphAt(steps, tOut) {
|
|
|
4575
5353
|
for (const s of steps) {
|
|
4576
5354
|
if (s.kind === "type" && tOut >= s.outStart && tOut <= s.outEnd) return "ibeam";
|
|
4577
5355
|
}
|
|
4578
|
-
return "arrow";
|
|
5356
|
+
return "arrow";
|
|
5357
|
+
}
|
|
5358
|
+
|
|
5359
|
+
// src/verify/media.ts
|
|
5360
|
+
import sharp3 from "sharp";
|
|
5361
|
+
async function extractGrayFrames(videoPath, fps, w, h) {
|
|
5362
|
+
const res = await runBinaryRaw(ffmpegPath(), [
|
|
5363
|
+
"-hide_banner",
|
|
5364
|
+
"-loglevel",
|
|
5365
|
+
"error",
|
|
5366
|
+
"-i",
|
|
5367
|
+
videoPath,
|
|
5368
|
+
"-vf",
|
|
5369
|
+
`fps=${fps},scale=${w}:${h}`,
|
|
5370
|
+
"-f",
|
|
5371
|
+
"rawvideo",
|
|
5372
|
+
"-pix_fmt",
|
|
5373
|
+
"gray",
|
|
5374
|
+
"pipe:1"
|
|
5375
|
+
]);
|
|
5376
|
+
if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
|
|
5377
|
+
const frameSize = w * h;
|
|
5378
|
+
const frames = [];
|
|
5379
|
+
for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
|
|
5380
|
+
frames.push(res.stdout.subarray(off, off + frameSize));
|
|
5381
|
+
}
|
|
5382
|
+
return { frames, intervalMs: 1e3 / fps };
|
|
5383
|
+
}
|
|
5384
|
+
async function extractFrameAt(videoPath, tMs) {
|
|
5385
|
+
const res = await runBinaryRaw(ffmpegPath(), [
|
|
5386
|
+
"-hide_banner",
|
|
5387
|
+
"-loglevel",
|
|
5388
|
+
"error",
|
|
5389
|
+
"-ss",
|
|
5390
|
+
(Math.max(0, tMs) / 1e3).toFixed(3),
|
|
5391
|
+
"-i",
|
|
5392
|
+
videoPath,
|
|
5393
|
+
"-frames:v",
|
|
5394
|
+
"1",
|
|
5395
|
+
"-f",
|
|
5396
|
+
"image2pipe",
|
|
5397
|
+
"-vcodec",
|
|
5398
|
+
"png",
|
|
5399
|
+
"pipe:1"
|
|
5400
|
+
]);
|
|
5401
|
+
if (res.code !== 0 || res.stdout.length === 0) {
|
|
5402
|
+
throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
|
|
5403
|
+
}
|
|
5404
|
+
return res.stdout;
|
|
5405
|
+
}
|
|
5406
|
+
async function probeVideo(videoPath) {
|
|
5407
|
+
const res = await runBinary(ffprobePath(), [
|
|
5408
|
+
"-v",
|
|
5409
|
+
"error",
|
|
5410
|
+
"-print_format",
|
|
5411
|
+
"json",
|
|
5412
|
+
"-show_format",
|
|
5413
|
+
"-show_streams",
|
|
5414
|
+
videoPath
|
|
5415
|
+
]);
|
|
5416
|
+
if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
|
|
5417
|
+
const json = JSON.parse(res.stdout);
|
|
5418
|
+
const v = json.streams?.find((s) => s.codec_type === "video");
|
|
5419
|
+
if (!v) throw new Error("no video stream found");
|
|
5420
|
+
const [num, den] = v.avg_frame_rate.split("/").map(Number);
|
|
5421
|
+
return {
|
|
5422
|
+
codec: v.codec_name,
|
|
5423
|
+
width: v.width,
|
|
5424
|
+
height: v.height,
|
|
5425
|
+
durationSec: Number(json.format?.duration ?? 0),
|
|
5426
|
+
fps: den ? num / den : 0
|
|
5427
|
+
};
|
|
5428
|
+
}
|
|
5429
|
+
function meanAbsDiff(a, b) {
|
|
5430
|
+
const n = Math.min(a.length, b.length);
|
|
5431
|
+
let sum = 0;
|
|
5432
|
+
for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
|
|
5433
|
+
return sum / n;
|
|
5434
|
+
}
|
|
5435
|
+
function variance(a) {
|
|
5436
|
+
let sum = 0;
|
|
5437
|
+
for (let i = 0; i < a.length; i++) sum += a[i];
|
|
5438
|
+
const mean = sum / a.length;
|
|
5439
|
+
let v = 0;
|
|
5440
|
+
for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
|
|
5441
|
+
return v / a.length;
|
|
5442
|
+
}
|
|
5443
|
+
async function sourceFrameGray(bundle, tMs, width) {
|
|
5444
|
+
const idx = frameIndexForTime(bundle.frames, tMs);
|
|
5445
|
+
const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
5446
|
+
return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
|
|
5447
|
+
}
|
|
5448
|
+
async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
|
|
5449
|
+
let idx = frameIndexForTime(bundle.frames, tMs);
|
|
5450
|
+
if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
|
|
5451
|
+
const meta = await sharp3(framePath(bundle, idx)).metadata();
|
|
5452
|
+
const fw = meta.width ?? 0;
|
|
5453
|
+
const fh = meta.height ?? 0;
|
|
5454
|
+
const x = Math.max(0, Math.round(region.x));
|
|
5455
|
+
const y = Math.max(0, Math.round(region.y));
|
|
5456
|
+
const w = Math.min(Math.round(region.w), fw - x);
|
|
5457
|
+
const h = Math.min(Math.round(region.h), fh - y);
|
|
5458
|
+
if (w < 4 || h < 4) return null;
|
|
5459
|
+
let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
|
|
5460
|
+
if (!color) img = img.grayscale();
|
|
5461
|
+
const { data } = await img.raw().toBuffer({ resolveWithObject: true });
|
|
5462
|
+
return data;
|
|
5463
|
+
}
|
|
5464
|
+
async function regionGray48(input, rect) {
|
|
5465
|
+
try {
|
|
5466
|
+
const img = sharp3(input);
|
|
5467
|
+
const meta = await img.metadata();
|
|
5468
|
+
const W = meta.width ?? 0;
|
|
5469
|
+
const H = meta.height ?? 0;
|
|
5470
|
+
const left = Math.max(0, Math.round(rect.x));
|
|
5471
|
+
const top = Math.max(0, Math.round(rect.y));
|
|
5472
|
+
const width = Math.min(W - left, Math.round(rect.w));
|
|
5473
|
+
const height = Math.min(H - top, Math.round(rect.h));
|
|
5474
|
+
if (width < 8 || height < 8) return null;
|
|
5475
|
+
return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
|
|
5476
|
+
} catch {
|
|
5477
|
+
return null;
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5480
|
+
function normalizedCorrelation(a, b) {
|
|
5481
|
+
const n = Math.min(a.length, b.length);
|
|
5482
|
+
let ma = 0;
|
|
5483
|
+
let mb = 0;
|
|
5484
|
+
for (let i = 0; i < n; i++) {
|
|
5485
|
+
ma += a[i];
|
|
5486
|
+
mb += b[i];
|
|
5487
|
+
}
|
|
5488
|
+
ma /= n;
|
|
5489
|
+
mb /= n;
|
|
5490
|
+
let num = 0;
|
|
5491
|
+
let da = 0;
|
|
5492
|
+
let db = 0;
|
|
5493
|
+
for (let i = 0; i < n; i++) {
|
|
5494
|
+
const xa = a[i] - ma;
|
|
5495
|
+
const xb = b[i] - mb;
|
|
5496
|
+
num += xa * xb;
|
|
5497
|
+
da += xa * xa;
|
|
5498
|
+
db += xb * xb;
|
|
5499
|
+
}
|
|
5500
|
+
if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
|
|
5501
|
+
return num / Math.sqrt(da * db);
|
|
4579
5502
|
}
|
|
4580
5503
|
|
|
4581
5504
|
// src/compose/index.ts
|
|
@@ -4586,9 +5509,9 @@ init_schema();
|
|
|
4586
5509
|
import { execFile } from "child_process";
|
|
4587
5510
|
import { promisify } from "util";
|
|
4588
5511
|
import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
4589
|
-
import { existsSync as
|
|
4590
|
-
import { join as
|
|
4591
|
-
import { createHash as
|
|
5512
|
+
import { existsSync as existsSync3 } from "fs";
|
|
5513
|
+
import { join as join6 } from "path";
|
|
5514
|
+
import { createHash as createHash3 } from "crypto";
|
|
4592
5515
|
import { createRequire as createRequire4 } from "module";
|
|
4593
5516
|
var require3 = createRequire4(import.meta.url);
|
|
4594
5517
|
var ffprobePath2 = require3("ffprobe-static").path;
|
|
@@ -4682,9 +5605,9 @@ async function synthesizeNarration(lines, opts, cacheDir) {
|
|
|
4682
5605
|
const ext = provider.name === "kokoro" ? "wav" : "aiff";
|
|
4683
5606
|
const out = /* @__PURE__ */ new Map();
|
|
4684
5607
|
for (const { stepRef, text } of lines) {
|
|
4685
|
-
const key =
|
|
4686
|
-
const file =
|
|
4687
|
-
if (!
|
|
5608
|
+
const key = createHash3("sha256").update(`${provider.name}|${voice}|${rate}|${text}`).digest("hex").slice(0, 16);
|
|
5609
|
+
const file = join6(cacheDir, `${key}.${ext}`);
|
|
5610
|
+
if (!existsSync3(file)) await provider.synth(text, file);
|
|
4688
5611
|
out.set(stepRef, { stepRef, file, durationMs: await probeDurationMs(file) });
|
|
4689
5612
|
}
|
|
4690
5613
|
return out;
|
|
@@ -4694,8 +5617,8 @@ async function synthesizeNarration(lines, opts, cacheDir) {
|
|
|
4694
5617
|
import { execFile as execFile2 } from "child_process";
|
|
4695
5618
|
import { promisify as promisify2 } from "util";
|
|
4696
5619
|
import { rename, mkdir as mkdir3 } from "fs/promises";
|
|
4697
|
-
import { existsSync as
|
|
4698
|
-
import { join as
|
|
5620
|
+
import { existsSync as existsSync4 } from "fs";
|
|
5621
|
+
import { join as join7 } from "path";
|
|
4699
5622
|
import { createRequire as createRequire5 } from "module";
|
|
4700
5623
|
var require4 = createRequire5(import.meta.url);
|
|
4701
5624
|
var ffmpegPath2 = require4("ffmpeg-static");
|
|
@@ -4703,8 +5626,9 @@ var exec2 = promisify2(execFile2);
|
|
|
4703
5626
|
async function mixAudio(videoPath, steps, durationMs, opts) {
|
|
4704
5627
|
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);
|
|
4705
5628
|
const beats = opts.sfxBeats ?? [];
|
|
5629
|
+
const keyBeats = opts.keyBeats ?? [];
|
|
4706
5630
|
const hasMusic = !!opts.music;
|
|
4707
|
-
if (placed.length === 0 && beats.length === 0 && !hasMusic) return [];
|
|
5631
|
+
if (placed.length === 0 && beats.length === 0 && keyBeats.length === 0 && !hasMusic) return [];
|
|
4708
5632
|
const durSec = (durationMs / 1e3).toFixed(3);
|
|
4709
5633
|
const args = ["-y", "-loglevel", "error", "-i", videoPath];
|
|
4710
5634
|
const inputs = [];
|
|
@@ -4742,6 +5666,21 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
|
|
|
4742
5666
|
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[sfx]`);
|
|
4743
5667
|
inputs.push("[sfx]");
|
|
4744
5668
|
}
|
|
5669
|
+
if (keyBeats.length > 0) {
|
|
5670
|
+
const key = await ensureKeySample(opts.cacheDir);
|
|
5671
|
+
args.push("-i", key);
|
|
5672
|
+
const idx = inputIdx++;
|
|
5673
|
+
const splits = keyBeats.map((_, i) => `[k${i}]`).join("");
|
|
5674
|
+
filters.push(`[${idx}]aformat=sample_rates=44100:channel_layouts=stereo,asplit=${keyBeats.length}${splits}`);
|
|
5675
|
+
const labels = [];
|
|
5676
|
+
keyBeats.forEach((t, i) => {
|
|
5677
|
+
const at = Math.max(0, Math.round(t));
|
|
5678
|
+
filters.push(`[k${i}]adelay=${at}|${at}[kk${i}]`);
|
|
5679
|
+
labels.push(`[kk${i}]`);
|
|
5680
|
+
});
|
|
5681
|
+
filters.push(`${labels.join("")}amix=inputs=${labels.length}:normalize=0:duration=longest[keys]`);
|
|
5682
|
+
inputs.push("[keys]");
|
|
5683
|
+
}
|
|
4745
5684
|
if (opts.music) {
|
|
4746
5685
|
args.push("-stream_loop", "-1", "-i", opts.music.file);
|
|
4747
5686
|
const idx = inputIdx++;
|
|
@@ -4785,10 +5724,28 @@ async function mixAudio(videoPath, steps, durationMs, opts) {
|
|
|
4785
5724
|
await rename(tmp, videoPath);
|
|
4786
5725
|
return placed.map((p) => ({ stepRef: p.clip.stepRef, tStart: p.start, durMs: p.clip.durationMs }));
|
|
4787
5726
|
}
|
|
5727
|
+
async function ensureKeySample(cacheDir) {
|
|
5728
|
+
await mkdir3(cacheDir, { recursive: true });
|
|
5729
|
+
const file = join7(cacheDir, "key.wav");
|
|
5730
|
+
if (existsSync4(file)) return file;
|
|
5731
|
+
await exec2(ffmpegPath2, [
|
|
5732
|
+
"-y",
|
|
5733
|
+
"-loglevel",
|
|
5734
|
+
"error",
|
|
5735
|
+
"-f",
|
|
5736
|
+
"lavfi",
|
|
5737
|
+
"-i",
|
|
5738
|
+
"sine=frequency=3400:duration=0.022",
|
|
5739
|
+
"-filter_complex",
|
|
5740
|
+
"[0]volume=0.12,afade=t=in:d=0.002,afade=t=out:st=0.008:d=0.014,aformat=sample_rates=44100:channel_layouts=stereo",
|
|
5741
|
+
file
|
|
5742
|
+
]);
|
|
5743
|
+
return file;
|
|
5744
|
+
}
|
|
4788
5745
|
async function ensureClickSample(cacheDir) {
|
|
4789
5746
|
await mkdir3(cacheDir, { recursive: true });
|
|
4790
|
-
const file =
|
|
4791
|
-
if (
|
|
5747
|
+
const file = join7(cacheDir, "click.wav");
|
|
5748
|
+
if (existsSync4(file)) return file;
|
|
4792
5749
|
await exec2(ffmpegPath2, [
|
|
4793
5750
|
"-y",
|
|
4794
5751
|
"-loglevel",
|
|
@@ -4827,7 +5784,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
|
|
|
4827
5784
|
const resolvedTheme = theme ?? resolveTheme(spec);
|
|
4828
5785
|
const kind = kindProfile(spec.output.kind);
|
|
4829
5786
|
let prof = profile ?? profileForAspect(spec.output.aspect, resolveResolution(spec), spec.output.fps);
|
|
4830
|
-
if (kind.stage && !prof.stage) prof = withStage(prof, true);
|
|
5787
|
+
if (kind.stage && !prof.stage) prof = withStage(prof, true, bundle.manifest.viewport.w / bundle.manifest.viewport.h);
|
|
4831
5788
|
const diagnostics = { overflows: [], cameraFallbacks: [], clockWarnings: [] };
|
|
4832
5789
|
const cfg = pacingConfig(spec.output.pacing ?? kind.pacing, kind);
|
|
4833
5790
|
const dwell = spec.output.dwellScale;
|
|
@@ -4838,7 +5795,7 @@ function plan(bundle, spec, theme, profile, audioMs) {
|
|
|
4838
5795
|
}
|
|
4839
5796
|
const timeline = buildTimeline(bundle.events, cfg, audioMs);
|
|
4840
5797
|
const END_CARD_MS = 2400;
|
|
4841
|
-
if (spec.output.endCard) {
|
|
5798
|
+
if (spec.output.endCard && !bundle.manifest.partial) {
|
|
4842
5799
|
const endStart = timeline.durationMs;
|
|
4843
5800
|
timeline.segments.push({ kind: "card", outStart: endStart, outEnd: endStart + END_CARD_MS, cardId: "end" });
|
|
4844
5801
|
timeline.durationMs += END_CARD_MS;
|
|
@@ -4865,8 +5822,9 @@ function plan(bundle, spec, theme, profile, audioMs) {
|
|
|
4865
5822
|
const prev = timeline.steps[i - 1];
|
|
4866
5823
|
const cur = timeline.steps[i];
|
|
4867
5824
|
if (cur.sceneId !== prev.sceneId) {
|
|
4868
|
-
const tStart =
|
|
4869
|
-
|
|
5825
|
+
const tStart = prev.outEnd;
|
|
5826
|
+
const tEnd = Math.min(cur.outStart + 120, tStart + 340);
|
|
5827
|
+
transitions.push({ tStart, tEnd: Math.max(tEnd, tStart + 200), srcFrom: Math.max(0, prev.srcSettled - 1) });
|
|
4870
5828
|
}
|
|
4871
5829
|
}
|
|
4872
5830
|
const subtitle = spec.subtitle ?? safeHost(bundle.manifest.appUrl);
|
|
@@ -4929,46 +5887,109 @@ function plan(bundle, spec, theme, profile, audioMs) {
|
|
|
4929
5887
|
bundleHashes: bundle.manifest.hashes
|
|
4930
5888
|
};
|
|
4931
5889
|
}
|
|
5890
|
+
var BLINK_DIFF_MIN = 25;
|
|
5891
|
+
async function excludeTransientPreActionFrames(bundle) {
|
|
5892
|
+
const notes = [];
|
|
5893
|
+
const dpr = bundle.manifest.dpr;
|
|
5894
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
5895
|
+
for (const e of actionEvents(bundle.events)) {
|
|
5896
|
+
if (e.kind !== "click" && e.kind !== "dblclick") continue;
|
|
5897
|
+
if (!e.targetPre || !e.cursorPath || e.cursorPath.length === 0) continue;
|
|
5898
|
+
const tArrive = e.cursorPath[e.cursorPath.length - 1].t;
|
|
5899
|
+
if (!(tArrive < e.tAction)) continue;
|
|
5900
|
+
const iArrive = frameIndexForTime(bundle.frames, tArrive);
|
|
5901
|
+
const iBeat = frameIndexForTime(bundle.frames, e.tAction);
|
|
5902
|
+
if (iBeat <= iArrive) continue;
|
|
5903
|
+
const b = e.targetPre.bbox;
|
|
5904
|
+
const region = { x: b.x * dpr, y: b.y * dpr, w: b.w * dpr, h: b.h * dpr };
|
|
5905
|
+
const ref = await sourceRegionGray(bundle, bundle.frames[iArrive].t, region);
|
|
5906
|
+
if (!ref) continue;
|
|
5907
|
+
for (let i = iArrive + 1; i <= iBeat; i++) {
|
|
5908
|
+
const cand = await sourceRegionGray(bundle, bundle.frames[i].t, region);
|
|
5909
|
+
if (!cand || cand.length !== ref.length) continue;
|
|
5910
|
+
if (meanAbsDiff(ref, cand) > BLINK_DIFF_MIN) {
|
|
5911
|
+
excluded.add(i);
|
|
5912
|
+
notes.push(
|
|
5913
|
+
`${e.sceneId}/${e.stepIndex}: frame ${bundle.frames[i].f} (t=${Math.round(bundle.frames[i].t)}) is a pre-click transient (target region contradicts the arrival frame) \u2014 excluded from composition`
|
|
5914
|
+
);
|
|
5915
|
+
}
|
|
5916
|
+
}
|
|
5917
|
+
}
|
|
5918
|
+
if (excluded.size === 0) return { bundle, notes };
|
|
5919
|
+
return { bundle: { ...bundle, frames: bundle.frames.filter((_, i) => !excluded.has(i)) }, notes };
|
|
5920
|
+
}
|
|
5921
|
+
var JUMP_DIFF_MIN = 18;
|
|
5922
|
+
var JUMP_DISSOLVE_MS = 360;
|
|
5923
|
+
async function smoothJumpCuts(bundle, manifest) {
|
|
5924
|
+
for (const s of manifest.steps) {
|
|
5925
|
+
if (s.kind !== "click" && s.kind !== "dblclick" && s.kind !== "press" && s.kind !== "select") continue;
|
|
5926
|
+
const before = await sourceFrameGray(bundle, Math.max(0, s.srcAction - 30), 320);
|
|
5927
|
+
const after = await sourceFrameGray(bundle, Math.min(s.srcAction + 700, s.srcSettled), 320);
|
|
5928
|
+
if (after.frameT <= before.frameT || after.data.length !== before.data.length) continue;
|
|
5929
|
+
if (meanAbsDiff(before.data, after.data) < JUMP_DIFF_MIN) continue;
|
|
5930
|
+
const tStart = s.outBeat + 60;
|
|
5931
|
+
const tEnd = Math.min(tStart + JUMP_DISSOLVE_MS, s.outEnd);
|
|
5932
|
+
if ((manifest.transitions ?? []).some((w) => tStart <= w.tEnd && tEnd >= w.tStart)) continue;
|
|
5933
|
+
manifest.transitions = manifest.transitions ?? [];
|
|
5934
|
+
manifest.transitions.push({ tStart, tEnd, srcFrom: before.frameT });
|
|
5935
|
+
}
|
|
5936
|
+
}
|
|
4932
5937
|
async function compose(bundle, spec, opts) {
|
|
4933
5938
|
const audioCfg = spec.output.audio;
|
|
4934
5939
|
let narration;
|
|
4935
5940
|
let audioMs;
|
|
5941
|
+
let narrationDegraded;
|
|
5942
|
+
const voiceLabel = audioCfg?.voice ?? (audioCfg?.provider === "kokoro" ? "heart" : "Samantha");
|
|
4936
5943
|
if (audioCfg && audioCfg.narration === "tts") {
|
|
4937
5944
|
const texts = specStepTexts(spec);
|
|
4938
5945
|
const lines = actionEvents(bundle.events).map((e) => {
|
|
4939
5946
|
const ref = `${e.sceneId}/${e.stepIndex}`;
|
|
4940
5947
|
return { stepRef: ref, text: writeNarration(e, texts.get(ref)) };
|
|
4941
5948
|
}).filter((l) => typeof l.text === "string" && l.text.length > 0);
|
|
4942
|
-
log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${
|
|
5949
|
+
log.info(`synthesizing narration: ${lines.length} lines (${audioCfg.provider}, voice ${voiceLabel})`);
|
|
4943
5950
|
try {
|
|
4944
|
-
narration = await synthesizeNarration(lines, audioCfg,
|
|
5951
|
+
narration = await synthesizeNarration(lines, audioCfg, join8(opts.outDir, "narration"));
|
|
4945
5952
|
audioMs = new Map([...narration].map(([k, v]) => [k, v.durationMs]));
|
|
4946
5953
|
} catch (e) {
|
|
4947
|
-
|
|
5954
|
+
narrationDegraded = e.message.split("\n")[0] ?? "TTS unavailable";
|
|
5955
|
+
log.warn(`narration unavailable on this host (${narrationDegraded}) \u2014 rendering WITHOUT voice; the audio check will FAIL`);
|
|
4948
5956
|
narration = void 0;
|
|
4949
5957
|
audioMs = void 0;
|
|
4950
5958
|
}
|
|
4951
5959
|
}
|
|
5960
|
+
const transient = await excludeTransientPreActionFrames(bundle);
|
|
5961
|
+
bundle = transient.bundle;
|
|
4952
5962
|
const manifest = plan(bundle, spec, opts.theme, opts.profile, audioMs);
|
|
5963
|
+
manifest.diagnostics.clockWarnings.push(...transient.notes);
|
|
5964
|
+
await smoothJumpCuts(bundle, manifest);
|
|
4953
5965
|
for (const w of manifest.diagnostics.clockWarnings) log.warn(w);
|
|
4954
5966
|
for (const f of manifest.diagnostics.cameraFallbacks) log.warn(f);
|
|
4955
|
-
const videoPath =
|
|
5967
|
+
const videoPath = join8(opts.outDir, opts.fileName ?? "out.mp4");
|
|
4956
5968
|
log.info(
|
|
4957
5969
|
`composing ${(manifest.durationMs / 1e3).toFixed(1)}s (${manifest.totalFrames} frames @ ${manifest.profile.fps}fps, ${manifest.profile.width}x${manifest.profile.height})`
|
|
4958
5970
|
);
|
|
4959
5971
|
await renderVideo(bundle, manifest, videoPath);
|
|
4960
5972
|
if (audioCfg && (narration || audioCfg.sfx || audioCfg.music)) {
|
|
5973
|
+
const keyBeats = [];
|
|
5974
|
+
if (audioCfg.sfx) {
|
|
5975
|
+
for (const s of manifest.steps) {
|
|
5976
|
+
if (s.kind !== "type") continue;
|
|
5977
|
+
const headEnd = Math.min(s.outEnd, s.outBeat + 1150);
|
|
5978
|
+
for (let t = s.outBeat + 40; t < headEnd; t += 78 + (t | 0) % 29) keyBeats.push(t);
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
4961
5981
|
const clips = await mixAudio(videoPath, manifest.steps, manifest.durationMs, {
|
|
4962
5982
|
...narration ? { narration } : {},
|
|
4963
5983
|
...audioCfg.sfx ? { sfxBeats: manifest.ripples } : {},
|
|
5984
|
+
...keyBeats.length > 0 ? { keyBeats } : {},
|
|
4964
5985
|
...audioCfg.music ? { music: audioCfg.music } : {},
|
|
4965
|
-
cacheDir:
|
|
5986
|
+
cacheDir: join8(opts.outDir, "narration")
|
|
4966
5987
|
});
|
|
4967
5988
|
if (narration) {
|
|
4968
5989
|
manifest.audio = {
|
|
4969
5990
|
narrated: true,
|
|
4970
5991
|
provider: audioCfg.provider,
|
|
4971
|
-
voice:
|
|
5992
|
+
voice: voiceLabel,
|
|
4972
5993
|
lines: clips.length,
|
|
4973
5994
|
clips
|
|
4974
5995
|
};
|
|
@@ -4977,8 +5998,19 @@ async function compose(bundle, spec, opts) {
|
|
|
4977
5998
|
`\u2713 audio: ${clips.length} narration line(s)${audioCfg.sfx ? `, ${manifest.ripples.length} click(s)` : ""}${audioCfg.music ? ", music bed" : ""} \u2192 ${videoPath}`
|
|
4978
5999
|
);
|
|
4979
6000
|
}
|
|
6001
|
+
if (narrationDegraded && !manifest.audio) {
|
|
6002
|
+
manifest.audio = {
|
|
6003
|
+
narrated: false,
|
|
6004
|
+
provider: audioCfg?.provider ?? "say",
|
|
6005
|
+
voice: voiceLabel,
|
|
6006
|
+
lines: 0,
|
|
6007
|
+
degraded: narrationDegraded
|
|
6008
|
+
};
|
|
6009
|
+
} else if (narrationDegraded && manifest.audio) {
|
|
6010
|
+
manifest.audio.degraded = narrationDegraded;
|
|
6011
|
+
}
|
|
4980
6012
|
manifest.videoSha256 = await sha256File(videoPath);
|
|
4981
|
-
const manifestPath =
|
|
6013
|
+
const manifestPath = join8(opts.outDir, "compose-manifest.json");
|
|
4982
6014
|
await writeFile4(manifestPath, JSON.stringify(manifest, null, 2));
|
|
4983
6015
|
return { videoPath, manifestPath, manifest };
|
|
4984
6016
|
}
|
|
@@ -4991,158 +6023,11 @@ function safeHost(url) {
|
|
|
4991
6023
|
}
|
|
4992
6024
|
|
|
4993
6025
|
// src/verify/runner.ts
|
|
4994
|
-
import { join as
|
|
6026
|
+
import { join as join9 } from "path";
|
|
4995
6027
|
import { mkdir as mkdir4, writeFile as writeFile6, readFile as readFile4 } from "fs/promises";
|
|
4996
6028
|
|
|
4997
6029
|
// src/verify/checks.ts
|
|
4998
6030
|
init_geometry();
|
|
4999
|
-
|
|
5000
|
-
// src/verify/media.ts
|
|
5001
|
-
import sharp3 from "sharp";
|
|
5002
|
-
async function extractGrayFrames(videoPath, fps, w, h) {
|
|
5003
|
-
const res = await runBinaryRaw(ffmpegPath(), [
|
|
5004
|
-
"-hide_banner",
|
|
5005
|
-
"-loglevel",
|
|
5006
|
-
"error",
|
|
5007
|
-
"-i",
|
|
5008
|
-
videoPath,
|
|
5009
|
-
"-vf",
|
|
5010
|
-
`fps=${fps},scale=${w}:${h}`,
|
|
5011
|
-
"-f",
|
|
5012
|
-
"rawvideo",
|
|
5013
|
-
"-pix_fmt",
|
|
5014
|
-
"gray",
|
|
5015
|
-
"pipe:1"
|
|
5016
|
-
]);
|
|
5017
|
-
if (res.code !== 0) throw new Error(`frame extraction failed: ${res.stderr}`);
|
|
5018
|
-
const frameSize = w * h;
|
|
5019
|
-
const frames = [];
|
|
5020
|
-
for (let off = 0; off + frameSize <= res.stdout.length; off += frameSize) {
|
|
5021
|
-
frames.push(res.stdout.subarray(off, off + frameSize));
|
|
5022
|
-
}
|
|
5023
|
-
return { frames, intervalMs: 1e3 / fps };
|
|
5024
|
-
}
|
|
5025
|
-
async function extractFrameAt(videoPath, tMs) {
|
|
5026
|
-
const res = await runBinaryRaw(ffmpegPath(), [
|
|
5027
|
-
"-hide_banner",
|
|
5028
|
-
"-loglevel",
|
|
5029
|
-
"error",
|
|
5030
|
-
"-ss",
|
|
5031
|
-
(Math.max(0, tMs) / 1e3).toFixed(3),
|
|
5032
|
-
"-i",
|
|
5033
|
-
videoPath,
|
|
5034
|
-
"-frames:v",
|
|
5035
|
-
"1",
|
|
5036
|
-
"-f",
|
|
5037
|
-
"image2pipe",
|
|
5038
|
-
"-vcodec",
|
|
5039
|
-
"png",
|
|
5040
|
-
"pipe:1"
|
|
5041
|
-
]);
|
|
5042
|
-
if (res.code !== 0 || res.stdout.length === 0) {
|
|
5043
|
-
throw new Error(`could not extract frame at ${tMs}ms: ${res.stderr}`);
|
|
5044
|
-
}
|
|
5045
|
-
return res.stdout;
|
|
5046
|
-
}
|
|
5047
|
-
async function probeVideo(videoPath) {
|
|
5048
|
-
const res = await runBinary(ffprobePath(), [
|
|
5049
|
-
"-v",
|
|
5050
|
-
"error",
|
|
5051
|
-
"-print_format",
|
|
5052
|
-
"json",
|
|
5053
|
-
"-show_format",
|
|
5054
|
-
"-show_streams",
|
|
5055
|
-
videoPath
|
|
5056
|
-
]);
|
|
5057
|
-
if (res.code !== 0) throw new Error(`ffprobe failed: ${res.stderr}`);
|
|
5058
|
-
const json = JSON.parse(res.stdout);
|
|
5059
|
-
const v = json.streams?.find((s) => s.codec_type === "video");
|
|
5060
|
-
if (!v) throw new Error("no video stream found");
|
|
5061
|
-
const [num, den] = v.avg_frame_rate.split("/").map(Number);
|
|
5062
|
-
return {
|
|
5063
|
-
codec: v.codec_name,
|
|
5064
|
-
width: v.width,
|
|
5065
|
-
height: v.height,
|
|
5066
|
-
durationSec: Number(json.format?.duration ?? 0),
|
|
5067
|
-
fps: den ? num / den : 0
|
|
5068
|
-
};
|
|
5069
|
-
}
|
|
5070
|
-
function meanAbsDiff(a, b) {
|
|
5071
|
-
const n = Math.min(a.length, b.length);
|
|
5072
|
-
let sum = 0;
|
|
5073
|
-
for (let i = 0; i < n; i++) sum += Math.abs(a[i] - b[i]);
|
|
5074
|
-
return sum / n;
|
|
5075
|
-
}
|
|
5076
|
-
function variance(a) {
|
|
5077
|
-
let sum = 0;
|
|
5078
|
-
for (let i = 0; i < a.length; i++) sum += a[i];
|
|
5079
|
-
const mean = sum / a.length;
|
|
5080
|
-
let v = 0;
|
|
5081
|
-
for (let i = 0; i < a.length; i++) v += (a[i] - mean) ** 2;
|
|
5082
|
-
return v / a.length;
|
|
5083
|
-
}
|
|
5084
|
-
async function sourceFrameGray(bundle, tMs, width) {
|
|
5085
|
-
const idx = frameIndexForTime(bundle.frames, tMs);
|
|
5086
|
-
const { data, info } = await sharp3(framePath(bundle, idx)).resize({ width }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
5087
|
-
return { data, w: info.width, h: info.height, frameT: bundle.frames[idx].t };
|
|
5088
|
-
}
|
|
5089
|
-
async function sourceRegionGray(bundle, tMs, region, preferAfter = false, color = false) {
|
|
5090
|
-
let idx = frameIndexForTime(bundle.frames, tMs);
|
|
5091
|
-
if (preferAfter && idx + 1 < bundle.frames.length && bundle.frames[idx].t < tMs) idx += 1;
|
|
5092
|
-
const meta = await sharp3(framePath(bundle, idx)).metadata();
|
|
5093
|
-
const fw = meta.width ?? 0;
|
|
5094
|
-
const fh = meta.height ?? 0;
|
|
5095
|
-
const x = Math.max(0, Math.round(region.x));
|
|
5096
|
-
const y = Math.max(0, Math.round(region.y));
|
|
5097
|
-
const w = Math.min(Math.round(region.w), fw - x);
|
|
5098
|
-
const h = Math.min(Math.round(region.h), fh - y);
|
|
5099
|
-
if (w < 4 || h < 4) return null;
|
|
5100
|
-
let img = sharp3(framePath(bundle, idx)).extract({ left: x, top: y, width: w, height: h });
|
|
5101
|
-
if (!color) img = img.grayscale();
|
|
5102
|
-
const { data } = await img.raw().toBuffer({ resolveWithObject: true });
|
|
5103
|
-
return data;
|
|
5104
|
-
}
|
|
5105
|
-
async function regionGray48(input, rect) {
|
|
5106
|
-
try {
|
|
5107
|
-
const img = sharp3(input);
|
|
5108
|
-
const meta = await img.metadata();
|
|
5109
|
-
const W = meta.width ?? 0;
|
|
5110
|
-
const H = meta.height ?? 0;
|
|
5111
|
-
const left = Math.max(0, Math.round(rect.x));
|
|
5112
|
-
const top = Math.max(0, Math.round(rect.y));
|
|
5113
|
-
const width = Math.min(W - left, Math.round(rect.w));
|
|
5114
|
-
const height = Math.min(H - top, Math.round(rect.h));
|
|
5115
|
-
if (width < 8 || height < 8) return null;
|
|
5116
|
-
return await sharp3(input).extract({ left, top, width, height }).grayscale().resize(48, 48, { fit: "fill" }).raw().toBuffer();
|
|
5117
|
-
} catch {
|
|
5118
|
-
return null;
|
|
5119
|
-
}
|
|
5120
|
-
}
|
|
5121
|
-
function normalizedCorrelation(a, b) {
|
|
5122
|
-
const n = Math.min(a.length, b.length);
|
|
5123
|
-
let ma = 0;
|
|
5124
|
-
let mb = 0;
|
|
5125
|
-
for (let i = 0; i < n; i++) {
|
|
5126
|
-
ma += a[i];
|
|
5127
|
-
mb += b[i];
|
|
5128
|
-
}
|
|
5129
|
-
ma /= n;
|
|
5130
|
-
mb /= n;
|
|
5131
|
-
let num = 0;
|
|
5132
|
-
let da = 0;
|
|
5133
|
-
let db = 0;
|
|
5134
|
-
for (let i = 0; i < n; i++) {
|
|
5135
|
-
const xa = a[i] - ma;
|
|
5136
|
-
const xb = b[i] - mb;
|
|
5137
|
-
num += xa * xb;
|
|
5138
|
-
da += xa * xa;
|
|
5139
|
-
db += xb * xb;
|
|
5140
|
-
}
|
|
5141
|
-
if (da < 1e-6 || db < 1e-6) return da < 1e-6 && db < 1e-6 ? 1 : 0;
|
|
5142
|
-
return num / Math.sqrt(da * db);
|
|
5143
|
-
}
|
|
5144
|
-
|
|
5145
|
-
// src/verify/checks.ts
|
|
5146
6031
|
var EFFECT_KINDS = /* @__PURE__ */ new Set(["click", "dblclick", "type", "select", "goto"]);
|
|
5147
6032
|
var CLICK_KINDS2 = /* @__PURE__ */ new Set(["click", "dblclick", "select"]);
|
|
5148
6033
|
function plane(ctx2) {
|
|
@@ -5221,7 +6106,47 @@ async function checkCursorOnTarget(ctx2) {
|
|
|
5221
6106
|
}
|
|
5222
6107
|
}
|
|
5223
6108
|
if (checked === 0) return { id: "cursor-on-target", status: "skip", details: "no click steps" };
|
|
5224
|
-
|
|
6109
|
+
if (evidence.length > 0)
|
|
6110
|
+
return { id: "cursor-on-target", status: "fail", details: `${evidence.length}/${checked} clicks miss their target`, evidence };
|
|
6111
|
+
const p = plane(ctx2);
|
|
6112
|
+
const content = stageContent(ctx2.manifest.profile);
|
|
6113
|
+
const frameScale = ctx2.bundle.manifest.frameW / ctx2.manifest.viewport.w;
|
|
6114
|
+
const clickSteps = ctx2.manifest.steps.filter((s) => CLICK_KINDS2.has(s.kind) && s.targetRectVp);
|
|
6115
|
+
let sampled = 0;
|
|
6116
|
+
let present = 0;
|
|
6117
|
+
for (const step of clickSteps.slice(0, 2)) {
|
|
6118
|
+
const t = step.outBeat;
|
|
6119
|
+
const pos = sampleCursor(cursorPlan, t);
|
|
6120
|
+
const boxVp = { x: pos.x - 16, y: pos.y - 16, w: 32, h: 32 };
|
|
6121
|
+
const cam = sampleCamera(ctx2.manifest.camera, t);
|
|
6122
|
+
const proj = projectRect(boxVp, cam, p);
|
|
6123
|
+
const projFrame = { x: proj.x + content.x, y: proj.y + content.y, w: proj.w, h: proj.h };
|
|
6124
|
+
const { srcAt } = sampleSource(ctx2.manifest.segments, t);
|
|
6125
|
+
if (srcAt === null) continue;
|
|
6126
|
+
const outPng = await extractFrameAt(ctx2.videoPath, t).catch(() => null);
|
|
6127
|
+
if (!outPng) continue;
|
|
6128
|
+
const [outRegion, srcRegion] = await Promise.all([
|
|
6129
|
+
regionGray48(outPng, projFrame),
|
|
6130
|
+
regionGray48(framePath(ctx2.bundle, frameIndexForTime(ctx2.bundle.frames, srcAt)), {
|
|
6131
|
+
x: boxVp.x * frameScale,
|
|
6132
|
+
y: boxVp.y * frameScale,
|
|
6133
|
+
w: boxVp.w * frameScale,
|
|
6134
|
+
h: boxVp.h * frameScale
|
|
6135
|
+
})
|
|
6136
|
+
]);
|
|
6137
|
+
if (!outRegion || !srcRegion) continue;
|
|
6138
|
+
sampled += 1;
|
|
6139
|
+
if (meanAbsDiff(outRegion, srcRegion) > 6) present += 1;
|
|
6140
|
+
}
|
|
6141
|
+
if (sampled > 0 && present === 0) {
|
|
6142
|
+
return {
|
|
6143
|
+
id: "cursor-on-target",
|
|
6144
|
+
status: "fail",
|
|
6145
|
+
details: `cursor sprite NOT FOUND in the output pixels at ${sampled} sampled click beat(s) \u2014 the plan says it's there, the film disagrees`
|
|
6146
|
+
};
|
|
6147
|
+
}
|
|
6148
|
+
const renderedNote = sampled > 0 ? `; sprite verified on film at ${present}/${sampled} sampled beat(s)` : "";
|
|
6149
|
+
return { id: "cursor-on-target", status: "pass", details: `${checked}/${checked} clicks land on target${renderedNote}` };
|
|
5225
6150
|
}
|
|
5226
6151
|
async function checkActionEffect(ctx2) {
|
|
5227
6152
|
const evidence = [];
|
|
@@ -5279,12 +6204,14 @@ async function checkActionEffect(ctx2) {
|
|
|
5279
6204
|
}
|
|
5280
6205
|
const maskedNote = skippedMasked > 0 ? ` (${skippedMasked} masked skipped)` : "";
|
|
5281
6206
|
const inconcNote = inconclusive.length > 0 ? `, ${inconclusive.length} inconclusive (capture gap)` : "";
|
|
6207
|
+
const coverage = { verified: checked - evidence.length - inconclusive.length, total: checked + skippedMasked };
|
|
5282
6208
|
if (evidence.length > 0) {
|
|
5283
6209
|
return {
|
|
5284
6210
|
id: "action-effect",
|
|
5285
6211
|
status: "fail",
|
|
5286
6212
|
details: `${evidence.length}/${checked} actions show no visible effect${maskedNote}${inconcNote}`,
|
|
5287
|
-
evidence: [...evidence, ...inconclusive]
|
|
6213
|
+
evidence: [...evidence, ...inconclusive],
|
|
6214
|
+
coverage
|
|
5288
6215
|
};
|
|
5289
6216
|
}
|
|
5290
6217
|
if (inconclusive.length > 0) {
|
|
@@ -5292,10 +6219,11 @@ async function checkActionEffect(ctx2) {
|
|
|
5292
6219
|
id: "action-effect",
|
|
5293
6220
|
status: "warn",
|
|
5294
6221
|
details: `${checked - inconclusive.length}/${checked} actions visibly effective; ${inconclusive.length} unverified (capture gap)${maskedNote}`,
|
|
5295
|
-
evidence: inconclusive
|
|
6222
|
+
evidence: inconclusive,
|
|
6223
|
+
coverage
|
|
5296
6224
|
};
|
|
5297
6225
|
}
|
|
5298
|
-
return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}
|
|
6226
|
+
return { id: "action-effect", status: "pass", details: `${checked}/${checked} actions visibly effective${maskedNote}`, coverage };
|
|
5299
6227
|
}
|
|
5300
6228
|
async function checkFrozenBlank(ctx2) {
|
|
5301
6229
|
const { frames, intervalMs } = await extractGrayFrames(ctx2.videoPath, 2, 320, 180);
|
|
@@ -5303,11 +6231,23 @@ async function checkFrozenBlank(ctx2) {
|
|
|
5303
6231
|
const evidence = [];
|
|
5304
6232
|
const dips = ctx2.manifest.overlays.filter((o) => o.kind === "dip");
|
|
5305
6233
|
const inTransition = (t) => segmentAt(ctx2.manifest.segments, t).kind === "card" || dips.some((d) => t >= d.tStart - 60 && t <= d.tEnd + 60);
|
|
6234
|
+
const centerBlank = [];
|
|
6235
|
+
const W = 320;
|
|
6236
|
+
const H = 180;
|
|
5306
6237
|
for (let i = 0; i < frames.length; i++) {
|
|
5307
6238
|
const t = i * intervalMs;
|
|
5308
6239
|
if (inTransition(t)) continue;
|
|
5309
6240
|
if (variance(frames[i]) < 2) {
|
|
5310
6241
|
evidence.push({ t, note: `blank frame at ${(t / 1e3).toFixed(1)}s` });
|
|
6242
|
+
continue;
|
|
6243
|
+
}
|
|
6244
|
+
const f = frames[i];
|
|
6245
|
+
const center = [];
|
|
6246
|
+
for (let y = Math.floor(H * 0.3); y < Math.floor(H * 0.7); y++) {
|
|
6247
|
+
for (let x = Math.floor(W * 0.25); x < Math.floor(W * 0.75); x++) center.push(f[y * W + x]);
|
|
6248
|
+
}
|
|
6249
|
+
if (variance(Buffer.from(center)) < 3) {
|
|
6250
|
+
centerBlank.push({ t, note: `content region blank at ${(t / 1e3).toFixed(1)}s \u2014 still loading? Add a wait: { for: \u2026 } step so hydration never gets filmed` });
|
|
5311
6251
|
}
|
|
5312
6252
|
}
|
|
5313
6253
|
const maskedWindows = ctx2.manifest.steps.filter((s) => s.masked).map((s) => ({ start: s.outStart, end: s.outEnd }));
|
|
@@ -5342,7 +6282,18 @@ async function checkFrozenBlank(ctx2) {
|
|
|
5342
6282
|
runStart = i;
|
|
5343
6283
|
}
|
|
5344
6284
|
}
|
|
5345
|
-
|
|
6285
|
+
if (evidence.length > 0) {
|
|
6286
|
+
return { id: "frozen-blank", status: "fail", details: `${evidence.length} frozen/blank issue(s)`, evidence: [...evidence, ...centerBlank] };
|
|
6287
|
+
}
|
|
6288
|
+
if (centerBlank.length > 0) {
|
|
6289
|
+
return {
|
|
6290
|
+
id: "frozen-blank",
|
|
6291
|
+
status: "warn",
|
|
6292
|
+
details: `frames live, but ${centerBlank.length} sampled frame(s) have a blank content region (mid-hydration footage?)`,
|
|
6293
|
+
evidence: centerBlank
|
|
6294
|
+
};
|
|
6295
|
+
}
|
|
6296
|
+
return { id: "frozen-blank", status: "pass", details: `${frames.length} sampled output frames live and non-blank` };
|
|
5346
6297
|
}
|
|
5347
6298
|
async function checkCaptions(ctx2) {
|
|
5348
6299
|
const evidence = [];
|
|
@@ -5353,7 +6304,7 @@ async function checkCaptions(ctx2) {
|
|
|
5353
6304
|
if (o.kind !== "caption") continue;
|
|
5354
6305
|
count += 1;
|
|
5355
6306
|
if (o.truncated) evidence.push({ stepRef: o.stepRef, note: `caption truncated: "${o.text}"` });
|
|
5356
|
-
if (o.fontPx < ctx2.manifest.theme.captionMinFontPx)
|
|
6307
|
+
if (o.fontPx < Math.round(ctx2.manifest.theme.captionMinFontPx * ctx2.manifest.profile.uiScale))
|
|
5357
6308
|
evidence.push({ stepRef: o.stepRef, note: `caption font ${o.fontPx}px below minimum` });
|
|
5358
6309
|
if (!rectContains(out, o.box, 24)) evidence.push({ stepRef: o.stepRef, note: "caption box outside safe bounds" });
|
|
5359
6310
|
if (o.shrunk && !o.truncated) warned += 1;
|
|
@@ -5361,7 +6312,35 @@ async function checkCaptions(ctx2) {
|
|
|
5361
6312
|
if (count === 0) return { id: "captions", status: "skip", details: "no captions" };
|
|
5362
6313
|
if (evidence.length > 0)
|
|
5363
6314
|
return { id: "captions", status: "fail", details: `${evidence.length} caption problem(s)`, evidence };
|
|
5364
|
-
|
|
6315
|
+
const caps = ctx2.manifest.overlays.filter((o) => o.kind === "caption");
|
|
6316
|
+
const stride = Math.max(1, Math.floor(caps.length / 3));
|
|
6317
|
+
let sampled = 0;
|
|
6318
|
+
let drawn = 0;
|
|
6319
|
+
for (let i = 0; i < caps.length && sampled < 3; i += stride) {
|
|
6320
|
+
const c = caps[i];
|
|
6321
|
+
const tOn = (c.tStart + c.tEnd) / 2;
|
|
6322
|
+
const tOff = c.tEnd + 500;
|
|
6323
|
+
const clashes = caps.some((o) => o !== c && tOff >= o.tStart - 100 && tOff <= o.tEnd + 100);
|
|
6324
|
+
if (clashes || tOff >= ctx2.manifest.durationMs - 200) continue;
|
|
6325
|
+
const [onPng, offPng] = await Promise.all([
|
|
6326
|
+
extractFrameAt(ctx2.videoPath, tOn).catch(() => null),
|
|
6327
|
+
extractFrameAt(ctx2.videoPath, tOff).catch(() => null)
|
|
6328
|
+
]);
|
|
6329
|
+
if (!onPng || !offPng) continue;
|
|
6330
|
+
const [ra, rb] = await Promise.all([regionGray48(onPng, c.box), regionGray48(offPng, c.box)]);
|
|
6331
|
+
if (!ra || !rb) continue;
|
|
6332
|
+
sampled += 1;
|
|
6333
|
+
if (meanAbsDiff(ra, rb) > 4) drawn += 1;
|
|
6334
|
+
}
|
|
6335
|
+
if (sampled > 0 && drawn === 0) {
|
|
6336
|
+
return {
|
|
6337
|
+
id: "captions",
|
|
6338
|
+
status: "fail",
|
|
6339
|
+
details: `captions planned but NOT FOUND in the output pixels (${sampled} sampled boxes identical with and without caption)`
|
|
6340
|
+
};
|
|
6341
|
+
}
|
|
6342
|
+
const renderedNote = sampled > 0 ? `; ${drawn}/${sampled} sampled on film` : "";
|
|
6343
|
+
return warned > 0 ? { id: "captions", status: "warn", details: `${count} captions ok, ${warned} auto-shrunk to fit${renderedNote}` } : { id: "captions", status: "pass", details: `${count} captions fit at full size${renderedNote}` };
|
|
5365
6344
|
}
|
|
5366
6345
|
async function checkPacing(ctx2) {
|
|
5367
6346
|
const evidence = [];
|
|
@@ -5377,7 +6356,20 @@ async function checkPacing(ctx2) {
|
|
|
5377
6356
|
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` };
|
|
5378
6357
|
}
|
|
5379
6358
|
async function checkAudio(ctx2) {
|
|
5380
|
-
if (
|
|
6359
|
+
if (ctx2.manifest.audio?.degraded) {
|
|
6360
|
+
return {
|
|
6361
|
+
id: "audio",
|
|
6362
|
+
status: "fail",
|
|
6363
|
+
details: `narration was requested but degraded to silence: ${ctx2.manifest.audio.degraded}`
|
|
6364
|
+
};
|
|
6365
|
+
}
|
|
6366
|
+
if (!ctx2.manifest.audio?.narrated) {
|
|
6367
|
+
return {
|
|
6368
|
+
id: "audio",
|
|
6369
|
+
status: "skip",
|
|
6370
|
+
details: "no narration \u2014 for a voice-over, set output.audio: { narration: tts } and re-compose"
|
|
6371
|
+
};
|
|
6372
|
+
}
|
|
5381
6373
|
const evidence = [];
|
|
5382
6374
|
const probe = await runBinary(ffprobePath(), [
|
|
5383
6375
|
"-v",
|
|
@@ -5512,9 +6504,37 @@ async function checkMasks(ctx2) {
|
|
|
5512
6504
|
if (evidence.length > 0) {
|
|
5513
6505
|
return { id: "masks", status: "fail", details: `${evidence.length} mask violation(s)`, evidence };
|
|
5514
6506
|
}
|
|
6507
|
+
const p = plane(ctx2);
|
|
6508
|
+
const content = stageContent(ctx2.manifest.profile);
|
|
6509
|
+
const solidSamples = ctx2.bundle.maskSamples.flatMap((s) => s.rects.filter((r) => r.style === "solid").map((r) => ({ t: s.t, r }))).filter((_, i, arr) => i % Math.max(1, Math.floor(arr.length / 3)) === 0).slice(0, 3);
|
|
6510
|
+
let outSampled = 0;
|
|
6511
|
+
let outCovered = 0;
|
|
6512
|
+
for (const s of solidSamples) {
|
|
6513
|
+
const tOut = outTimeForSrc(ctx2.manifest.segments, s.t);
|
|
6514
|
+
if (tOut === null || tOut >= ctx2.manifest.durationMs - 100) continue;
|
|
6515
|
+
const cam = sampleCamera(ctx2.manifest.camera, tOut);
|
|
6516
|
+
const rectVp = { x: s.r.x / frameScale, y: s.r.y / frameScale, w: s.r.w / frameScale, h: s.r.h / frameScale };
|
|
6517
|
+
const proj = projectRect(rectVp, cam, p);
|
|
6518
|
+
const projFrame = { x: proj.x + content.x + 4, y: proj.y + content.y + 4, w: proj.w - 8, h: proj.h - 8 };
|
|
6519
|
+
if (projFrame.w < 12 || projFrame.h < 12) continue;
|
|
6520
|
+
const png = await extractFrameAt(ctx2.videoPath, tOut).catch(() => null);
|
|
6521
|
+
if (!png) continue;
|
|
6522
|
+
const region = await regionGray48(png, projFrame);
|
|
6523
|
+
if (!region) continue;
|
|
6524
|
+
outSampled += 1;
|
|
6525
|
+
if (Math.sqrt(variance(region)) < 16) outCovered += 1;
|
|
6526
|
+
}
|
|
6527
|
+
if (outSampled > 0 && outCovered === 0) {
|
|
6528
|
+
return {
|
|
6529
|
+
id: "masks",
|
|
6530
|
+
status: "fail",
|
|
6531
|
+
details: `masked regions verified in SOURCE frames but NOT covered in the delivered video (${outSampled} output sample(s) show content)`
|
|
6532
|
+
};
|
|
6533
|
+
}
|
|
5515
6534
|
const parts = [];
|
|
5516
6535
|
if (buckets.size > 0) parts.push(`${buckets.size} solid region(s) verified opaque`);
|
|
5517
6536
|
if (blurKeys.size > 0) parts.push(`${blurKeys.size} blurred region(s) applied`);
|
|
6537
|
+
if (outSampled > 0) parts.push(`${outCovered}/${outSampled} re-verified in the output video`);
|
|
5518
6538
|
return { id: "masks", status: "pass", details: `${parts.join(", ")}; log clean` };
|
|
5519
6539
|
}
|
|
5520
6540
|
function scaleRect(r, s) {
|
|
@@ -5572,7 +6592,7 @@ async function checkOutputPixels(ctx2) {
|
|
|
5572
6592
|
}
|
|
5573
6593
|
}
|
|
5574
6594
|
if (checked === 0) return { id: "output-pixels", status: "skip", details: "no comparable samples (captions overlapped or frames unavailable)" };
|
|
5575
|
-
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
|
|
6595
|
+
return evidence.length > 0 ? { id: "output-pixels", status: "fail", details: `${evidence.length}/${checked} sampled regions diverge from the plan`, evidence, coverage: { verified: checked - evidence.length, total: checked } } : { id: "output-pixels", status: "pass", details: `${checked} sampled output regions match their planned source`, coverage: { verified: checked, total: checked } };
|
|
5576
6596
|
}
|
|
5577
6597
|
|
|
5578
6598
|
// src/verify/contact-sheet.ts
|
|
@@ -5721,13 +6741,17 @@ function extractJson(text) {
|
|
|
5721
6741
|
}
|
|
5722
6742
|
|
|
5723
6743
|
// src/verify/report.ts
|
|
5724
|
-
import { createHmac } from "crypto";
|
|
6744
|
+
import { createHmac, createHash as createHash4, sign as edSign, verify as edVerify, createPrivateKey, createPublicKey } from "crypto";
|
|
6745
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5725
6746
|
function signablePayload(v) {
|
|
5726
6747
|
return JSON.stringify({
|
|
5727
6748
|
schema: v.schema,
|
|
5728
6749
|
verdict: v.verdict,
|
|
5729
6750
|
videoSha256: v.video.sha256,
|
|
6751
|
+
manifestHash: v.manifestHash,
|
|
6752
|
+
contactSheetSha256: v.contactSheetSha256,
|
|
5730
6753
|
bundleHashes: { events: v.bundleHashes.events, framesIndex: v.bundleHashes.framesIndex },
|
|
6754
|
+
coverage: v.coverage,
|
|
5731
6755
|
provenance: {
|
|
5732
6756
|
playheadVersion: v.provenance.playheadVersion,
|
|
5733
6757
|
specHash: v.provenance.specHash,
|
|
@@ -5736,23 +6760,54 @@ function signablePayload(v) {
|
|
|
5736
6760
|
verifiedAt: v.provenance.verifiedAt,
|
|
5737
6761
|
checkSuiteVersion: v.provenance.checkSuiteVersion,
|
|
5738
6762
|
host: v.provenance.host,
|
|
5739
|
-
partial: v.provenance.partial ?? false
|
|
6763
|
+
partial: v.provenance.partial ?? false,
|
|
6764
|
+
failure: v.provenance.failure ?? null
|
|
5740
6765
|
},
|
|
5741
|
-
checks: v.checks.map((c) => ({
|
|
6766
|
+
checks: v.checks.map((c) => ({
|
|
6767
|
+
id: c.id,
|
|
6768
|
+
status: c.status,
|
|
6769
|
+
details: c.details,
|
|
6770
|
+
evidence: (c.evidence ?? []).map((e) => ({ stepRef: e.stepRef ?? null, t: e.t ?? null, note: e.note }))
|
|
6771
|
+
})),
|
|
6772
|
+
vision: v.vision
|
|
5742
6773
|
});
|
|
5743
6774
|
}
|
|
5744
|
-
function signVerdict(v,
|
|
5745
|
-
|
|
5746
|
-
|
|
6775
|
+
function signVerdict(v, env = {
|
|
6776
|
+
...process.env.PLAYHEAD_SIGNING_KEY ? { hmacKey: process.env.PLAYHEAD_SIGNING_KEY } : {},
|
|
6777
|
+
...process.env.PLAYHEAD_SIGNING_KEY_FILE ? { keyFile: process.env.PLAYHEAD_SIGNING_KEY_FILE } : {}
|
|
6778
|
+
}) {
|
|
6779
|
+
const payload = Buffer.from(signablePayload(v));
|
|
6780
|
+
if (env.keyFile) {
|
|
6781
|
+
const privateKey = createPrivateKey(readFileSync2(env.keyFile, "utf8"));
|
|
6782
|
+
const publicKey = createPublicKey(privateKey);
|
|
6783
|
+
const spki = publicKey.export({ type: "spki", format: "der" });
|
|
6784
|
+
return {
|
|
6785
|
+
alg: "Ed25519",
|
|
6786
|
+
keyId: keyIdFor(spki),
|
|
6787
|
+
publicKey: spki.toString("base64"),
|
|
6788
|
+
value: edSign(null, payload, privateKey).toString("base64")
|
|
6789
|
+
};
|
|
6790
|
+
}
|
|
6791
|
+
if (env.hmacKey) {
|
|
6792
|
+
return {
|
|
6793
|
+
alg: "HS256",
|
|
6794
|
+
keyId: keyIdFor(Buffer.from(env.hmacKey)),
|
|
6795
|
+
value: createHmac("sha256", env.hmacKey).update(payload).digest("hex")
|
|
6796
|
+
};
|
|
6797
|
+
}
|
|
6798
|
+
return null;
|
|
6799
|
+
}
|
|
6800
|
+
function keyIdFor(material) {
|
|
6801
|
+
return createHash4("sha256").update(material).digest("hex").slice(0, 16);
|
|
5747
6802
|
}
|
|
5748
6803
|
|
|
5749
6804
|
// src/verify/runner.ts
|
|
5750
6805
|
init_log();
|
|
5751
6806
|
import { platform, arch } from "os";
|
|
5752
6807
|
import pc2 from "picocolors";
|
|
5753
|
-
var CHECK_SUITE_VERSION = "playhead/checks@
|
|
6808
|
+
var CHECK_SUITE_VERSION = "playhead/checks@3";
|
|
5754
6809
|
async function verify(bundle, manifest, videoPath, opts) {
|
|
5755
|
-
const verifyDir =
|
|
6810
|
+
const verifyDir = join9(opts.outDir, "verify");
|
|
5756
6811
|
await mkdir4(verifyDir, { recursive: true });
|
|
5757
6812
|
const ctx2 = { bundle, manifest, videoPath, outDir: verifyDir };
|
|
5758
6813
|
log.info("verifying output");
|
|
@@ -5776,7 +6831,7 @@ async function verify(bundle, manifest, videoPath, opts) {
|
|
|
5776
6831
|
checks.push(result);
|
|
5777
6832
|
report(result);
|
|
5778
6833
|
}
|
|
5779
|
-
const contactSheet =
|
|
6834
|
+
const contactSheet = join9(verifyDir, "contact-sheet.png");
|
|
5780
6835
|
await renderContactSheet(manifest, videoPath, contactSheet);
|
|
5781
6836
|
log.ok(`contact sheet \u2192 ${contactSheet}`);
|
|
5782
6837
|
let vision = null;
|
|
@@ -5787,11 +6842,28 @@ async function verify(bundle, manifest, videoPath, opts) {
|
|
|
5787
6842
|
vision = review;
|
|
5788
6843
|
}
|
|
5789
6844
|
const failed = checks.some((c) => c.status === "fail");
|
|
6845
|
+
const effectCoverage = checks.find((c) => c.id === "action-effect")?.coverage;
|
|
6846
|
+
const pixelCoverage = checks.find((c) => c.id === "output-pixels")?.coverage;
|
|
6847
|
+
const coverage = {
|
|
6848
|
+
actionsVerified: effectCoverage?.verified ?? 0,
|
|
6849
|
+
actionsTotal: effectCoverage?.total ?? 0,
|
|
6850
|
+
outputSamplesVerified: pixelCoverage?.verified ?? 0,
|
|
6851
|
+
checksSkipped: checks.filter((c) => c.status === "skip").length,
|
|
6852
|
+
checksWarned: checks.filter((c) => c.status === "warn").length
|
|
6853
|
+
};
|
|
5790
6854
|
const unsigned = {
|
|
5791
|
-
schema: "playhead/verdict@
|
|
6855
|
+
schema: "playhead/verdict@3",
|
|
5792
6856
|
verdict: failed ? "not-publishable" : "publishable",
|
|
5793
|
-
|
|
6857
|
+
// ALWAYS hash the file the checks actually ran against — never trust the manifest's claim
|
|
6858
|
+
// (an unauthenticated JSON from disk). Round-2 audit: a doctored manifest could bind the
|
|
6859
|
+
// signed verdict to a video that was never verified.
|
|
6860
|
+
video: { path: videoPath, sha256: await sha256File(videoPath) },
|
|
5794
6861
|
bundleHashes: manifest.bundleHashes,
|
|
6862
|
+
// The compose manifest is the oracle for the plan-side checks — hash it into the verdict
|
|
6863
|
+
// so a doctored manifest can no longer mint passes undetected.
|
|
6864
|
+
manifestHash: sha256Json(manifest),
|
|
6865
|
+
contactSheetSha256: await sha256File(contactSheet),
|
|
6866
|
+
coverage,
|
|
5795
6867
|
provenance: {
|
|
5796
6868
|
playheadVersion: PLAYHEAD_VERSION,
|
|
5797
6869
|
specHash: bundle.manifest.specHash,
|
|
@@ -5809,9 +6881,10 @@ async function verify(bundle, manifest, videoPath, opts) {
|
|
|
5809
6881
|
};
|
|
5810
6882
|
const verdict = { ...unsigned, signature: signVerdict(unsigned) };
|
|
5811
6883
|
if (!verdict.signature) log.debug("verdict unsigned \u2014 set PLAYHEAD_SIGNING_KEY to sign");
|
|
5812
|
-
await writeFile6(
|
|
5813
|
-
|
|
5814
|
-
|
|
6884
|
+
await writeFile6(join9(verifyDir, "verdict.json"), JSON.stringify(verdict, null, 2));
|
|
6885
|
+
const cov = coverage.actionsTotal > 0 ? ` (coverage: ${coverage.actionsVerified}/${coverage.actionsTotal} actions pixel-verified, ${coverage.outputSamplesVerified} output samples)` : " (coverage: no effectful actions)";
|
|
6886
|
+
if (failed) log.error(`verdict: NOT PUBLISHABLE${cov}`);
|
|
6887
|
+
else log.ok(`verdict: publishable${cov}`);
|
|
5815
6888
|
return verdict;
|
|
5816
6889
|
}
|
|
5817
6890
|
function report(c) {
|
|
@@ -5842,11 +6915,16 @@ function buildServer() {
|
|
|
5842
6915
|
description: "Launch the running web app and return a grounded catalog of addressable elements \u2014 each line is a Playhead locator validated to resolve uniquely against the real DOM. Use these locators verbatim when writing a spec. Only shows the CURRENT screen; call again after a navigation to see later screens.",
|
|
5843
6916
|
inputSchema: {
|
|
5844
6917
|
url: z2.string().describe("the running app URL, e.g. http://localhost:3000"),
|
|
5845
|
-
viewport: dims.describe("viewport WxH")
|
|
6918
|
+
viewport: dims.describe("viewport WxH"),
|
|
6919
|
+
storageState: z2.string().optional().describe("session file from `playhead login` \u2014 REQUIRED for authenticated apps")
|
|
5846
6920
|
}
|
|
5847
6921
|
},
|
|
5848
|
-
async ({ url, viewport }) => {
|
|
5849
|
-
const snap = await exploreUrl(url, {
|
|
6922
|
+
async ({ url, viewport, storageState }) => {
|
|
6923
|
+
const snap = await exploreUrl(url, {
|
|
6924
|
+
viewport: parseDims(viewport),
|
|
6925
|
+
headless: true,
|
|
6926
|
+
...storageState ? { storageStatePath: resolve(storageState) } : {}
|
|
6927
|
+
});
|
|
5850
6928
|
return { content: [{ type: "text", text: formatCatalog(snap) }] };
|
|
5851
6929
|
}
|
|
5852
6930
|
);
|
|
@@ -5859,10 +6937,11 @@ function buildServer() {
|
|
|
5859
6937
|
url: z2.string(),
|
|
5860
6938
|
goal: z2.string().describe('what flow to demo, e.g. "create a new deal for Globex"'),
|
|
5861
6939
|
outPath: z2.string().default("playhead.yaml").describe("where to write the spec"),
|
|
5862
|
-
viewport: dims
|
|
6940
|
+
viewport: dims,
|
|
6941
|
+
storageState: z2.string().optional().describe("session file from `playhead login` for authenticated apps")
|
|
5863
6942
|
}
|
|
5864
6943
|
},
|
|
5865
|
-
async ({ url, goal, outPath, viewport }) => {
|
|
6944
|
+
async ({ url, goal, outPath, viewport, storageState }) => {
|
|
5866
6945
|
const path = isAbsolute(outPath) ? outPath : resolve(outPath);
|
|
5867
6946
|
const { steps } = await authorSpec({
|
|
5868
6947
|
url,
|
|
@@ -5870,7 +6949,8 @@ function buildServer() {
|
|
|
5870
6949
|
outPath: path,
|
|
5871
6950
|
viewport: parseDims(viewport),
|
|
5872
6951
|
maxSteps: 40,
|
|
5873
|
-
headless: true
|
|
6952
|
+
headless: true,
|
|
6953
|
+
...storageState ? { storageStatePath: resolve(storageState) } : {}
|
|
5874
6954
|
});
|
|
5875
6955
|
const yaml = await readFile6(path, "utf8");
|
|
5876
6956
|
return { content: [{ type: "text", text: `Wrote ${steps.length}-step spec to ${path}
|
|
@@ -5926,21 +7006,48 @@ ${formatValidateResult2(res)}` }],
|
|
|
5926
7006
|
await mkdir5(out, { recursive: true });
|
|
5927
7007
|
let parsedSpec;
|
|
5928
7008
|
if (spec) {
|
|
5929
|
-
const tmp = await mkdtemp(
|
|
5930
|
-
const p =
|
|
7009
|
+
const tmp = await mkdtemp(join11(tmpdir(), "playhead-spec-"));
|
|
7010
|
+
const p = join11(tmp, "spec.yaml");
|
|
5931
7011
|
await writeFile7(p, spec);
|
|
5932
7012
|
parsedSpec = parseSpec(spec);
|
|
5933
7013
|
} else {
|
|
5934
7014
|
parsedSpec = await loadSpec(resolve(specPath));
|
|
5935
7015
|
}
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
7016
|
+
try {
|
|
7017
|
+
const { bundleDir } = await capture(parsedSpec, { outDir: out, headless: true });
|
|
7018
|
+
const bundle = await openBundle(bundleDir);
|
|
7019
|
+
const composed = await compose(bundle, parsedSpec, { outDir: out });
|
|
7020
|
+
const verdict = await verify(bundle, composed.manifest, composed.videoPath, { outDir: out, vision });
|
|
7021
|
+
return {
|
|
7022
|
+
content: [{ type: "text", text: renderVerdictReport(verdict, composed.videoPath, composed.manifestPath) }],
|
|
7023
|
+
isError: verdict.verdict !== "publishable"
|
|
7024
|
+
};
|
|
7025
|
+
} catch (e) {
|
|
7026
|
+
const { FlowError: FlowError2 } = await Promise.resolve().then(() => (init_exit(), exit_exports));
|
|
7027
|
+
if (e instanceof FlowError2) {
|
|
7028
|
+
let clipLine = "";
|
|
7029
|
+
try {
|
|
7030
|
+
const bundle = await openBundle(join11(out, "capture"));
|
|
7031
|
+
const clip = await compose(bundle, parsedSpec, { outDir: out, fileName: "failure.mp4" });
|
|
7032
|
+
clipLine = `
|
|
7033
|
+
failure clip: ${clip.videoPath} (watch the flow up to the break)`;
|
|
7034
|
+
} catch {
|
|
7035
|
+
}
|
|
7036
|
+
const failure = await readFile6(join11(out, "capture", "failure.json"), "utf8").catch(() => null);
|
|
7037
|
+
return {
|
|
7038
|
+
content: [
|
|
7039
|
+
{
|
|
7040
|
+
type: "text",
|
|
7041
|
+
text: `FLOW FAILED \u2014 the app broke the scripted flow.
|
|
7042
|
+
${failure ?? e.message}${clipLine}
|
|
7043
|
+
Fix the spec (or the app) and render again.`
|
|
7044
|
+
}
|
|
7045
|
+
],
|
|
7046
|
+
isError: true
|
|
7047
|
+
};
|
|
7048
|
+
}
|
|
7049
|
+
throw e;
|
|
7050
|
+
}
|
|
5944
7051
|
}
|
|
5945
7052
|
);
|
|
5946
7053
|
server.registerTool(
|