@kheleshikhi/studio-agent 0.6.1 → 0.8.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 +215 -13
- package/dist/browser.d.ts +74 -2
- package/dist/browser.js +472 -12
- package/dist/browser.js.map +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +515 -55
- 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 +41 -3
- package/dist/daemon.js.map +1 -1
- package/dist/presence.d.ts +49 -0
- package/dist/presence.js +141 -0
- package/dist/presence.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/video.d.ts +164 -0
- package/dist/video.js +385 -0
- package/dist/video.js.map +1 -0
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { actionsBetween, audioKey, frameCount, frameTime, 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";
|
|
5
6
|
import { CliError } from "./types.js";
|
|
6
7
|
import { assertCurrentObservation, ObservationSampler, observationSettings } from "./observation.js";
|
|
7
|
-
import { isDriveCommand, matchesObservation } from "./scenario.js";
|
|
8
|
+
import { evidenceMode, isDriveCommand, matchesObservation, isExperienceCommand, matchesExperienceObservation, observedValue, stepSummary } from "./scenario.js";
|
|
8
9
|
export function studioBrowserLaunchOptions(headless) {
|
|
9
10
|
return {
|
|
10
11
|
headless,
|
|
@@ -76,7 +77,7 @@ async function waitForVisualReady(page, timeoutMs, stage) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
const viewportSessions = new WeakMap();
|
|
79
|
-
export async function applyCssViewport(page, dimensions) {
|
|
80
|
+
export async function applyCssViewport(page, dimensions, deviceScaleFactor = 0) {
|
|
80
81
|
const nativeScreen = await page.evaluate(() => ({
|
|
81
82
|
width: window.screen.width,
|
|
82
83
|
height: window.screen.height,
|
|
@@ -92,11 +93,12 @@ export async function applyCssViewport(page, dimensions) {
|
|
|
92
93
|
}
|
|
93
94
|
// The override belongs to its CDP session. Detaching here silently restores
|
|
94
95
|
// the original viewport on Chromium, so keep the session until page close.
|
|
95
|
-
// deviceScaleFactor 0 leaves the host display's native scale intact
|
|
96
|
+
// deviceScaleFactor 0 leaves the host display's native scale intact; a
|
|
97
|
+
// presentation render asks for 1, so one CSS pixel is one output pixel.
|
|
96
98
|
await session.send("Emulation.setDeviceMetricsOverride", {
|
|
97
99
|
width: dimensions.width,
|
|
98
100
|
height: dimensions.height,
|
|
99
|
-
deviceScaleFactor
|
|
101
|
+
deviceScaleFactor,
|
|
100
102
|
mobile: false,
|
|
101
103
|
screenWidth: nativeScreen.width,
|
|
102
104
|
screenHeight: nativeScreen.height,
|
|
@@ -249,6 +251,21 @@ export class BrowserCredentialManager {
|
|
|
249
251
|
return this.credential;
|
|
250
252
|
}
|
|
251
253
|
}
|
|
254
|
+
async function notify(listener, report) {
|
|
255
|
+
if (!listener)
|
|
256
|
+
return;
|
|
257
|
+
try {
|
|
258
|
+
await listener(report);
|
|
259
|
+
}
|
|
260
|
+
catch { /* Presence is supplementary. */ }
|
|
261
|
+
}
|
|
262
|
+
function driveOutcome(kind, steps, results, summaries, final) {
|
|
263
|
+
const observations = [...results.map((item) => (item.observation ?? item.actual)), final];
|
|
264
|
+
return {
|
|
265
|
+
evidence_mode: evidenceMode(kind, steps, observations),
|
|
266
|
+
step_summaries: summaries,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
252
269
|
const LIBRARY_WORKSPACES = new Set(["experiences", "audio", "components", "behaviors"]);
|
|
253
270
|
export class StudioBrowser {
|
|
254
271
|
api;
|
|
@@ -364,7 +381,7 @@ export class StudioBrowser {
|
|
|
364
381
|
throw new CliError(`Unknown Studio view: ${view}`, "STUDIO_VIEW_INVALID", 2);
|
|
365
382
|
const { page, webOrigin } = await this.authenticatedPage();
|
|
366
383
|
const dimensions = cssViewport(request.viewport || "desktop");
|
|
367
|
-
await applyCssViewport(page, dimensions);
|
|
384
|
+
await applyCssViewport(page, dimensions, request.deviceScaleFactor ?? 0);
|
|
368
385
|
const consoleErrors = [];
|
|
369
386
|
const pageErrors = [];
|
|
370
387
|
const requestFailures = [];
|
|
@@ -397,9 +414,10 @@ export class StudioBrowser {
|
|
|
397
414
|
method: response.request().method(),
|
|
398
415
|
});
|
|
399
416
|
});
|
|
417
|
+
const present = request.present ? { studio_present: "1" } : {};
|
|
400
418
|
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);
|
|
419
|
+
? 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)
|
|
420
|
+
: new URL(`/maker/interactive/studio/${encodeURIComponent(request.asset)}?${new URLSearchParams({ ...VIEW_QUERY[view], studio_observation: "1", ...(request.drive ? { studio_drive: "1" } : {}), ...present })}`, webOrigin);
|
|
403
421
|
await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: request.timeoutMs || 30_000 });
|
|
404
422
|
const apiUnauthorized = httpErrors.some((item) => {
|
|
405
423
|
if (item.status !== 401)
|
|
@@ -517,6 +535,34 @@ export class StudioBrowser {
|
|
|
517
535
|
});
|
|
518
536
|
}
|
|
519
537
|
}
|
|
538
|
+
// An unsaved candidate is mounted next and waited for by its own hash;
|
|
539
|
+
// the saved revision need not (and, mid-iteration, often cannot) mount.
|
|
540
|
+
if (request.drive && request.library?.workspace === "experiences" && !request.awaitsCandidate) {
|
|
541
|
+
const deadline = Date.now() + (request.timeoutMs || 30_000);
|
|
542
|
+
while (Date.now() < deadline) {
|
|
543
|
+
observation = await page.evaluate(() => {
|
|
544
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
545
|
+
if (!raw)
|
|
546
|
+
return null;
|
|
547
|
+
try {
|
|
548
|
+
return JSON.parse(raw);
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
if (observation?.observation_scope === "live_experience_preview" &&
|
|
555
|
+
observation.mounted === true && typeof observation.active_state === "string")
|
|
556
|
+
break;
|
|
557
|
+
await page.waitForTimeout(125);
|
|
558
|
+
}
|
|
559
|
+
if (observation?.mounted !== true || typeof observation.active_state !== "string") {
|
|
560
|
+
throw new CliError("Experience Preview did not become ready for capture.", "EXPERIENCE_PREVIEW_TIMEOUT", 1, {
|
|
561
|
+
elapsed_ms: request.timeoutMs || 30_000,
|
|
562
|
+
observed: observation,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
520
566
|
// Runtime observation is emitted before Chromium necessarily composites
|
|
521
567
|
// the first visible frame. Wait for fonts and two post-ready paint frames
|
|
522
568
|
// rather than a time-based guess that varies by machine.
|
|
@@ -612,6 +658,7 @@ export class StudioBrowser {
|
|
|
612
658
|
throw new CliError("Candidate preview reported a runtime error.", "STUDIO_RUNTIME_ERROR", 1, ready);
|
|
613
659
|
}
|
|
614
660
|
const results = [];
|
|
661
|
+
const summaries = [];
|
|
615
662
|
const observation = async () => page.evaluate(() => {
|
|
616
663
|
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
617
664
|
return raw ? JSON.parse(raw) : {};
|
|
@@ -630,7 +677,7 @@ export class StudioBrowser {
|
|
|
630
677
|
node.hidden = true;
|
|
631
678
|
document.body.append(node);
|
|
632
679
|
}
|
|
633
|
-
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value });
|
|
680
|
+
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value, setup: step.setup });
|
|
634
681
|
}, { id, step });
|
|
635
682
|
const result = await page.waitForFunction((expected) => {
|
|
636
683
|
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
@@ -642,9 +689,24 @@ export class StudioBrowser {
|
|
|
642
689
|
const lastObserved = await page.evaluate(() => document.getElementById("kheleshikhi-studio-drive-result")?.textContent?.slice(0, 500) || null);
|
|
643
690
|
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
691
|
});
|
|
692
|
+
summaries.push(stepSummary(step, result.ok === true));
|
|
693
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: result.observation });
|
|
645
694
|
if (result.ok !== true)
|
|
646
|
-
throw new CliError(String(result.error || "Semantic command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, step, result });
|
|
695
|
+
throw new CliError(String(result.error || "Semantic command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, step, result, step_summaries: summaries });
|
|
647
696
|
results.push({ index, op: step.op, observation: result.observation });
|
|
697
|
+
if (step.op === "restart") {
|
|
698
|
+
// A restart remounts the production renderer; later steps act on the
|
|
699
|
+
// fresh runtime, not on the one being torn down.
|
|
700
|
+
await page.waitForFunction(() => {
|
|
701
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
702
|
+
if (!raw)
|
|
703
|
+
return false;
|
|
704
|
+
const value = JSON.parse(raw);
|
|
705
|
+
return value.runtime_ready === true && value.visual_ready === true;
|
|
706
|
+
}, undefined, { timeout: step.timeoutMs || 15_000 }).catch(() => {
|
|
707
|
+
throw new CliError("The runtime did not become ready after restart.", "STUDIO_DRIVE_TIMEOUT", 1, { stage: "restart", step: index });
|
|
708
|
+
});
|
|
709
|
+
}
|
|
648
710
|
continue;
|
|
649
711
|
}
|
|
650
712
|
if (step.op === "capture") {
|
|
@@ -653,7 +715,10 @@ export class StudioBrowser {
|
|
|
653
715
|
await waitForVisualReady(page, step.timeoutMs || 10_000, `scenario_capture_${index}`);
|
|
654
716
|
await waitForCapturePaint(page);
|
|
655
717
|
await page.screenshot({ path: output, scale: "device" });
|
|
656
|
-
|
|
718
|
+
const captured = await observation();
|
|
719
|
+
results.push({ index, op: step.op, path: output, observation: captured });
|
|
720
|
+
summaries.push(stepSummary(step, true));
|
|
721
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: captured });
|
|
657
722
|
continue;
|
|
658
723
|
}
|
|
659
724
|
let current = await observation();
|
|
@@ -664,12 +729,407 @@ export class StudioBrowser {
|
|
|
664
729
|
current = await observation();
|
|
665
730
|
}
|
|
666
731
|
}
|
|
732
|
+
summaries.push(stepSummary(step, matchesObservation(current, step), observedValue(current, step.path || "")));
|
|
733
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: current });
|
|
667
734
|
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 });
|
|
735
|
+
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
736
|
}
|
|
670
737
|
results.push({ index, op: step.op, path: step.path, actual: current });
|
|
671
738
|
}
|
|
672
|
-
|
|
739
|
+
const final = await observation();
|
|
740
|
+
return { source_hash: request.candidateHash, initial: first, steps: results, final,
|
|
741
|
+
...driveOutcome("scene", request.scenario.steps, results, summaries, final) };
|
|
742
|
+
}
|
|
743
|
+
async driveExperience(request) {
|
|
744
|
+
if (request.library?.workspace !== "experiences")
|
|
745
|
+
throw new CliError("Experience drive requires the experiences workspace.", "STUDIO_VIEW_INVALID", 2);
|
|
746
|
+
const initial = await this.capture({ ...request, drive: true, observation: false,
|
|
747
|
+
awaitsCandidate: Boolean(request.candidateHash) });
|
|
748
|
+
const page = this.page;
|
|
749
|
+
if (!page)
|
|
750
|
+
throw new CliError("Studio browser is unavailable.", "AGENT_BROWSER_NOT_READY");
|
|
751
|
+
const observation = async () => page.evaluate(() => {
|
|
752
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
753
|
+
return raw ? JSON.parse(raw) : {};
|
|
754
|
+
});
|
|
755
|
+
if (request.candidateHash) {
|
|
756
|
+
// Mount the exact unsaved local Source in this page's Draft Preview.
|
|
757
|
+
// The page answers with the server's canonical hash of what mounted.
|
|
758
|
+
await page.evaluate((payload) => {
|
|
759
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
760
|
+
if (!node) {
|
|
761
|
+
node = document.createElement("div");
|
|
762
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
763
|
+
node.hidden = true;
|
|
764
|
+
document.body.append(node);
|
|
765
|
+
}
|
|
766
|
+
node.textContent = JSON.stringify(payload);
|
|
767
|
+
}, { id: "candidate", op: "loadCandidate", source: request.candidate, candidateHash: request.candidateHash });
|
|
768
|
+
const reply = await page.waitForFunction(() => {
|
|
769
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
770
|
+
if (!raw)
|
|
771
|
+
return false;
|
|
772
|
+
const value = JSON.parse(raw);
|
|
773
|
+
return value.id === "candidate" ? value : false;
|
|
774
|
+
}, undefined, { timeout: Math.max(request.timeoutMs || 30_000, 50_000) })
|
|
775
|
+
.then((handle) => handle.jsonValue())
|
|
776
|
+
.catch(() => { throw new CliError("The unsaved Experience did not mount.", "EXPERIENCE_PREVIEW_TIMEOUT", 1); });
|
|
777
|
+
if (reply.ok !== true)
|
|
778
|
+
throw new CliError(String(reply.error || "The unsaved Experience was refused."), "EXPERIENCE_SOURCE_INVALID", 1, { reply });
|
|
779
|
+
if (reply.source_hash !== request.candidateHash)
|
|
780
|
+
throw new CliError("Studio mounted a different Experience source.", "STUDIO_SOURCE_MISMATCH", 3, {
|
|
781
|
+
expected: request.candidateHash, actual: reply.source_hash
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
await page.waitForFunction((expected) => {
|
|
785
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
786
|
+
if (!raw)
|
|
787
|
+
return false;
|
|
788
|
+
const state = JSON.parse(raw);
|
|
789
|
+
return state.observation_scope === "live_experience_preview" && state.mounted === true &&
|
|
790
|
+
typeof state.active_state === "string" && (!expected || state.source_hash === expected);
|
|
791
|
+
}, request.candidateHash ?? null, { timeout: request.timeoutMs || 30_000 }).catch(() => {
|
|
792
|
+
throw new CliError("Experience Preview did not mount.", "EXPERIENCE_PREVIEW_TIMEOUT", 1);
|
|
793
|
+
});
|
|
794
|
+
if (request.candidateHash) {
|
|
795
|
+
// The initial capture showed the saved revision; retake it from the
|
|
796
|
+
// candidate that is actually being driven.
|
|
797
|
+
await waitForCapturePaint(page);
|
|
798
|
+
await page.screenshot({ path: path.resolve(request.out) });
|
|
799
|
+
initial.source_origin = "local";
|
|
800
|
+
initial.observation = await observation();
|
|
801
|
+
}
|
|
802
|
+
const results = [];
|
|
803
|
+
const summaries = [];
|
|
804
|
+
const started = Date.now();
|
|
805
|
+
for (const [index, step] of request.scenario.steps.entries()) {
|
|
806
|
+
if (Date.now() - started > 120_000)
|
|
807
|
+
throw new CliError("Experience scenario exceeded two minutes.", "STUDIO_SCENARIO_TIMEOUT");
|
|
808
|
+
if (isExperienceCommand(step)) {
|
|
809
|
+
const id = `experience-${index}`;
|
|
810
|
+
await page.evaluate(({ id, step }) => {
|
|
811
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
812
|
+
if (!node) {
|
|
813
|
+
node = document.createElement("div");
|
|
814
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
815
|
+
node.hidden = true;
|
|
816
|
+
document.body.append(node);
|
|
817
|
+
}
|
|
818
|
+
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value, setup: step.setup });
|
|
819
|
+
}, { id, step });
|
|
820
|
+
const result = await page.waitForFunction((expected) => {
|
|
821
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
822
|
+
if (!raw)
|
|
823
|
+
return false;
|
|
824
|
+
const reply = JSON.parse(raw);
|
|
825
|
+
return reply.id === expected ? reply : false;
|
|
826
|
+
}, id, { timeout: step.timeoutMs || 10_000 }).then((handle) => handle.jsonValue()).catch(() => {
|
|
827
|
+
throw new CliError("Experience command acknowledgement timed out.", "STUDIO_DRIVE_TIMEOUT", 1, { step: index, operation: step.op });
|
|
828
|
+
});
|
|
829
|
+
summaries.push(stepSummary(step, result.ok === true));
|
|
830
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: result.observation });
|
|
831
|
+
if (result.ok !== true)
|
|
832
|
+
throw new CliError(String(result.error || "Experience command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, result, step_summaries: summaries });
|
|
833
|
+
results.push({ index, op: step.op, observation: result.observation });
|
|
834
|
+
}
|
|
835
|
+
else if (step.op === "capture") {
|
|
836
|
+
const output = path.join(path.dirname(request.out), `step-${String(index).padStart(2, "0")}${step.name ? `-${step.name}` : ""}.png`);
|
|
837
|
+
await waitForCapturePaint(page);
|
|
838
|
+
await page.screenshot({ path: output, scale: "device" });
|
|
839
|
+
const captured = await observation();
|
|
840
|
+
results.push({ index, op: step.op, path: output, observation: captured });
|
|
841
|
+
summaries.push(stepSummary(step, true));
|
|
842
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: captured });
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
let current = await observation();
|
|
846
|
+
if (step.op === "wait") {
|
|
847
|
+
const deadline = Date.now() + (step.timeoutMs || 10_000);
|
|
848
|
+
while (!matchesExperienceObservation(current, step) && Date.now() < deadline) {
|
|
849
|
+
await page.waitForTimeout(100);
|
|
850
|
+
current = await observation();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const matched = matchesExperienceObservation(current, step);
|
|
854
|
+
summaries.push(stepSummary(step, matched, (step.path || "").split(".").reduce((value, key) => value && typeof value === "object" && Object.hasOwn(value, key) ? value[key] : undefined, current)));
|
|
855
|
+
await notify(request.onStep, { index, summary: summaries.at(-1), observation: current });
|
|
856
|
+
if (!matched)
|
|
857
|
+
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 });
|
|
858
|
+
results.push({ index, op: step.op, path: step.path, actual: current });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const final = await observation();
|
|
862
|
+
return { initial, steps: results, final,
|
|
863
|
+
...driveOutcome("experience", request.scenario.steps, results, summaries, final) };
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Presentation frames (STU-001) of a Scene, from the presentation host.
|
|
867
|
+
*
|
|
868
|
+
* Mounts and verifies the candidate exactly as `drive` does, but in the
|
|
869
|
+
* presentation host (`studio_present=1`) at a viewport of exactly the
|
|
870
|
+
* output size and device scale 1. `renderBegin` silences the Scene's
|
|
871
|
+
* sounds into a log and restarts it at 0 s; then for every frame video
|
|
872
|
+
* time is stepped to `start + k / fps`, the runtime confirms that instant
|
|
873
|
+
* and a paint, and the whole viewport is captured: one PNG of exactly the
|
|
874
|
+
* output size, proved before it is kept. Scheduled learner actions happen
|
|
875
|
+
* at their own seconds. Afterwards the audio log and each logged sound's
|
|
876
|
+
* canonical bytes are collected for the mixer.
|
|
877
|
+
*/
|
|
878
|
+
async renderFrames(request) {
|
|
879
|
+
const { recipe } = request;
|
|
880
|
+
const mounted = await this.drive({ ...request, viewport: `${recipe.width}x${recipe.height}`, present: true,
|
|
881
|
+
deviceScaleFactor: 1, scenario: { version: 1, steps: [{ op: "pause" }] } });
|
|
882
|
+
const page = this.requirePage();
|
|
883
|
+
const { observation, command } = this.renderChannel(page);
|
|
884
|
+
const first = await observation();
|
|
885
|
+
if (first.scene_clock_seconds === undefined || first.scene_clock_seconds === null) {
|
|
886
|
+
throw new CliError("This Scene has no SceneClock; a video needs logical time.", "VIDEO_NO_CLOCK", 2);
|
|
887
|
+
}
|
|
888
|
+
await command({ op: "renderBegin" });
|
|
889
|
+
await this.waitForRender(page, (value) => value.visual_ready === true, "the Scene to repaint after restarting");
|
|
890
|
+
await command({ op: "step", value: recipe.start });
|
|
891
|
+
// Video time is the master; the SceneClock follows it only while it
|
|
892
|
+
// plays, so an authored pause holds the picture. Record each stretch.
|
|
893
|
+
const clock = [];
|
|
894
|
+
let lastPlaying;
|
|
895
|
+
const frames = await this.captureFrames(page, request, async (time, index) => {
|
|
896
|
+
const reached = await this.waitForRender(page, (value) => typeof value.render_video_seconds === "number" &&
|
|
897
|
+
Math.abs(value.render_video_seconds - time) <= 1e-6 && value.visual_ready === true, "the Scene to stand at the frame and paint");
|
|
898
|
+
if (typeof reached.scene_clock_seconds !== "number") {
|
|
899
|
+
throw new CliError("The Scene reported no SceneClock time.", "VIDEO_CLOCK_DRIFT", 1, { frame: index });
|
|
900
|
+
}
|
|
901
|
+
if (index === 0 || reached.scene_clock_playing !== lastPlaying) {
|
|
902
|
+
clock.push({ frame: index, video_seconds: time, scene_seconds: reached.scene_clock_seconds, playing: reached.scene_clock_playing === true });
|
|
903
|
+
}
|
|
904
|
+
lastPlaying = reached.scene_clock_playing;
|
|
905
|
+
return reached;
|
|
906
|
+
}, command);
|
|
907
|
+
const log = await command({ op: "renderLog" });
|
|
908
|
+
const audio = await this.collectAudio(log.audio ?? [], request.audioDirectory, recipe, async (event) => (await command({ op: "audioBytes", target: event.id }, 30_000)).bytes);
|
|
909
|
+
const final = await observation();
|
|
910
|
+
// Real-time playback timing on the same mounted learner preview: the
|
|
911
|
+
// clock plays from the recipe's start and every animation frame interval
|
|
912
|
+
// is sampled. Diagnostic only; it never touches the rendered frames.
|
|
913
|
+
let playback;
|
|
914
|
+
const seconds = Math.min(30, Math.max(0, request.measurePlaybackSeconds ?? 0));
|
|
915
|
+
if (seconds > 0) {
|
|
916
|
+
// Video time only moves forward; real-time playback seeks instead.
|
|
917
|
+
await command({ op: "reset" });
|
|
918
|
+
await command({ op: "seek", value: recipe.start });
|
|
919
|
+
await command({ op: "play" });
|
|
920
|
+
const timing = await page.evaluate(async (milliseconds) => {
|
|
921
|
+
const deltas = [];
|
|
922
|
+
let last = performance.now();
|
|
923
|
+
const end = last + milliseconds;
|
|
924
|
+
await new Promise((resolve) => {
|
|
925
|
+
const tick = (now) => { deltas.push(now - last); last = now; if (now < end)
|
|
926
|
+
requestAnimationFrame(tick);
|
|
927
|
+
else
|
|
928
|
+
resolve(); };
|
|
929
|
+
requestAnimationFrame(tick);
|
|
930
|
+
});
|
|
931
|
+
const memory = performance.memory;
|
|
932
|
+
return { deltas, heap: memory ? memory.usedJSHeapSize : null };
|
|
933
|
+
}, seconds * 1000);
|
|
934
|
+
await command({ op: "pause" });
|
|
935
|
+
const sorted = [...timing.deltas].sort((a, b) => a - b);
|
|
936
|
+
const at = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
|
|
937
|
+
const reached = await observation();
|
|
938
|
+
playback = {
|
|
939
|
+
seconds, frames: sorted.length,
|
|
940
|
+
p50_ms: Number(at(0.5).toFixed(2)), p95_ms: Number(at(0.95).toFixed(2)), p99_ms: Number(at(0.99).toFixed(2)),
|
|
941
|
+
max_ms: Number((sorted.at(-1) ?? 0).toFixed(2)), over_33ms: sorted.filter((value) => value > 33.4).length,
|
|
942
|
+
js_heap_mb: timing.heap ? Math.round(timing.heap / 1048576) : null,
|
|
943
|
+
scene_clock_after: reached.scene_clock_seconds,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
...(playback ? { playback_timing: playback } : {}),
|
|
948
|
+
...frames,
|
|
949
|
+
audio,
|
|
950
|
+
scene_clock: clock,
|
|
951
|
+
runtime_fingerprint: first.runtime_fingerprint,
|
|
952
|
+
source_hash: first.source_hash,
|
|
953
|
+
source_revision: first.source_revision,
|
|
954
|
+
mounted_evidence: mounted.evidence_mode,
|
|
955
|
+
final_states: final.active_states,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Presentation frames of an Experience: the canonical Experience player
|
|
960
|
+
* in the presentation host, driven by video time (`renderBegin`, then
|
|
961
|
+
* `step` per frame). The master timeline advances with video time only
|
|
962
|
+
* while it runs, so a learner pause or a wait for a choice stays paused,
|
|
963
|
+
* as it would in playback; Scenes change, cues fire and sounds start at
|
|
964
|
+
* the exact video second they would.
|
|
965
|
+
*/
|
|
966
|
+
async renderExperienceFrames(request) {
|
|
967
|
+
const { recipe } = request;
|
|
968
|
+
await this.driveExperience({ ...request, viewport: `${recipe.width}x${recipe.height}`, present: true,
|
|
969
|
+
deviceScaleFactor: 1, scenario: { version: 1, steps: [] }, out: path.join(request.audioDirectory, "..", "mounted.png") });
|
|
970
|
+
const page = this.requirePage();
|
|
971
|
+
const { observation, command } = this.renderChannel(page);
|
|
972
|
+
const first = await observation();
|
|
973
|
+
await command({ op: "renderBegin" });
|
|
974
|
+
await this.waitForRender(page, (value) => value.mounted === true && value.stage_ready === true, "the Experience stage to paint");
|
|
975
|
+
await command({ op: "step", value: recipe.start });
|
|
976
|
+
const scenes = [];
|
|
977
|
+
let lastScene;
|
|
978
|
+
const frames = await this.captureFrames(page, request, async (time, index) => {
|
|
979
|
+
const reached = await this.waitForRender(page, (value) => value.stage_ready === true, "the Experience stage to paint");
|
|
980
|
+
if (typeof reached.render_video_ms !== "number" || Math.abs(reached.render_video_ms - time * 1000) > 1) {
|
|
981
|
+
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 });
|
|
982
|
+
}
|
|
983
|
+
if (index === 0 || reached.active_scene !== lastScene)
|
|
984
|
+
scenes.push({ frame: index, at: time, scene: reached.active_scene, state: reached.active_state });
|
|
985
|
+
lastScene = reached.active_scene;
|
|
986
|
+
return reached;
|
|
987
|
+
}, command);
|
|
988
|
+
const log = await command({ op: "renderLog" });
|
|
989
|
+
const audio = await this.collectAudio(log.audio ?? [], request.audioDirectory, recipe, async (event) => (await command({ op: "audioBytes", target: event.id, ...(event.scene ? { scene: event.scene } : {}) }, 30_000)).bytes);
|
|
990
|
+
const final = await observation();
|
|
991
|
+
return {
|
|
992
|
+
...frames,
|
|
993
|
+
audio,
|
|
994
|
+
scenes,
|
|
995
|
+
source_hash: first.source_hash,
|
|
996
|
+
source_origin: first.source_origin,
|
|
997
|
+
final_state: final.active_state,
|
|
998
|
+
final_scene: final.active_scene,
|
|
999
|
+
timeline_ms: final.timeline_ms,
|
|
1000
|
+
completed: final.completed,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
requirePage() {
|
|
1004
|
+
if (!this.page)
|
|
1005
|
+
throw new CliError("Studio browser is unavailable.", "AGENT_BROWSER_NOT_READY");
|
|
1006
|
+
return this.page;
|
|
1007
|
+
}
|
|
1008
|
+
/** The page's runtime observation and its one-at-a-time drive channel. */
|
|
1009
|
+
renderChannel(page) {
|
|
1010
|
+
const observation = async () => page.evaluate(() => {
|
|
1011
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
1012
|
+
return raw ? JSON.parse(raw) : {};
|
|
1013
|
+
});
|
|
1014
|
+
let sequence = 0;
|
|
1015
|
+
const command = async (payload, timeoutMs = 10_000) => {
|
|
1016
|
+
const id = `render-${sequence += 1}`;
|
|
1017
|
+
await page.evaluate(({ id, payload }) => {
|
|
1018
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
1019
|
+
if (!node) {
|
|
1020
|
+
node = document.createElement("div");
|
|
1021
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
1022
|
+
node.hidden = true;
|
|
1023
|
+
document.body.append(node);
|
|
1024
|
+
}
|
|
1025
|
+
node.textContent = JSON.stringify({ id, ...payload });
|
|
1026
|
+
}, { id, payload });
|
|
1027
|
+
const reply = await page.waitForFunction((expected) => {
|
|
1028
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
1029
|
+
if (!raw)
|
|
1030
|
+
return false;
|
|
1031
|
+
const value = JSON.parse(raw);
|
|
1032
|
+
return value.id === expected ? value : false;
|
|
1033
|
+
}, id, { timeout: timeoutMs }).then((handle) => handle.jsonValue()).catch(() => {
|
|
1034
|
+
throw new CliError("The presentation host did not acknowledge a render step.", "VIDEO_STEP_TIMEOUT", 1, { payload: { ...payload, bytes: undefined } });
|
|
1035
|
+
});
|
|
1036
|
+
if (reply.ok !== true)
|
|
1037
|
+
throw new CliError(String(reply.error || "Render step failed."), "VIDEO_STEP_FAILED", 1, { payload, reply });
|
|
1038
|
+
return reply;
|
|
1039
|
+
};
|
|
1040
|
+
return { observation, command };
|
|
1041
|
+
}
|
|
1042
|
+
/** The newest observation once [ready] holds (the page publishes every 150 ms). */
|
|
1043
|
+
async waitForRender(page, ready, what, timeoutMs = 20_000) {
|
|
1044
|
+
const deadline = Date.now() + timeoutMs;
|
|
1045
|
+
for (;;) {
|
|
1046
|
+
const value = await page.evaluate(() => {
|
|
1047
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
1048
|
+
return raw ? JSON.parse(raw) : {};
|
|
1049
|
+
});
|
|
1050
|
+
if (ready(value))
|
|
1051
|
+
return value;
|
|
1052
|
+
if (Date.now() > deadline)
|
|
1053
|
+
throw new CliError(`Timed out waiting for ${what}.`, "VIDEO_STAGE_NOT_READY", 1, { observed: value });
|
|
1054
|
+
await page.waitForTimeout(40);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The frame loop both subjects share: actions at their seconds, a step to
|
|
1059
|
+
* each frame's exact second, the subject's own arrival check, a paint,
|
|
1060
|
+
* and a whole-viewport PNG proved to be exactly the output size.
|
|
1061
|
+
*/
|
|
1062
|
+
async captureFrames(page, request, arrived, command) {
|
|
1063
|
+
const { recipe } = request;
|
|
1064
|
+
await mkdir(request.framesDirectory, { recursive: true });
|
|
1065
|
+
const viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio }));
|
|
1066
|
+
if (viewport.width !== recipe.width || viewport.height !== recipe.height || viewport.dpr !== 1) {
|
|
1067
|
+
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 } });
|
|
1068
|
+
}
|
|
1069
|
+
const clip = { x: 0, y: 0, width: recipe.width, height: recipe.height };
|
|
1070
|
+
const total = frameCount(recipe);
|
|
1071
|
+
const hashes = [];
|
|
1072
|
+
const shots = [];
|
|
1073
|
+
const performed = [];
|
|
1074
|
+
let previous = recipe.start - 1;
|
|
1075
|
+
let lastShot;
|
|
1076
|
+
const started = Date.now();
|
|
1077
|
+
for (let index = 0; index < total; index += 1) {
|
|
1078
|
+
const time = frameTime(recipe, index);
|
|
1079
|
+
for (const action of actionsBetween(recipe, previous, time)) {
|
|
1080
|
+
await command({ op: "step", value: action.at });
|
|
1081
|
+
await command({ op: action.op, ...(action.target !== undefined ? { target: action.target } : {}), ...(action.value !== undefined ? { value: action.value } : {}) });
|
|
1082
|
+
performed.push({ at: action.at, op: action.op, ...(action.target !== undefined ? { target: action.target } : {}), frame: index });
|
|
1083
|
+
}
|
|
1084
|
+
await command({ op: "step", value: time });
|
|
1085
|
+
const reached = await arrived(time, index);
|
|
1086
|
+
await waitForCapturePaint(page);
|
|
1087
|
+
const png = await page.screenshot({ type: "png", scale: "css", clip });
|
|
1088
|
+
const size = pngSize(png);
|
|
1089
|
+
if (size.width !== recipe.width || size.height !== recipe.height) {
|
|
1090
|
+
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 });
|
|
1091
|
+
}
|
|
1092
|
+
hashes.push(sha256(png));
|
|
1093
|
+
await writeFile(path.join(request.framesDirectory, `${String(index).padStart(6, "0")}.png`), png);
|
|
1094
|
+
const shot = reached.learner_camera?.shot ?? null;
|
|
1095
|
+
if (index === 0 || shot !== lastShot)
|
|
1096
|
+
shots.push({ frame: index, at: time, shot });
|
|
1097
|
+
lastShot = shot;
|
|
1098
|
+
previous = time;
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
frames: total,
|
|
1102
|
+
frame_size: { width: recipe.width, height: recipe.height, device_scale_factor: 1, verified_frames: total },
|
|
1103
|
+
frame_sha256: hashes,
|
|
1104
|
+
frames_sha256: sha256(hashes.join("\n")),
|
|
1105
|
+
learner_surface: { ...clip, kind: "presentation_host" },
|
|
1106
|
+
camera_shots: shots,
|
|
1107
|
+
actions: performed,
|
|
1108
|
+
capture_seconds: (Date.now() - started) / 1000,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
/** Every logged sound's canonical bytes, written once by key; checked against the logged SHA-256. */
|
|
1112
|
+
async collectAudio(events, directory, recipe, bytesOf) {
|
|
1113
|
+
const sources = {};
|
|
1114
|
+
if (recipe.audio === "scene") {
|
|
1115
|
+
await mkdir(directory, { recursive: true });
|
|
1116
|
+
for (const event of events) {
|
|
1117
|
+
const key = audioKey(event);
|
|
1118
|
+
if (event.action !== "play" || sources[key])
|
|
1119
|
+
continue;
|
|
1120
|
+
if (Object.keys(sources).length >= 32)
|
|
1121
|
+
throw new CliError("A render mixes at most 32 distinct sounds.", "VIDEO_AUDIO_TOO_MANY", 2);
|
|
1122
|
+
const data = Buffer.from(await bytesOf(event), "base64");
|
|
1123
|
+
const digest = sha256(data);
|
|
1124
|
+
if (event.sha256 && event.sha256 !== digest) {
|
|
1125
|
+
throw new CliError(`Sound ${key} bytes do not match their declared SHA-256.`, "VIDEO_AUDIO_SOURCE_MISMATCH", 1, { expected: event.sha256, actual: digest });
|
|
1126
|
+
}
|
|
1127
|
+
const file = path.join(directory, `${digest}.m4a`);
|
|
1128
|
+
await writeFile(file, data);
|
|
1129
|
+
sources[key] = { file, sha256: digest, bytes: data.length };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return { events, sources };
|
|
673
1133
|
}
|
|
674
1134
|
/** Local-agent capture from the same browser page and renderer the Maker sees. */
|
|
675
1135
|
async capturePreview(request) {
|