@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/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: 0,
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
- constructor(api) {
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(), "browser-profile");
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
- results.push({ index, op: step.op, path: output, observation: await observation() });
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
- return { source_hash: request.candidateHash, initial: first, steps: results, final: await observation() };
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) {