@kheleshikhi/studio-agent 0.6.1 → 0.9.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/README.md +269 -13
- package/dist/attempt.d.ts +4 -0
- package/dist/attempt.js +11 -1
- package/dist/attempt.js.map +1 -1
- package/dist/browser.d.ts +89 -3
- package/dist/browser.js +536 -14
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +20 -0
- package/dist/cli.js +851 -73
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +66 -0
- package/dist/context.js +207 -0
- package/dist/context.js.map +1 -0
- package/dist/daemon.js +109 -7
- package/dist/daemon.js.map +1 -1
- package/dist/episode-runner.d.ts +109 -0
- package/dist/episode-runner.js +381 -0
- package/dist/episode-runner.js.map +1 -0
- package/dist/episode.d.ts +332 -0
- package/dist/episode.js +648 -0
- package/dist/episode.js.map +1 -0
- package/dist/presence.d.ts +49 -0
- package/dist/presence.js +141 -0
- package/dist/presence.js.map +1 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +204 -0
- package/dist/project.js.map +1 -0
- package/dist/render-jobs.d.ts +101 -0
- package/dist/render-jobs.js +241 -0
- package/dist/render-jobs.js.map +1 -0
- package/dist/scenario.d.ts +33 -1
- package/dist/scenario.js +143 -5
- package/dist/scenario.js.map +1 -1
- package/dist/shot-provenance.d.ts +66 -0
- package/dist/shot-provenance.js +114 -0
- package/dist/shot-provenance.js.map +1 -0
- package/dist/state.js +10 -6
- package/dist/state.js.map +1 -1
- package/dist/video.d.ts +197 -0
- package/dist/video.js +446 -0
- package/dist/video.js.map +1 -0
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { actionsBetween, audioKey, frameCount, frameTime, PhaseTimes, pngSize, sha256 } from "./video.js";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { chromium } from "playwright";
|
|
4
5
|
import { cacheDirectory } from "./state.js";
|
|
6
|
+
import { throwIfCancelled } from "./render-jobs.js";
|
|
5
7
|
import { CliError } from "./types.js";
|
|
6
8
|
import { assertCurrentObservation, ObservationSampler, observationSettings } from "./observation.js";
|
|
7
|
-
import { isDriveCommand, matchesObservation } from "./scenario.js";
|
|
9
|
+
import { evidenceMode, isDriveCommand, matchesObservation, isExperienceCommand, matchesExperienceObservation, observedValue, stepSummary } from "./scenario.js";
|
|
8
10
|
export function studioBrowserLaunchOptions(headless) {
|
|
9
11
|
return {
|
|
10
12
|
headless,
|
|
@@ -48,6 +50,25 @@ export async function waitForCapturePaint(page) {
|
|
|
48
50
|
throw new CliError("Studio did not paint after runtime readiness.", "STUDIO_CAPTURE_PAINT_TIMEOUT");
|
|
49
51
|
}
|
|
50
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* One PNG of the viewport `clip` at device scale 1, through the page's
|
|
55
|
+
* existing CDP session with Chrome's fast PNG encoder (`optimizeForSpeed`:
|
|
56
|
+
* the same pixels, lighter compression). Playwright's screenshot is the
|
|
57
|
+
* fallback.
|
|
58
|
+
*/
|
|
59
|
+
async function captureViewportPng(page, clip) {
|
|
60
|
+
const session = viewportSessions.get(page);
|
|
61
|
+
if (session) {
|
|
62
|
+
try {
|
|
63
|
+
const { data } = await session.send("Page.captureScreenshot", {
|
|
64
|
+
format: "png", optimizeForSpeed: true, captureBeyondViewport: false, clip: { ...clip, scale: 1 },
|
|
65
|
+
});
|
|
66
|
+
return Buffer.from(data, "base64");
|
|
67
|
+
}
|
|
68
|
+
catch { /* fall back to Playwright */ }
|
|
69
|
+
}
|
|
70
|
+
return page.screenshot({ type: "png", scale: "css", clip });
|
|
71
|
+
}
|
|
51
72
|
async function waitForVisualReady(page, timeoutMs, stage) {
|
|
52
73
|
try {
|
|
53
74
|
await page.waitForFunction(() => {
|
|
@@ -76,7 +97,7 @@ async function waitForVisualReady(page, timeoutMs, stage) {
|
|
|
76
97
|
}
|
|
77
98
|
}
|
|
78
99
|
const viewportSessions = new WeakMap();
|
|
79
|
-
export async function applyCssViewport(page, dimensions) {
|
|
100
|
+
export async function applyCssViewport(page, dimensions, deviceScaleFactor = 0) {
|
|
80
101
|
const nativeScreen = await page.evaluate(() => ({
|
|
81
102
|
width: window.screen.width,
|
|
82
103
|
height: window.screen.height,
|
|
@@ -92,11 +113,12 @@ export async function applyCssViewport(page, dimensions) {
|
|
|
92
113
|
}
|
|
93
114
|
// The override belongs to its CDP session. Detaching here silently restores
|
|
94
115
|
// the original viewport on Chromium, so keep the session until page close.
|
|
95
|
-
// deviceScaleFactor 0 leaves the host display's native scale intact
|
|
116
|
+
// deviceScaleFactor 0 leaves the host display's native scale intact; a
|
|
117
|
+
// presentation render asks for 1, so one CSS pixel is one output pixel.
|
|
96
118
|
await session.send("Emulation.setDeviceMetricsOverride", {
|
|
97
119
|
width: dimensions.width,
|
|
98
120
|
height: dimensions.height,
|
|
99
|
-
deviceScaleFactor
|
|
121
|
+
deviceScaleFactor,
|
|
100
122
|
mobile: false,
|
|
101
123
|
screenWidth: nativeScreen.width,
|
|
102
124
|
screenHeight: nativeScreen.height,
|
|
@@ -249,17 +271,35 @@ export class BrowserCredentialManager {
|
|
|
249
271
|
return this.credential;
|
|
250
272
|
}
|
|
251
273
|
}
|
|
274
|
+
async function notify(listener, report) {
|
|
275
|
+
if (!listener)
|
|
276
|
+
return;
|
|
277
|
+
try {
|
|
278
|
+
await listener(report);
|
|
279
|
+
}
|
|
280
|
+
catch { /* Presence is supplementary. */ }
|
|
281
|
+
}
|
|
282
|
+
function driveOutcome(kind, steps, results, summaries, final) {
|
|
283
|
+
const observations = [...results.map((item) => (item.observation ?? item.actual)), final];
|
|
284
|
+
return {
|
|
285
|
+
evidence_mode: evidenceMode(kind, steps, observations),
|
|
286
|
+
step_summaries: summaries,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
252
289
|
const LIBRARY_WORKSPACES = new Set(["experiences", "audio", "components", "behaviors"]);
|
|
253
290
|
export class StudioBrowser {
|
|
254
291
|
api;
|
|
292
|
+
profileName;
|
|
255
293
|
context;
|
|
256
294
|
page;
|
|
257
295
|
headless = false;
|
|
258
296
|
browserEngine = "not-started";
|
|
259
297
|
apiOrigin;
|
|
260
298
|
credentials;
|
|
261
|
-
|
|
299
|
+
/** `profileName` keeps a render worker's persistent profile apart from the main browser's. */
|
|
300
|
+
constructor(api, profileName = "browser-profile") {
|
|
262
301
|
this.api = api;
|
|
302
|
+
this.profileName = profileName;
|
|
263
303
|
this.apiOrigin = new URL(api.origin).origin;
|
|
264
304
|
this.credentials = new BrowserCredentialManager(api);
|
|
265
305
|
}
|
|
@@ -267,7 +307,7 @@ export class StudioBrowser {
|
|
|
267
307
|
if (this.context)
|
|
268
308
|
return;
|
|
269
309
|
this.headless = headless;
|
|
270
|
-
const profile = path.join(cacheDirectory(),
|
|
310
|
+
const profile = path.join(cacheDirectory(), this.profileName);
|
|
271
311
|
await mkdir(profile, { recursive: true, mode: 0o700 });
|
|
272
312
|
const attempts = [
|
|
273
313
|
{ channel: "chrome", name: "Google Chrome" },
|
|
@@ -364,7 +404,7 @@ export class StudioBrowser {
|
|
|
364
404
|
throw new CliError(`Unknown Studio view: ${view}`, "STUDIO_VIEW_INVALID", 2);
|
|
365
405
|
const { page, webOrigin } = await this.authenticatedPage();
|
|
366
406
|
const dimensions = cssViewport(request.viewport || "desktop");
|
|
367
|
-
await applyCssViewport(page, dimensions);
|
|
407
|
+
await applyCssViewport(page, dimensions, request.deviceScaleFactor ?? 0);
|
|
368
408
|
const consoleErrors = [];
|
|
369
409
|
const pageErrors = [];
|
|
370
410
|
const requestFailures = [];
|
|
@@ -397,9 +437,10 @@ export class StudioBrowser {
|
|
|
397
437
|
method: response.request().method(),
|
|
398
438
|
});
|
|
399
439
|
});
|
|
440
|
+
const present = request.present ? { studio_present: "1" } : {};
|
|
400
441
|
const target = request.library
|
|
401
|
-
? new URL(`/maker/interactive/studio?${new URLSearchParams({ workspace: request.library.workspace, selection: request.library.selection, studio_observation: "1" })}`, webOrigin)
|
|
402
|
-
: new URL(`/maker/interactive/studio/${encodeURIComponent(request.asset)}?${new URLSearchParams({ ...VIEW_QUERY[view], studio_observation: "1", ...(request.drive ? { studio_drive: "1" } : {}) })}`, webOrigin);
|
|
442
|
+
? new URL(`/maker/interactive/studio?${new URLSearchParams({ workspace: request.library.workspace, selection: request.library.selection, studio_observation: "1", ...(request.drive ? { studio_drive: "1" } : {}), ...present })}`, webOrigin)
|
|
443
|
+
: new URL(`/maker/interactive/studio/${encodeURIComponent(request.asset)}?${new URLSearchParams({ ...VIEW_QUERY[view], studio_observation: "1", ...(request.drive ? { studio_drive: "1" } : {}), ...present })}`, webOrigin);
|
|
403
444
|
await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: request.timeoutMs || 30_000 });
|
|
404
445
|
const apiUnauthorized = httpErrors.some((item) => {
|
|
405
446
|
if (item.status !== 401)
|
|
@@ -517,6 +558,34 @@ export class StudioBrowser {
|
|
|
517
558
|
});
|
|
518
559
|
}
|
|
519
560
|
}
|
|
561
|
+
// An unsaved candidate is mounted next and waited for by its own hash;
|
|
562
|
+
// the saved revision need not (and, mid-iteration, often cannot) mount.
|
|
563
|
+
if (request.drive && request.library?.workspace === "experiences" && !request.awaitsCandidate) {
|
|
564
|
+
const deadline = Date.now() + (request.timeoutMs || 30_000);
|
|
565
|
+
while (Date.now() < deadline) {
|
|
566
|
+
observation = await page.evaluate(() => {
|
|
567
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
568
|
+
if (!raw)
|
|
569
|
+
return null;
|
|
570
|
+
try {
|
|
571
|
+
return JSON.parse(raw);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
if (observation?.observation_scope === "live_experience_preview" &&
|
|
578
|
+
observation.mounted === true && typeof observation.active_state === "string")
|
|
579
|
+
break;
|
|
580
|
+
await page.waitForTimeout(125);
|
|
581
|
+
}
|
|
582
|
+
if (observation?.mounted !== true || typeof observation.active_state !== "string") {
|
|
583
|
+
throw new CliError("Experience Preview did not become ready for capture.", "EXPERIENCE_PREVIEW_TIMEOUT", 1, {
|
|
584
|
+
elapsed_ms: request.timeoutMs || 30_000,
|
|
585
|
+
observed: observation,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
520
589
|
// Runtime observation is emitted before Chromium necessarily composites
|
|
521
590
|
// the first visible frame. Wait for fonts and two post-ready paint frames
|
|
522
591
|
// rather than a time-based guess that varies by machine.
|
|
@@ -612,6 +681,7 @@ export class StudioBrowser {
|
|
|
612
681
|
throw new CliError("Candidate preview reported a runtime error.", "STUDIO_RUNTIME_ERROR", 1, ready);
|
|
613
682
|
}
|
|
614
683
|
const results = [];
|
|
684
|
+
const summaries = [];
|
|
615
685
|
const observation = async () => page.evaluate(() => {
|
|
616
686
|
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
617
687
|
return raw ? JSON.parse(raw) : {};
|
|
@@ -630,7 +700,7 @@ export class StudioBrowser {
|
|
|
630
700
|
node.hidden = true;
|
|
631
701
|
document.body.append(node);
|
|
632
702
|
}
|
|
633
|
-
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value });
|
|
703
|
+
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value, setup: step.setup });
|
|
634
704
|
}, { id, step });
|
|
635
705
|
const result = await page.waitForFunction((expected) => {
|
|
636
706
|
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
@@ -642,9 +712,24 @@ export class StudioBrowser {
|
|
|
642
712
|
const lastObserved = await page.evaluate(() => document.getElementById("kheleshikhi-studio-drive-result")?.textContent?.slice(0, 500) || null);
|
|
643
713
|
throw new CliError("Studio drive timed out waiting for command acknowledgement.", "STUDIO_DRIVE_TIMEOUT", 1, { stage: "command_ack", step: index, operation: step.op, elapsed_ms: step.timeoutMs || 10_000, last_observed: lastObserved });
|
|
644
714
|
});
|
|
715
|
+
summaries.push(stepSummary(step, result.ok === true));
|
|
716
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: result.observation });
|
|
645
717
|
if (result.ok !== true)
|
|
646
|
-
throw new CliError(String(result.error || "Semantic command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, step, result });
|
|
718
|
+
throw new CliError(String(result.error || "Semantic command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, step, result, step_summaries: summaries });
|
|
647
719
|
results.push({ index, op: step.op, observation: result.observation });
|
|
720
|
+
if (step.op === "restart") {
|
|
721
|
+
// A restart remounts the production renderer; later steps act on the
|
|
722
|
+
// fresh runtime, not on the one being torn down.
|
|
723
|
+
await page.waitForFunction(() => {
|
|
724
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
725
|
+
if (!raw)
|
|
726
|
+
return false;
|
|
727
|
+
const value = JSON.parse(raw);
|
|
728
|
+
return value.runtime_ready === true && value.visual_ready === true;
|
|
729
|
+
}, undefined, { timeout: step.timeoutMs || 15_000 }).catch(() => {
|
|
730
|
+
throw new CliError("The runtime did not become ready after restart.", "STUDIO_DRIVE_TIMEOUT", 1, { stage: "restart", step: index });
|
|
731
|
+
});
|
|
732
|
+
}
|
|
648
733
|
continue;
|
|
649
734
|
}
|
|
650
735
|
if (step.op === "capture") {
|
|
@@ -653,7 +738,10 @@ export class StudioBrowser {
|
|
|
653
738
|
await waitForVisualReady(page, step.timeoutMs || 10_000, `scenario_capture_${index}`);
|
|
654
739
|
await waitForCapturePaint(page);
|
|
655
740
|
await page.screenshot({ path: output, scale: "device" });
|
|
656
|
-
|
|
741
|
+
const captured = await observation();
|
|
742
|
+
results.push({ index, op: step.op, path: output, observation: captured });
|
|
743
|
+
summaries.push(stepSummary(step, true));
|
|
744
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: captured });
|
|
657
745
|
continue;
|
|
658
746
|
}
|
|
659
747
|
let current = await observation();
|
|
@@ -664,12 +752,446 @@ export class StudioBrowser {
|
|
|
664
752
|
current = await observation();
|
|
665
753
|
}
|
|
666
754
|
}
|
|
755
|
+
summaries.push(stepSummary(step, matchesObservation(current, step), observedValue(current, step.path || "")));
|
|
756
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: current });
|
|
667
757
|
if (!matchesObservation(current, step)) {
|
|
668
|
-
throw new CliError(`Scenario ${step.op} failed at step ${index}: ${step.path}.`, step.op === "wait" ? "STUDIO_DRIVE_TIMEOUT" : "STUDIO_DRIVE_ASSERTION_FAILED", 1, { stage: step.op === "wait" ? "scenario_wait" : "assertion", step: index, operation: step.op, path: step.path, expected: "equals" in step ? step.equals : undefined, last_observed: current, elapsed_ms: step.timeoutMs || 10_000 });
|
|
758
|
+
throw new CliError(`Scenario ${step.op} failed at step ${index}: ${step.path}.`, step.op === "wait" ? "STUDIO_DRIVE_TIMEOUT" : "STUDIO_DRIVE_ASSERTION_FAILED", 1, { stage: step.op === "wait" ? "scenario_wait" : "assertion", step: index, operation: step.op, path: step.path, expected: "equals" in step ? step.equals : undefined, last_observed: current, elapsed_ms: step.timeoutMs || 10_000, step_summaries: summaries });
|
|
669
759
|
}
|
|
670
760
|
results.push({ index, op: step.op, path: step.path, actual: current });
|
|
671
761
|
}
|
|
672
|
-
|
|
762
|
+
const final = await observation();
|
|
763
|
+
return { source_hash: request.candidateHash, initial: first, steps: results, final,
|
|
764
|
+
...driveOutcome("scene", request.scenario.steps, results, summaries, final) };
|
|
765
|
+
}
|
|
766
|
+
async driveExperience(request) {
|
|
767
|
+
if (request.library?.workspace !== "experiences")
|
|
768
|
+
throw new CliError("Experience drive requires the experiences workspace.", "STUDIO_VIEW_INVALID", 2);
|
|
769
|
+
const initial = await this.capture({ ...request, drive: true, observation: false,
|
|
770
|
+
awaitsCandidate: Boolean(request.candidateHash) });
|
|
771
|
+
const page = this.page;
|
|
772
|
+
if (!page)
|
|
773
|
+
throw new CliError("Studio browser is unavailable.", "AGENT_BROWSER_NOT_READY");
|
|
774
|
+
const observation = async () => page.evaluate(() => {
|
|
775
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
776
|
+
return raw ? JSON.parse(raw) : {};
|
|
777
|
+
});
|
|
778
|
+
if (request.candidateHash) {
|
|
779
|
+
// Mount the exact unsaved local Source in this page's Draft Preview.
|
|
780
|
+
// The page answers with the server's canonical hash of what mounted.
|
|
781
|
+
await page.evaluate((payload) => {
|
|
782
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
783
|
+
if (!node) {
|
|
784
|
+
node = document.createElement("div");
|
|
785
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
786
|
+
node.hidden = true;
|
|
787
|
+
document.body.append(node);
|
|
788
|
+
}
|
|
789
|
+
node.textContent = JSON.stringify(payload);
|
|
790
|
+
}, { id: "candidate", op: "loadCandidate", source: request.candidate, candidateHash: request.candidateHash });
|
|
791
|
+
const reply = await page.waitForFunction(() => {
|
|
792
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
793
|
+
if (!raw)
|
|
794
|
+
return false;
|
|
795
|
+
const value = JSON.parse(raw);
|
|
796
|
+
return value.id === "candidate" ? value : false;
|
|
797
|
+
}, undefined, { timeout: Math.max(request.timeoutMs || 30_000, 50_000) })
|
|
798
|
+
.then((handle) => handle.jsonValue())
|
|
799
|
+
.catch(() => { throw new CliError("The unsaved Experience did not mount.", "EXPERIENCE_PREVIEW_TIMEOUT", 1); });
|
|
800
|
+
if (reply.ok !== true)
|
|
801
|
+
throw new CliError(String(reply.error || "The unsaved Experience was refused."), "EXPERIENCE_SOURCE_INVALID", 1, { reply });
|
|
802
|
+
if (reply.source_hash !== request.candidateHash)
|
|
803
|
+
throw new CliError("Studio mounted a different Experience source.", "STUDIO_SOURCE_MISMATCH", 3, {
|
|
804
|
+
expected: request.candidateHash, actual: reply.source_hash
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
await page.waitForFunction((expected) => {
|
|
808
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
809
|
+
if (!raw)
|
|
810
|
+
return false;
|
|
811
|
+
const state = JSON.parse(raw);
|
|
812
|
+
return state.observation_scope === "live_experience_preview" && state.mounted === true &&
|
|
813
|
+
typeof state.active_state === "string" && (!expected || state.source_hash === expected);
|
|
814
|
+
}, request.candidateHash ?? null, { timeout: request.timeoutMs || 30_000 }).catch(() => {
|
|
815
|
+
throw new CliError("Experience Preview did not mount.", "EXPERIENCE_PREVIEW_TIMEOUT", 1);
|
|
816
|
+
});
|
|
817
|
+
if (request.candidateHash) {
|
|
818
|
+
// The initial capture showed the saved revision; retake it from the
|
|
819
|
+
// candidate that is actually being driven.
|
|
820
|
+
await waitForCapturePaint(page);
|
|
821
|
+
await page.screenshot({ path: path.resolve(request.out) });
|
|
822
|
+
initial.source_origin = "local";
|
|
823
|
+
initial.observation = await observation();
|
|
824
|
+
}
|
|
825
|
+
const results = [];
|
|
826
|
+
const summaries = [];
|
|
827
|
+
const started = Date.now();
|
|
828
|
+
for (const [index, step] of request.scenario.steps.entries()) {
|
|
829
|
+
if (Date.now() - started > 120_000)
|
|
830
|
+
throw new CliError("Experience scenario exceeded two minutes.", "STUDIO_SCENARIO_TIMEOUT");
|
|
831
|
+
if (isExperienceCommand(step)) {
|
|
832
|
+
const id = `experience-${index}`;
|
|
833
|
+
await page.evaluate(({ id, step }) => {
|
|
834
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
835
|
+
if (!node) {
|
|
836
|
+
node = document.createElement("div");
|
|
837
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
838
|
+
node.hidden = true;
|
|
839
|
+
document.body.append(node);
|
|
840
|
+
}
|
|
841
|
+
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value, setup: step.setup });
|
|
842
|
+
}, { id, step });
|
|
843
|
+
const result = await page.waitForFunction((expected) => {
|
|
844
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
845
|
+
if (!raw)
|
|
846
|
+
return false;
|
|
847
|
+
const reply = JSON.parse(raw);
|
|
848
|
+
return reply.id === expected ? reply : false;
|
|
849
|
+
}, id, { timeout: step.timeoutMs || 10_000 }).then((handle) => handle.jsonValue()).catch(() => {
|
|
850
|
+
throw new CliError("Experience command acknowledgement timed out.", "STUDIO_DRIVE_TIMEOUT", 1, { step: index, operation: step.op });
|
|
851
|
+
});
|
|
852
|
+
summaries.push(stepSummary(step, result.ok === true));
|
|
853
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: result.observation });
|
|
854
|
+
if (result.ok !== true)
|
|
855
|
+
throw new CliError(String(result.error || "Experience command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, result, step_summaries: summaries });
|
|
856
|
+
results.push({ index, op: step.op, observation: result.observation });
|
|
857
|
+
}
|
|
858
|
+
else if (step.op === "capture") {
|
|
859
|
+
const output = path.join(path.dirname(request.out), `step-${String(index).padStart(2, "0")}${step.name ? `-${step.name}` : ""}.png`);
|
|
860
|
+
await waitForCapturePaint(page);
|
|
861
|
+
await page.screenshot({ path: output, scale: "device" });
|
|
862
|
+
const captured = await observation();
|
|
863
|
+
results.push({ index, op: step.op, path: output, observation: captured });
|
|
864
|
+
summaries.push(stepSummary(step, true));
|
|
865
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: captured });
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
let current = await observation();
|
|
869
|
+
if (step.op === "wait") {
|
|
870
|
+
const deadline = Date.now() + (step.timeoutMs || 10_000);
|
|
871
|
+
while (!matchesExperienceObservation(current, step) && Date.now() < deadline) {
|
|
872
|
+
await page.waitForTimeout(100);
|
|
873
|
+
current = await observation();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const matched = matchesExperienceObservation(current, step);
|
|
877
|
+
summaries.push(stepSummary(step, matched, (step.path || "").split(".").reduce((value, key) => value && typeof value === "object" && Object.hasOwn(value, key) ? value[key] : undefined, current)));
|
|
878
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: current });
|
|
879
|
+
if (!matched)
|
|
880
|
+
throw new CliError(`Experience ${step.op} failed at step ${index}: ${step.path}.`, "STUDIO_DRIVE_ASSERTION_FAILED", 1, { step: index, expected: step.equals, observed: current, step_summaries: summaries });
|
|
881
|
+
results.push({ index, op: step.op, path: step.path, actual: current });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const final = await observation();
|
|
885
|
+
return { initial, steps: results, final,
|
|
886
|
+
...driveOutcome("experience", request.scenario.steps, results, summaries, final) };
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Presentation frames (STU-001) of a Scene, from the presentation host.
|
|
890
|
+
*
|
|
891
|
+
* Mounts and verifies the candidate exactly as `drive` does, but in the
|
|
892
|
+
* presentation host (`studio_present=1`) at a viewport of exactly the
|
|
893
|
+
* output size and device scale 1. `renderBegin` silences the Scene's
|
|
894
|
+
* sounds into a log and restarts it at 0 s; then for every frame video
|
|
895
|
+
* time is stepped to `start + k / fps`, the runtime confirms that instant
|
|
896
|
+
* and a paint, and the whole viewport is captured: one PNG of exactly the
|
|
897
|
+
* output size, proved before it is kept. Scheduled learner actions happen
|
|
898
|
+
* at their own seconds. Afterwards the audio log and each logged sound's
|
|
899
|
+
* canonical bytes are collected for the mixer.
|
|
900
|
+
*/
|
|
901
|
+
async renderFrames(request) {
|
|
902
|
+
const { recipe } = request;
|
|
903
|
+
request.onProgress?.({ stage: "mount" });
|
|
904
|
+
const mounted = await this.drive({ ...request, viewport: `${recipe.width}x${recipe.height}`, present: true,
|
|
905
|
+
deviceScaleFactor: 1, scenario: { version: 1, steps: [{ op: "pause" }] } });
|
|
906
|
+
const page = this.requirePage();
|
|
907
|
+
const phases = new PhaseTimes();
|
|
908
|
+
const { observation, command } = this.renderChannel(page, phases);
|
|
909
|
+
const first = await observation();
|
|
910
|
+
if (first.scene_clock_seconds === undefined || first.scene_clock_seconds === null) {
|
|
911
|
+
throw new CliError("This Scene has no SceneClock; a video needs logical time.", "VIDEO_NO_CLOCK", 2);
|
|
912
|
+
}
|
|
913
|
+
await command({ op: "renderBegin" });
|
|
914
|
+
await this.waitForRender(page, (value) => value.visual_ready === true, "the Scene to repaint after restarting");
|
|
915
|
+
await command({ op: "step", value: recipe.start });
|
|
916
|
+
// Video time is the master; the SceneClock follows it only while it
|
|
917
|
+
// plays, so an authored pause holds the picture. Record each stretch.
|
|
918
|
+
const clock = [];
|
|
919
|
+
let lastPlaying;
|
|
920
|
+
const frames = await this.captureFrames(page, { ...request, phases }, async (time, index) => {
|
|
921
|
+
const reached = await this.waitForRender(page, (value) => typeof value.render_video_seconds === "number" &&
|
|
922
|
+
Math.abs(value.render_video_seconds - time) <= 1e-6 && value.visual_ready === true, "the Scene to stand at the frame and paint");
|
|
923
|
+
if (typeof reached.scene_clock_seconds !== "number") {
|
|
924
|
+
throw new CliError("The Scene reported no SceneClock time.", "VIDEO_CLOCK_DRIFT", 1, { frame: index });
|
|
925
|
+
}
|
|
926
|
+
if (index === 0 || reached.scene_clock_playing !== lastPlaying) {
|
|
927
|
+
clock.push({ frame: index, video_seconds: time, scene_seconds: reached.scene_clock_seconds, playing: reached.scene_clock_playing === true });
|
|
928
|
+
}
|
|
929
|
+
lastPlaying = reached.scene_clock_playing;
|
|
930
|
+
return reached;
|
|
931
|
+
}, command);
|
|
932
|
+
request.onProgress?.({ stage: "audio_collect" });
|
|
933
|
+
const log = await command({ op: "renderLog" });
|
|
934
|
+
const audio = await this.collectAudio(log.audio ?? [], request.audioDirectory, recipe, request.signal, async (event) => (await command({ op: "audioBytes", target: event.id }, 30_000)).bytes);
|
|
935
|
+
const final = await observation();
|
|
936
|
+
// Real-time playback timing on the same mounted learner preview: the
|
|
937
|
+
// clock plays from the recipe's start and every animation frame interval
|
|
938
|
+
// is sampled. Diagnostic only; it never touches the rendered frames.
|
|
939
|
+
let playback;
|
|
940
|
+
const seconds = Math.min(30, Math.max(0, request.measurePlaybackSeconds ?? 0));
|
|
941
|
+
if (seconds > 0) {
|
|
942
|
+
throwIfCancelled(request.signal);
|
|
943
|
+
request.onProgress?.({ stage: "playback_measure" });
|
|
944
|
+
// Video time only moves forward; real-time playback seeks instead.
|
|
945
|
+
await command({ op: "reset" });
|
|
946
|
+
await command({ op: "seek", value: recipe.start });
|
|
947
|
+
await command({ op: "play" });
|
|
948
|
+
const timing = await page.evaluate(async (milliseconds) => {
|
|
949
|
+
const deltas = [];
|
|
950
|
+
let last = performance.now();
|
|
951
|
+
const end = last + milliseconds;
|
|
952
|
+
await new Promise((resolve) => {
|
|
953
|
+
const tick = (now) => { deltas.push(now - last); last = now; if (now < end)
|
|
954
|
+
requestAnimationFrame(tick);
|
|
955
|
+
else
|
|
956
|
+
resolve(); };
|
|
957
|
+
requestAnimationFrame(tick);
|
|
958
|
+
});
|
|
959
|
+
const memory = performance.memory;
|
|
960
|
+
return { deltas, heap: memory ? memory.usedJSHeapSize : null };
|
|
961
|
+
}, seconds * 1000);
|
|
962
|
+
await command({ op: "pause" });
|
|
963
|
+
const sorted = [...timing.deltas].sort((a, b) => a - b);
|
|
964
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
965
|
+
const reached = await observation();
|
|
966
|
+
playback = {
|
|
967
|
+
seconds, frames: sorted.length,
|
|
968
|
+
p50_ms: Number(at(0.5).toFixed(2)), p95_ms: Number(at(0.95).toFixed(2)), p99_ms: Number(at(0.99).toFixed(2)),
|
|
969
|
+
max_ms: Number((sorted.at(-1) ?? 0).toFixed(2)), over_33ms: sorted.filter((value) => value > 33.4).length,
|
|
970
|
+
js_heap_mb: timing.heap ? Math.round(timing.heap / 1048576) : null,
|
|
971
|
+
scene_clock_after: reached.scene_clock_seconds,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
...(playback ? { playback_timing: playback } : {}),
|
|
976
|
+
...frames,
|
|
977
|
+
audio,
|
|
978
|
+
scene_clock: clock,
|
|
979
|
+
runtime_fingerprint: first.runtime_fingerprint,
|
|
980
|
+
source_hash: first.source_hash,
|
|
981
|
+
source_revision: first.source_revision,
|
|
982
|
+
mounted_evidence: mounted.evidence_mode,
|
|
983
|
+
final_states: final.active_states,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Presentation frames of an Experience: the canonical Experience player
|
|
988
|
+
* in the presentation host, driven by video time (`renderBegin`, then
|
|
989
|
+
* `step` per frame). The master timeline advances with video time only
|
|
990
|
+
* while it runs, so a learner pause or a wait for a choice stays paused,
|
|
991
|
+
* as it would in playback; Scenes change, cues fire and sounds start at
|
|
992
|
+
* the exact video second they would.
|
|
993
|
+
*/
|
|
994
|
+
async renderExperienceFrames(request) {
|
|
995
|
+
const { recipe } = request;
|
|
996
|
+
request.onProgress?.({ stage: "mount" });
|
|
997
|
+
await this.driveExperience({ ...request, viewport: `${recipe.width}x${recipe.height}`, present: true,
|
|
998
|
+
deviceScaleFactor: 1, scenario: { version: 1, steps: [] }, out: path.join(request.audioDirectory, "..", "mounted.png") });
|
|
999
|
+
const page = this.requirePage();
|
|
1000
|
+
const phases = new PhaseTimes();
|
|
1001
|
+
const { observation, command } = this.renderChannel(page, phases);
|
|
1002
|
+
const first = await observation();
|
|
1003
|
+
await command({ op: "renderBegin" });
|
|
1004
|
+
await this.waitForRender(page, (value) => value.mounted === true && value.stage_ready === true, "the Experience stage to paint");
|
|
1005
|
+
await command({ op: "step", value: recipe.start });
|
|
1006
|
+
const scenes = [];
|
|
1007
|
+
let lastScene;
|
|
1008
|
+
const frames = await this.captureFrames(page, { ...request, phases }, async (time, index) => {
|
|
1009
|
+
const reached = await this.waitForRender(page, (value) => value.stage_ready === true, "the Experience stage to paint");
|
|
1010
|
+
if (typeof reached.render_video_ms !== "number" || Math.abs(reached.render_video_ms - time * 1000) > 1) {
|
|
1011
|
+
throw new CliError("The Experience did not stand at the requested video time.", "VIDEO_CLOCK_DRIFT", 1, { expected_ms: time * 1000, observed_ms: reached.render_video_ms });
|
|
1012
|
+
}
|
|
1013
|
+
if (index === 0 || reached.active_scene !== lastScene)
|
|
1014
|
+
scenes.push({ frame: index, at: time, scene: reached.active_scene, state: reached.active_state });
|
|
1015
|
+
lastScene = reached.active_scene;
|
|
1016
|
+
return reached;
|
|
1017
|
+
}, command);
|
|
1018
|
+
request.onProgress?.({ stage: "audio_collect" });
|
|
1019
|
+
const log = await command({ op: "renderLog" });
|
|
1020
|
+
const audio = await this.collectAudio(log.audio ?? [], request.audioDirectory, recipe, request.signal, async (event) => (await command({ op: "audioBytes", target: event.id, ...(event.scene ? { scene: event.scene } : {}) }, 30_000)).bytes);
|
|
1021
|
+
const final = await observation();
|
|
1022
|
+
return {
|
|
1023
|
+
...frames,
|
|
1024
|
+
audio,
|
|
1025
|
+
scenes,
|
|
1026
|
+
source_hash: first.source_hash,
|
|
1027
|
+
source_origin: first.source_origin,
|
|
1028
|
+
final_state: final.active_state,
|
|
1029
|
+
final_scene: final.active_scene,
|
|
1030
|
+
timeline_ms: final.timeline_ms,
|
|
1031
|
+
completed: final.completed,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
requirePage() {
|
|
1035
|
+
if (!this.page)
|
|
1036
|
+
throw new CliError("Studio browser is unavailable.", "AGENT_BROWSER_NOT_READY");
|
|
1037
|
+
return this.page;
|
|
1038
|
+
}
|
|
1039
|
+
/** The page's runtime observation and its one-at-a-time drive channel. */
|
|
1040
|
+
renderChannel(page, phases) {
|
|
1041
|
+
const observation = async () => page.evaluate(() => {
|
|
1042
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
1043
|
+
return raw ? JSON.parse(raw) : {};
|
|
1044
|
+
});
|
|
1045
|
+
let sequence = 0;
|
|
1046
|
+
const command = async (payload, timeoutMs = 10_000) => {
|
|
1047
|
+
const id = `render-${sequence += 1}`;
|
|
1048
|
+
const sent = performance.now();
|
|
1049
|
+
await page.evaluate(({ id, payload }) => {
|
|
1050
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
1051
|
+
if (!node) {
|
|
1052
|
+
node = document.createElement("div");
|
|
1053
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
1054
|
+
node.hidden = true;
|
|
1055
|
+
document.body.append(node);
|
|
1056
|
+
}
|
|
1057
|
+
node.textContent = JSON.stringify({ id, ...payload });
|
|
1058
|
+
// Wake the host now; a host without the listener still finds the
|
|
1059
|
+
// command on its next 150 ms poll.
|
|
1060
|
+
document.dispatchEvent(new Event("kheleshikhi-studio-drive-command"));
|
|
1061
|
+
}, { id, payload });
|
|
1062
|
+
const delivered = performance.now();
|
|
1063
|
+
phases?.add("command_send", delivered - sent);
|
|
1064
|
+
const reply = await page.waitForFunction((expected) => {
|
|
1065
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
1066
|
+
if (!raw)
|
|
1067
|
+
return false;
|
|
1068
|
+
const value = JSON.parse(raw);
|
|
1069
|
+
return value.id === expected ? value : false;
|
|
1070
|
+
}, id, { timeout: timeoutMs }).then((handle) => handle.jsonValue()).catch(() => {
|
|
1071
|
+
throw new CliError("The presentation host did not acknowledge a render step.", "VIDEO_STEP_TIMEOUT", 1, { payload: { ...payload, bytes: undefined } });
|
|
1072
|
+
});
|
|
1073
|
+
phases?.add("command_ack_wait", performance.now() - delivered);
|
|
1074
|
+
if (reply.ok !== true)
|
|
1075
|
+
throw new CliError(String(reply.error || "Render step failed."), "VIDEO_STEP_FAILED", 1, { payload, reply });
|
|
1076
|
+
return reply;
|
|
1077
|
+
};
|
|
1078
|
+
return { observation, command };
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* The newest observation once [ready] holds. The page publishes one every
|
|
1082
|
+
* 150 ms and after every command; while not ready, each read also asks the
|
|
1083
|
+
* host for a fresh one (the drive-command wake event, with no command
|
|
1084
|
+
* waiting, only republishes), so a paint that lands between polls is seen
|
|
1085
|
+
* within milliseconds.
|
|
1086
|
+
*/
|
|
1087
|
+
async waitForRender(page, ready, what, timeoutMs = 20_000) {
|
|
1088
|
+
const deadline = Date.now() + timeoutMs;
|
|
1089
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1090
|
+
const value = await page.evaluate((wake) => {
|
|
1091
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
1092
|
+
if (wake)
|
|
1093
|
+
document.dispatchEvent(new Event("kheleshikhi-studio-drive-command"));
|
|
1094
|
+
return raw ? JSON.parse(raw) : {};
|
|
1095
|
+
}, attempt > 0);
|
|
1096
|
+
if (ready(value))
|
|
1097
|
+
return value;
|
|
1098
|
+
if (Date.now() > deadline)
|
|
1099
|
+
throw new CliError(`Timed out waiting for ${what}.`, "VIDEO_STAGE_NOT_READY", 1, { observed: value });
|
|
1100
|
+
await page.waitForTimeout(attempt === 0 ? 0 : 10);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The frame loop both subjects share: actions at their seconds, a step to
|
|
1105
|
+
* each frame's exact second, the subject's own arrival check, a paint,
|
|
1106
|
+
* and a whole-viewport PNG proved to be exactly the output size.
|
|
1107
|
+
*/
|
|
1108
|
+
async captureFrames(page, request, arrived, command) {
|
|
1109
|
+
const { recipe } = request;
|
|
1110
|
+
const phases = request.phases ?? new PhaseTimes();
|
|
1111
|
+
throwIfCancelled(request.signal);
|
|
1112
|
+
await mkdir(request.framesDirectory, { recursive: true });
|
|
1113
|
+
const viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio }));
|
|
1114
|
+
if (viewport.width !== recipe.width || viewport.height !== recipe.height || viewport.dpr !== 1) {
|
|
1115
|
+
throw new CliError("The presentation viewport is not the output size at device scale 1.", "VIDEO_VIEWPORT_MISMATCH", 1, { viewport, expected: { width: recipe.width, height: recipe.height } });
|
|
1116
|
+
}
|
|
1117
|
+
const clip = { x: 0, y: 0, width: recipe.width, height: recipe.height };
|
|
1118
|
+
const total = frameCount(recipe);
|
|
1119
|
+
const hashes = [];
|
|
1120
|
+
const shots = [];
|
|
1121
|
+
const performed = [];
|
|
1122
|
+
let previous = recipe.start - 1;
|
|
1123
|
+
let lastShot;
|
|
1124
|
+
const started = Date.now();
|
|
1125
|
+
phases.active = true;
|
|
1126
|
+
for (let index = 0; index < total; index += 1) {
|
|
1127
|
+
const frameStarted = performance.now();
|
|
1128
|
+
// A cancelled job (client gone, Ctrl-C, deadline) stops here, between frames.
|
|
1129
|
+
throwIfCancelled(request.signal);
|
|
1130
|
+
request.onProgress?.({ stage: "capture", frame: index, frames: total });
|
|
1131
|
+
const time = frameTime(recipe, index);
|
|
1132
|
+
for (const action of actionsBetween(recipe, previous, time)) {
|
|
1133
|
+
await phases.time("actions", async () => {
|
|
1134
|
+
await command({ op: "step", value: action.at });
|
|
1135
|
+
await command({ op: action.op, ...(action.target !== undefined ? { target: action.target } : {}), ...(action.value !== undefined ? { value: action.value } : {}) });
|
|
1136
|
+
});
|
|
1137
|
+
performed.push({ at: action.at, op: action.op, ...(action.target !== undefined ? { target: action.target } : {}), frame: index });
|
|
1138
|
+
}
|
|
1139
|
+
await phases.time("step", () => command({ op: "step", value: time }));
|
|
1140
|
+
const reached = await phases.time("arrival", () => arrived(time, index));
|
|
1141
|
+
await phases.time("paint", () => waitForCapturePaint(page));
|
|
1142
|
+
const png = await phases.time("screenshot", () => captureViewportPng(page, clip));
|
|
1143
|
+
const writeStarted = performance.now();
|
|
1144
|
+
const size = pngSize(png);
|
|
1145
|
+
if (size.width !== recipe.width || size.height !== recipe.height) {
|
|
1146
|
+
throw new CliError(`Frame ${index} is ${size.width}x${size.height}, not ${recipe.width}x${recipe.height}.`, "VIDEO_FRAME_SIZE_MISMATCH", 1, { frame: index, size });
|
|
1147
|
+
}
|
|
1148
|
+
hashes.push(sha256(png));
|
|
1149
|
+
await writeFile(path.join(request.framesDirectory, `${String(index).padStart(6, "0")}.png`), png);
|
|
1150
|
+
phases.add("hash_and_write", performance.now() - writeStarted);
|
|
1151
|
+
phases.add("frame_total", performance.now() - frameStarted);
|
|
1152
|
+
const shot = reached.learner_camera?.shot ?? null;
|
|
1153
|
+
if (index === 0 || shot !== lastShot)
|
|
1154
|
+
shots.push({ frame: index, at: time, shot });
|
|
1155
|
+
lastShot = shot;
|
|
1156
|
+
previous = time;
|
|
1157
|
+
}
|
|
1158
|
+
phases.active = false;
|
|
1159
|
+
request.onProgress?.({ stage: "capture", frame: total, frames: total });
|
|
1160
|
+
return {
|
|
1161
|
+
frames: total,
|
|
1162
|
+
frame_size: { width: recipe.width, height: recipe.height, device_scale_factor: 1, verified_frames: total },
|
|
1163
|
+
frame_sha256: hashes,
|
|
1164
|
+
frames_sha256: sha256(hashes.join("\n")),
|
|
1165
|
+
learner_surface: { ...clip, kind: "presentation_host" },
|
|
1166
|
+
camera_shots: shots,
|
|
1167
|
+
actions: performed,
|
|
1168
|
+
capture_seconds: (Date.now() - started) / 1000,
|
|
1169
|
+
capture_phases: phases.summary(total),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/** Every logged sound's canonical bytes, written once by key; checked against the logged SHA-256. */
|
|
1173
|
+
async collectAudio(events, directory, recipe, signal, bytesOf) {
|
|
1174
|
+
const sources = {};
|
|
1175
|
+
if (recipe.audio === "scene") {
|
|
1176
|
+
await mkdir(directory, { recursive: true });
|
|
1177
|
+
for (const event of events) {
|
|
1178
|
+
const key = audioKey(event);
|
|
1179
|
+
if (event.action !== "play" || sources[key])
|
|
1180
|
+
continue;
|
|
1181
|
+
throwIfCancelled(signal);
|
|
1182
|
+
if (Object.keys(sources).length >= 32)
|
|
1183
|
+
throw new CliError("A render mixes at most 32 distinct sounds.", "VIDEO_AUDIO_TOO_MANY", 2);
|
|
1184
|
+
const data = Buffer.from(await bytesOf(event), "base64");
|
|
1185
|
+
const digest = sha256(data);
|
|
1186
|
+
if (event.sha256 && event.sha256 !== digest) {
|
|
1187
|
+
throw new CliError(`Sound ${key} bytes do not match their declared SHA-256.`, "VIDEO_AUDIO_SOURCE_MISMATCH", 1, { expected: event.sha256, actual: digest });
|
|
1188
|
+
}
|
|
1189
|
+
const file = path.join(directory, `${digest}.m4a`);
|
|
1190
|
+
await writeFile(file, data);
|
|
1191
|
+
sources[key] = { file, sha256: digest, bytes: data.length };
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return { events, sources };
|
|
673
1195
|
}
|
|
674
1196
|
/** Local-agent capture from the same browser page and renderer the Maker sees. */
|
|
675
1197
|
async capturePreview(request) {
|