@kheleshikhi/studio-agent 0.6.1
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 +225 -0
- package/dist/attempt.d.ts +25 -0
- package/dist/attempt.js +197 -0
- package/dist/attempt.js.map +1 -0
- package/dist/browser.d.ts +102 -0
- package/dist/browser.js +837 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +1198 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon.d.ts +17 -0
- package/dist/daemon.js +208 -0
- package/dist/daemon.js.map +1 -0
- package/dist/library.d.ts +25 -0
- package/dist/library.js +120 -0
- package/dist/library.js.map +1 -0
- package/dist/observation.d.ts +31 -0
- package/dist/observation.js +66 -0
- package/dist/observation.js.map +1 -0
- package/dist/protocol.d.ts +20 -0
- package/dist/protocol.js +99 -0
- package/dist/protocol.js.map +1 -0
- package/dist/scenario.d.ts +19 -0
- package/dist/scenario.js +90 -0
- package/dist/scenario.js.map +1 -0
- package/dist/state.d.ts +13 -0
- package/dist/state.js +111 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +64 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +15 -0
- package/dist/workspace.js +66 -0
- package/dist/workspace.js.map +1 -0
- package/dist/zip.d.ts +7 -0
- package/dist/zip.js +76 -0
- package/dist/zip.js.map +1 -0
- package/package.json +36 -0
package/dist/browser.js
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { cacheDirectory } from "./state.js";
|
|
5
|
+
import { CliError } from "./types.js";
|
|
6
|
+
import { assertCurrentObservation, ObservationSampler, observationSettings } from "./observation.js";
|
|
7
|
+
import { isDriveCommand, matchesObservation } from "./scenario.js";
|
|
8
|
+
export function studioBrowserLaunchOptions(headless) {
|
|
9
|
+
return {
|
|
10
|
+
headless,
|
|
11
|
+
viewport: null,
|
|
12
|
+
// Playwright otherwise adds --no-sandbox to Chromium. The Studio Agent is
|
|
13
|
+
// a normal user-machine browser and must keep the browser sandbox enabled.
|
|
14
|
+
chromiumSandbox: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function browserProbeLaunchOptions(channel) {
|
|
18
|
+
return {
|
|
19
|
+
channel,
|
|
20
|
+
headless: true,
|
|
21
|
+
chromiumSandbox: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function cssViewport(value) {
|
|
25
|
+
if (value === "desktop")
|
|
26
|
+
return { width: 1440, height: 1000 };
|
|
27
|
+
if (value === "mobile")
|
|
28
|
+
return { width: 390, height: 844 };
|
|
29
|
+
const match = /^(\d{3,5})x(\d{3,5})$/.exec(value);
|
|
30
|
+
if (!match)
|
|
31
|
+
throw new CliError("Viewport must be desktop, mobile, or WIDTHxHEIGHT.", "STUDIO_VIEWPORT_INVALID", 2);
|
|
32
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
33
|
+
}
|
|
34
|
+
export async function waitForCapturePaint(page) {
|
|
35
|
+
try {
|
|
36
|
+
await page.evaluate(async () => {
|
|
37
|
+
await Promise.race([
|
|
38
|
+
document.fonts.ready.then(() => new Promise((resolve) => {
|
|
39
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
40
|
+
})),
|
|
41
|
+
new Promise((_, reject) => {
|
|
42
|
+
setTimeout(() => reject(new Error("capture paint timeout")), 8000);
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new CliError("Studio did not paint after runtime readiness.", "STUDIO_CAPTURE_PAINT_TIMEOUT");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function waitForVisualReady(page, timeoutMs, stage) {
|
|
52
|
+
try {
|
|
53
|
+
await page.waitForFunction(() => {
|
|
54
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return false;
|
|
57
|
+
const state = JSON.parse(raw);
|
|
58
|
+
return state.status === "complete" && state.visual_ready === true;
|
|
59
|
+
}, undefined, { timeout: timeoutMs });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
const lastObserved = await page.evaluate(() => {
|
|
63
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
64
|
+
if (!raw)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
throw new CliError("Studio visual surface did not become ready.", "STUDIO_VISUAL_NOT_READY", 1, {
|
|
74
|
+
stage, elapsed_ms: timeoutMs, ...readinessSummary(lastObserved),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const viewportSessions = new WeakMap();
|
|
79
|
+
export async function applyCssViewport(page, dimensions) {
|
|
80
|
+
const nativeScreen = await page.evaluate(() => ({
|
|
81
|
+
width: window.screen.width,
|
|
82
|
+
height: window.screen.height,
|
|
83
|
+
}));
|
|
84
|
+
let session = viewportSessions.get(page);
|
|
85
|
+
if (!session) {
|
|
86
|
+
session = await page.context().newCDPSession(page);
|
|
87
|
+
viewportSessions.set(page, session);
|
|
88
|
+
page.once("close", () => {
|
|
89
|
+
viewportSessions.delete(page);
|
|
90
|
+
void session?.detach().catch(() => undefined);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// The override belongs to its CDP session. Detaching here silently restores
|
|
94
|
+
// the original viewport on Chromium, so keep the session until page close.
|
|
95
|
+
// deviceScaleFactor 0 leaves the host display's native scale intact.
|
|
96
|
+
await session.send("Emulation.setDeviceMetricsOverride", {
|
|
97
|
+
width: dimensions.width,
|
|
98
|
+
height: dimensions.height,
|
|
99
|
+
deviceScaleFactor: 0,
|
|
100
|
+
mobile: false,
|
|
101
|
+
screenWidth: nativeScreen.width,
|
|
102
|
+
screenHeight: nativeScreen.height,
|
|
103
|
+
});
|
|
104
|
+
const actual = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }));
|
|
105
|
+
if (actual.width !== dimensions.width || actual.height !== dimensions.height) {
|
|
106
|
+
throw new CliError("Browser did not apply the requested CSS viewport.", "STUDIO_VIEWPORT_NOT_APPLIED", 1, {
|
|
107
|
+
requested: dimensions,
|
|
108
|
+
actual,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function renderingDiagnostics(page, screenshot) {
|
|
113
|
+
if (screenshot.length < 24 || screenshot.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") {
|
|
114
|
+
throw new CliError("Browser screenshot was not a valid PNG.", "STUDIO_SCREENSHOT_INVALID");
|
|
115
|
+
}
|
|
116
|
+
const display = await page.evaluate(() => ({
|
|
117
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
118
|
+
innerWidth: window.innerWidth,
|
|
119
|
+
innerHeight: window.innerHeight,
|
|
120
|
+
screenWidth: window.screen.width,
|
|
121
|
+
screenHeight: window.screen.height,
|
|
122
|
+
screenAvailWidth: window.screen.availWidth,
|
|
123
|
+
screenAvailHeight: window.screen.availHeight,
|
|
124
|
+
}));
|
|
125
|
+
return {
|
|
126
|
+
viewport: { width: display.innerWidth, height: display.innerHeight },
|
|
127
|
+
device_pixel_ratio: display.devicePixelRatio,
|
|
128
|
+
window: { inner_width: display.innerWidth, inner_height: display.innerHeight },
|
|
129
|
+
screen: {
|
|
130
|
+
width: display.screenWidth,
|
|
131
|
+
height: display.screenHeight,
|
|
132
|
+
available_width: display.screenAvailWidth,
|
|
133
|
+
available_height: display.screenAvailHeight,
|
|
134
|
+
},
|
|
135
|
+
screenshot_pixels: {
|
|
136
|
+
width: screenshot.readUInt32BE(16),
|
|
137
|
+
height: screenshot.readUInt32BE(20),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export const VIEW_QUERY = {
|
|
142
|
+
canvas: { workspace: "scene", view: "canvas" },
|
|
143
|
+
final: { workspace: "scene", view: "preview" },
|
|
144
|
+
learner: { workspace: "scene", view: "preview", studio_learner: "1" },
|
|
145
|
+
source: { workspace: "scene", panel: "source" },
|
|
146
|
+
problems: { workspace: "scene", panel: "problems" },
|
|
147
|
+
states: { workspace: "scene", view: "states", panel: "events-state" },
|
|
148
|
+
variables: { workspace: "scene", panel: "variables" },
|
|
149
|
+
bindings: { workspace: "scene", panel: "wiring" },
|
|
150
|
+
internals: { workspace: "scene", panel: "internals" },
|
|
151
|
+
certification: { workspace: "scene", panel: "certification" },
|
|
152
|
+
activity: { workspace: "scene", panel: "agent-activity" },
|
|
153
|
+
math: { workspace: "scene", view: "math" },
|
|
154
|
+
components: { workspace: "components" },
|
|
155
|
+
behaviors: { workspace: "behaviors" },
|
|
156
|
+
};
|
|
157
|
+
// Catalogue workspaces intentionally contain no mounted Scene runtime. They
|
|
158
|
+
// still support real-browser screenshots and diagnostics; runtime observation
|
|
159
|
+
// remains mandatory for every Scene workspace view.
|
|
160
|
+
const NON_RUNTIME_VIEWS = new Set(["components", "behaviors"]);
|
|
161
|
+
const BROWSER_TOKEN_REFRESH_MARGIN_MS = 60_000;
|
|
162
|
+
export function viewRequiresRuntimeObservation(view) {
|
|
163
|
+
return !NON_RUNTIME_VIEWS.has(view);
|
|
164
|
+
}
|
|
165
|
+
function safeUrl(raw) {
|
|
166
|
+
const url = new URL(raw);
|
|
167
|
+
// Query values and URL user-info can contain credentials even when their
|
|
168
|
+
// names look harmless. Route and host are enough to diagnose a failed load.
|
|
169
|
+
return `${url.origin}${url.pathname}`.slice(0, 300);
|
|
170
|
+
}
|
|
171
|
+
function boundedText(value) {
|
|
172
|
+
return value
|
|
173
|
+
.replace(/\b(?:Bearer\s+\S+|nbs_[A-Za-z0-9_-]+|nbp_[A-Za-z0-9_-]+)\b/gi, "[REDACTED]")
|
|
174
|
+
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g, "[REDACTED]")
|
|
175
|
+
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED]")
|
|
176
|
+
.slice(0, 400);
|
|
177
|
+
}
|
|
178
|
+
function readinessSummary(value) {
|
|
179
|
+
if (!value)
|
|
180
|
+
return { readiness_stage: "observation_missing" };
|
|
181
|
+
return {
|
|
182
|
+
readiness_stage: value.readiness_stage ?? null,
|
|
183
|
+
status: value.status ?? null,
|
|
184
|
+
source_hash: value.source_hash ?? null,
|
|
185
|
+
preview_is_current: value.preview_is_current ?? null,
|
|
186
|
+
runtime_ready: value.runtime_ready ?? null,
|
|
187
|
+
visual_ready: value.visual_ready ?? null,
|
|
188
|
+
visual_surface_available: value.visual_surface_available ?? null,
|
|
189
|
+
visual_bands: value.visual_bands ?? null,
|
|
190
|
+
renderers: Array.isArray(value.renderers) ? value.renderers.slice(0, 12).map((item) => {
|
|
191
|
+
const renderer = item;
|
|
192
|
+
return {
|
|
193
|
+
id: boundedText(String(renderer.id ?? "")),
|
|
194
|
+
type: boundedText(String(renderer.type ?? "")),
|
|
195
|
+
mounted: renderer.mounted === true,
|
|
196
|
+
phase: boundedText(String(renderer.phase ?? "")),
|
|
197
|
+
instance_id: boundedText(String(renderer.instance_id ?? "")),
|
|
198
|
+
error: renderer.error ? boundedText(String(renderer.error)) : null,
|
|
199
|
+
};
|
|
200
|
+
}) : [],
|
|
201
|
+
error_summary: value.error_summary ? boundedText(String(value.error_summary)) : null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Memory-only reuse of the short-lived browser JWT; bootstrap tickets stay one-use. */
|
|
205
|
+
export class BrowserCredentialManager {
|
|
206
|
+
api;
|
|
207
|
+
now;
|
|
208
|
+
credential;
|
|
209
|
+
bootstrapCount = 0;
|
|
210
|
+
apiOrigin;
|
|
211
|
+
constructor(api, now = Date.now) {
|
|
212
|
+
this.api = api;
|
|
213
|
+
this.now = now;
|
|
214
|
+
this.apiOrigin = new URL(api.origin).origin;
|
|
215
|
+
}
|
|
216
|
+
clear() {
|
|
217
|
+
this.credential = undefined;
|
|
218
|
+
}
|
|
219
|
+
diagnostics() {
|
|
220
|
+
return {
|
|
221
|
+
browser_credential_cached: this.credential !== undefined,
|
|
222
|
+
browser_bootstrap_count: this.bootstrapCount,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
tokenForRequest(requestUrl) {
|
|
226
|
+
if (!this.credential || new URL(requestUrl).origin !== this.apiOrigin)
|
|
227
|
+
return undefined;
|
|
228
|
+
return this.credential.token;
|
|
229
|
+
}
|
|
230
|
+
async get(forceRefresh = false) {
|
|
231
|
+
if (!forceRefresh
|
|
232
|
+
&& this.credential
|
|
233
|
+
&& this.credential.expiresAt - this.now() > BROWSER_TOKEN_REFRESH_MARGIN_MS) {
|
|
234
|
+
return this.credential;
|
|
235
|
+
}
|
|
236
|
+
this.clear();
|
|
237
|
+
const ticket = await this.api.request("POST", "/v1/agent/browser-session", {});
|
|
238
|
+
const session = await this.api.request("POST", "/v1/agent/browser-session/redeem", { ticket: ticket.ticket }, false);
|
|
239
|
+
const expiresAt = Date.parse(session.expires_at);
|
|
240
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
|
|
241
|
+
throw new CliError("Server returned an expired browser credential.", "AGENT_BROWSER_SESSION_INVALID", 4);
|
|
242
|
+
}
|
|
243
|
+
this.credential = {
|
|
244
|
+
token: session.browser_token,
|
|
245
|
+
expiresAt,
|
|
246
|
+
webOrigin: new URL(session.allowed_origin).origin,
|
|
247
|
+
};
|
|
248
|
+
this.bootstrapCount += 1;
|
|
249
|
+
return this.credential;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const LIBRARY_WORKSPACES = new Set(["experiences", "audio", "components", "behaviors"]);
|
|
253
|
+
export class StudioBrowser {
|
|
254
|
+
api;
|
|
255
|
+
context;
|
|
256
|
+
page;
|
|
257
|
+
headless = false;
|
|
258
|
+
browserEngine = "not-started";
|
|
259
|
+
apiOrigin;
|
|
260
|
+
credentials;
|
|
261
|
+
constructor(api) {
|
|
262
|
+
this.api = api;
|
|
263
|
+
this.apiOrigin = new URL(api.origin).origin;
|
|
264
|
+
this.credentials = new BrowserCredentialManager(api);
|
|
265
|
+
}
|
|
266
|
+
async start(headless) {
|
|
267
|
+
if (this.context)
|
|
268
|
+
return;
|
|
269
|
+
this.headless = headless;
|
|
270
|
+
const profile = path.join(cacheDirectory(), "browser-profile");
|
|
271
|
+
await mkdir(profile, { recursive: true, mode: 0o700 });
|
|
272
|
+
const attempts = [
|
|
273
|
+
{ channel: "chrome", name: "Google Chrome" },
|
|
274
|
+
{ channel: "msedge", name: "Microsoft Edge" },
|
|
275
|
+
{ name: "Playwright Chromium" },
|
|
276
|
+
];
|
|
277
|
+
const errors = [];
|
|
278
|
+
for (const attempt of attempts) {
|
|
279
|
+
try {
|
|
280
|
+
this.context = await chromium.launchPersistentContext(profile, {
|
|
281
|
+
...studioBrowserLaunchOptions(headless),
|
|
282
|
+
channel: attempt.channel,
|
|
283
|
+
});
|
|
284
|
+
await this.context.route("**/*", async (route) => {
|
|
285
|
+
const request = route.request();
|
|
286
|
+
const destination = new URL(request.url());
|
|
287
|
+
const browserToken = this.credentials.tokenForRequest(request.url());
|
|
288
|
+
if (browserToken && destination.origin === this.apiOrigin) {
|
|
289
|
+
await route.continue({
|
|
290
|
+
headers: {
|
|
291
|
+
...request.headers(),
|
|
292
|
+
Authorization: `Bearer ${browserToken}`,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await route.continue();
|
|
298
|
+
});
|
|
299
|
+
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
300
|
+
this.browserEngine = attempt.name;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
errors.push(`${attempt.name}: ${String(error).split("\n")[0]}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
throw new CliError(`No supported browser could launch. Run \`nerdbro setup\`. ${errors.join("; ")}`, "STUDIO_BROWSER_UNAVAILABLE", 5);
|
|
308
|
+
}
|
|
309
|
+
async stop() {
|
|
310
|
+
this.clearAuthentication();
|
|
311
|
+
await this.context?.close();
|
|
312
|
+
this.context = undefined;
|
|
313
|
+
this.page = undefined;
|
|
314
|
+
}
|
|
315
|
+
clearAuthentication() {
|
|
316
|
+
this.credentials.clear();
|
|
317
|
+
}
|
|
318
|
+
authenticationDiagnostics() {
|
|
319
|
+
return { ...this.credentials.diagnostics(), browser_engine: this.browserEngine };
|
|
320
|
+
}
|
|
321
|
+
async authenticatedPage(forceRefresh = false) {
|
|
322
|
+
if (!this.context)
|
|
323
|
+
await this.start(this.headless);
|
|
324
|
+
const browserSession = await this.credentials.get(forceRefresh);
|
|
325
|
+
const webOrigin = browserSession.webOrigin;
|
|
326
|
+
const page = this.page && !this.page.isClosed() ? this.page : await this.context.newPage();
|
|
327
|
+
await applyCssViewport(page, { width: 1440, height: 1000 });
|
|
328
|
+
await page.goto(webOrigin, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
329
|
+
await page.evaluate(() => {
|
|
330
|
+
localStorage.setItem("GetStorage", JSON.stringify({
|
|
331
|
+
// This is intentionally not a credential. It lets the Flutter route
|
|
332
|
+
// guard mount while Playwright injects the real short-lived browser
|
|
333
|
+
// token from daemon memory only for the configured NerdBro API.
|
|
334
|
+
jwtToken: "studio-agent-browser-session",
|
|
335
|
+
onboardingCompleted: true,
|
|
336
|
+
cachedRoles: ["maker"],
|
|
337
|
+
studioAgentBrowserSession: true,
|
|
338
|
+
}));
|
|
339
|
+
});
|
|
340
|
+
return { page, webOrigin };
|
|
341
|
+
}
|
|
342
|
+
async open(asset, view = "canvas", allowRefresh = true) {
|
|
343
|
+
if (!VIEW_QUERY[view])
|
|
344
|
+
throw new CliError(`Unknown Studio view: ${view}`, "STUDIO_VIEW_INVALID", 2);
|
|
345
|
+
const { page, webOrigin } = await this.authenticatedPage();
|
|
346
|
+
const query = new URLSearchParams({ ...(VIEW_QUERY[view] || VIEW_QUERY.canvas), studio_observation: "1" });
|
|
347
|
+
const url = new URL(`/maker/interactive/studio/${encodeURIComponent(asset)}?${query}`, webOrigin);
|
|
348
|
+
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
349
|
+
if (/\/(sign-in|login|complete-profile)(\/|\?|$)/.test(page.url())) {
|
|
350
|
+
if (allowRefresh) {
|
|
351
|
+
this.clearAuthentication();
|
|
352
|
+
return this.open(asset, view, false);
|
|
353
|
+
}
|
|
354
|
+
throw new CliError("Studio redirected to authentication.", "AGENT_BROWSER_AUTH_REQUIRED");
|
|
355
|
+
}
|
|
356
|
+
return { ok: true, url: safeUrl(page.url()), view };
|
|
357
|
+
}
|
|
358
|
+
async capture(request, allowRefresh = true) {
|
|
359
|
+
const view = request.library ? "library" : request.view || "canvas";
|
|
360
|
+
if (request.library && !LIBRARY_WORKSPACES.has(request.library.workspace)) {
|
|
361
|
+
throw new CliError(`Unknown Library workspace: ${request.library.workspace}`, "STUDIO_VIEW_INVALID", 2);
|
|
362
|
+
}
|
|
363
|
+
if (!request.library && !VIEW_QUERY[view])
|
|
364
|
+
throw new CliError(`Unknown Studio view: ${view}`, "STUDIO_VIEW_INVALID", 2);
|
|
365
|
+
const { page, webOrigin } = await this.authenticatedPage();
|
|
366
|
+
const dimensions = cssViewport(request.viewport || "desktop");
|
|
367
|
+
await applyCssViewport(page, dimensions);
|
|
368
|
+
const consoleErrors = [];
|
|
369
|
+
const pageErrors = [];
|
|
370
|
+
const requestFailures = [];
|
|
371
|
+
const httpErrors = [];
|
|
372
|
+
page.removeAllListeners("console");
|
|
373
|
+
page.removeAllListeners("pageerror");
|
|
374
|
+
page.removeAllListeners("requestfailed");
|
|
375
|
+
page.removeAllListeners("response");
|
|
376
|
+
page.on("console", (message) => {
|
|
377
|
+
if (message.type() === "error" && consoleErrors.length < 12)
|
|
378
|
+
consoleErrors.push(boundedText(message.text()));
|
|
379
|
+
});
|
|
380
|
+
page.on("pageerror", (error) => {
|
|
381
|
+
if (pageErrors.length < 12)
|
|
382
|
+
pageErrors.push(boundedText(error.message));
|
|
383
|
+
});
|
|
384
|
+
page.on("requestfailed", (failed) => {
|
|
385
|
+
if (requestFailures.length < 12)
|
|
386
|
+
requestFailures.push({
|
|
387
|
+
url: safeUrl(failed.url()),
|
|
388
|
+
method: failed.method(),
|
|
389
|
+
error: boundedText(failed.failure()?.errorText || "request failed"),
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
page.on("response", (response) => {
|
|
393
|
+
if (response.status() >= 400 && httpErrors.length < 12)
|
|
394
|
+
httpErrors.push({
|
|
395
|
+
url: safeUrl(response.url()),
|
|
396
|
+
status: response.status(),
|
|
397
|
+
method: response.request().method(),
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
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);
|
|
403
|
+
await page.goto(target.toString(), { waitUntil: "domcontentloaded", timeout: request.timeoutMs || 30_000 });
|
|
404
|
+
const apiUnauthorized = httpErrors.some((item) => {
|
|
405
|
+
if (item.status !== 401)
|
|
406
|
+
return false;
|
|
407
|
+
try {
|
|
408
|
+
return new URL(String(item.url)).origin === this.apiOrigin;
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
if (apiUnauthorized && allowRefresh) {
|
|
415
|
+
this.clearAuthentication();
|
|
416
|
+
return this.capture(request, false);
|
|
417
|
+
}
|
|
418
|
+
if (/\/(sign-in|login|complete-profile)(\/|\?|$)/.test(page.url())) {
|
|
419
|
+
if (allowRefresh) {
|
|
420
|
+
this.clearAuthentication();
|
|
421
|
+
return this.capture(request, false);
|
|
422
|
+
}
|
|
423
|
+
throw new CliError("Studio redirected away from the Maker route.", "STUDIO_ROUTE_REDIRECTED");
|
|
424
|
+
}
|
|
425
|
+
let observation = null;
|
|
426
|
+
// Library workspaces mount no Scene runtime, like the catalogue views.
|
|
427
|
+
const requireObservation = request.observation ?? (request.library ? false : viewRequiresRuntimeObservation(view));
|
|
428
|
+
if (requireObservation) {
|
|
429
|
+
const deadline = Date.now() + (request.timeoutMs || 30_000);
|
|
430
|
+
while (Date.now() < deadline) {
|
|
431
|
+
observation = await page.evaluate(() => {
|
|
432
|
+
const value = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
433
|
+
if (!value)
|
|
434
|
+
return null;
|
|
435
|
+
try {
|
|
436
|
+
return JSON.parse(value);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
if (observation?.status === "error" ||
|
|
443
|
+
(observation?.status === "complete" && observation.visual_ready === true))
|
|
444
|
+
break;
|
|
445
|
+
await page.waitForTimeout(125);
|
|
446
|
+
}
|
|
447
|
+
if (!observation || observation.visual_ready !== true && observation.status !== "error") {
|
|
448
|
+
throw new CliError("Studio visual surface did not become ready for capture.", "STUDIO_VISUAL_NOT_READY", 1, {
|
|
449
|
+
stage: "capture_preflight", elapsed_ms: request.timeoutMs || 30_000,
|
|
450
|
+
...readinessSummary(observation),
|
|
451
|
+
console_errors: consoleErrors, page_errors: pageErrors,
|
|
452
|
+
request_failures: requestFailures, http_errors: httpErrors,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (observation.status === "error")
|
|
456
|
+
throw new CliError(String(observation.error_summary || "Runtime observation failed."), "STUDIO_RUNTIME_ERROR", 1, {
|
|
457
|
+
...readinessSummary(observation), console_errors: consoleErrors,
|
|
458
|
+
page_errors: pageErrors, request_failures: requestFailures,
|
|
459
|
+
http_errors: httpErrors,
|
|
460
|
+
});
|
|
461
|
+
if (observation.source_hash !== request.sourceHash) {
|
|
462
|
+
throw new CliError("Studio runtime source does not match the local checkout.", "STUDIO_SOURCE_MISMATCH", 3, {
|
|
463
|
+
expected: request.sourceHash,
|
|
464
|
+
actual: observation.source_hash,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const context = await this.api.request("GET", "/v1/agent/context");
|
|
468
|
+
if (observation.runtime_fingerprint !== context.runtime_fingerprint) {
|
|
469
|
+
throw new CliError("Studio runtime fingerprint is out of step with production.", "STUDIO_RUNTIME_FINGERPRINT_MISMATCH", 3, {
|
|
470
|
+
expected: context.runtime_fingerprint,
|
|
471
|
+
actual: observation.runtime_fingerprint,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (observation.mounted !== true)
|
|
475
|
+
throw new CliError("Studio runtime is not mounted.", "STUDIO_RUNTIME_NOT_MOUNTED");
|
|
476
|
+
if (observation.runtime_ready !== true)
|
|
477
|
+
throw new CliError("Studio runtime is not ready.", "STUDIO_RUNTIME_NOT_READY");
|
|
478
|
+
if (observation.state_machine_error)
|
|
479
|
+
throw new CliError(String(observation.state_machine_error), "STUDIO_STATE_MACHINE_ERROR");
|
|
480
|
+
const graph = observation.math_graph;
|
|
481
|
+
if (graph?.error)
|
|
482
|
+
throw new CliError(String(graph.error), "STUDIO_MATHGRAPH_ERROR");
|
|
483
|
+
}
|
|
484
|
+
let libraryObservation = null;
|
|
485
|
+
if (request.library) {
|
|
486
|
+
const deadline = Date.now() + (request.timeoutMs || 30_000);
|
|
487
|
+
while (Date.now() < deadline) {
|
|
488
|
+
libraryObservation = await page.evaluate(() => {
|
|
489
|
+
const value = document.getElementById("kheleshikhi-studio-library-observation")?.textContent;
|
|
490
|
+
if (!value)
|
|
491
|
+
return null;
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(value);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
if (libraryObservation?.status === "complete")
|
|
500
|
+
break;
|
|
501
|
+
await page.waitForTimeout(125);
|
|
502
|
+
}
|
|
503
|
+
if (!libraryObservation || libraryObservation.status !== "complete") {
|
|
504
|
+
throw new CliError("Studio Library did not load the selected definition.", "STUDIO_LIBRARY_CAPTURE_TIMEOUT", 1, {
|
|
505
|
+
url: safeUrl(page.url()),
|
|
506
|
+
console_errors: consoleErrors,
|
|
507
|
+
page_errors: pageErrors,
|
|
508
|
+
request_failures: requestFailures,
|
|
509
|
+
http_errors: httpErrors,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (libraryObservation.workspace !== request.library.workspace || libraryObservation.stable_id !== request.library.selection ||
|
|
513
|
+
(request.library.revision && libraryObservation.revision_id !== request.library.revision) ||
|
|
514
|
+
(request.library.definitionHash && libraryObservation.definition_hash !== request.library.definitionHash)) {
|
|
515
|
+
throw new CliError("Studio Library showed a different definition or revision.", "STUDIO_LIBRARY_SOURCE_MISMATCH", 3, {
|
|
516
|
+
expected: request.library, actual: libraryObservation,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// Runtime observation is emitted before Chromium necessarily composites
|
|
521
|
+
// the first visible frame. Wait for fonts and two post-ready paint frames
|
|
522
|
+
// rather than a time-based guess that varies by machine.
|
|
523
|
+
await waitForCapturePaint(page);
|
|
524
|
+
await mkdir(path.dirname(path.resolve(request.out)), { recursive: true });
|
|
525
|
+
const screenshot = await page.screenshot({
|
|
526
|
+
path: path.resolve(request.out),
|
|
527
|
+
fullPage: request.fullPage || false,
|
|
528
|
+
scale: "device",
|
|
529
|
+
});
|
|
530
|
+
const rendering = await renderingDiagnostics(page, screenshot);
|
|
531
|
+
const browser = {
|
|
532
|
+
url: safeUrl(page.url()),
|
|
533
|
+
requested_view: view,
|
|
534
|
+
...(libraryObservation ? { library_observation: libraryObservation } : {}),
|
|
535
|
+
...rendering,
|
|
536
|
+
browser: this.context?.browser()?.version() || "persistent chromium",
|
|
537
|
+
headless: this.headless,
|
|
538
|
+
console_errors: consoleErrors,
|
|
539
|
+
page_errors: pageErrors,
|
|
540
|
+
request_failures: requestFailures,
|
|
541
|
+
http_errors: httpErrors,
|
|
542
|
+
};
|
|
543
|
+
return { screenshot: path.resolve(request.out), runtime: observation, browser };
|
|
544
|
+
}
|
|
545
|
+
async drive(request) {
|
|
546
|
+
const first = await this.capture({ ...request, view: "learner", drive: true,
|
|
547
|
+
observation: Boolean(request.sourceHash) });
|
|
548
|
+
const page = this.page;
|
|
549
|
+
if (!page)
|
|
550
|
+
throw new CliError("Studio browser is unavailable.", "AGENT_BROWSER_NOT_READY");
|
|
551
|
+
const current = first.runtime;
|
|
552
|
+
const alreadyCurrent = request.sourceHash === request.candidateHash &&
|
|
553
|
+
current?.source_hash === request.candidateHash && current?.runtime_ready === true &&
|
|
554
|
+
current?.preview_is_current === true;
|
|
555
|
+
if (!alreadyCurrent)
|
|
556
|
+
await page.evaluate((candidate) => {
|
|
557
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
558
|
+
if (!node) {
|
|
559
|
+
node = document.createElement("div");
|
|
560
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
561
|
+
node.hidden = true;
|
|
562
|
+
document.body.append(node);
|
|
563
|
+
}
|
|
564
|
+
node.textContent = JSON.stringify({ id: "candidate", op: "loadCandidate", source: candidate });
|
|
565
|
+
}, request.candidate);
|
|
566
|
+
const candidateReply = alreadyCurrent ? { ok: true, source_hash: request.candidateHash } : await page.waitForFunction(() => {
|
|
567
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
568
|
+
if (!raw)
|
|
569
|
+
return false;
|
|
570
|
+
const reply = JSON.parse(raw);
|
|
571
|
+
return reply.id === "candidate" ? reply : false;
|
|
572
|
+
}, undefined, { timeout: request.timeoutMs || 30_000 }).then((handle) => handle.jsonValue()).catch(async () => {
|
|
573
|
+
const lastObserved = await page.evaluate(() => document.getElementById("kheleshikhi-studio-drive-result")?.textContent?.slice(0, 500) || null);
|
|
574
|
+
throw new CliError("Studio drive timed out waiting for candidate acknowledgement.", "STUDIO_DRIVE_TIMEOUT", 1, { stage: "candidate_ack", elapsed_ms: request.timeoutMs || 30_000, last_observed: lastObserved });
|
|
575
|
+
});
|
|
576
|
+
if (candidateReply.ok !== true || candidateReply.source_hash !== request.candidateHash) {
|
|
577
|
+
throw new CliError(String(candidateReply.error || "Candidate preview source hash mismatch."), "STUDIO_SOURCE_MISMATCH", 3, candidateReply);
|
|
578
|
+
}
|
|
579
|
+
await page.waitForFunction((expected) => {
|
|
580
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
581
|
+
if (!raw)
|
|
582
|
+
return false;
|
|
583
|
+
const value = JSON.parse(raw);
|
|
584
|
+
return value.source_hash === expected && value.runtime_ready === true && value.visual_ready === true && value.preview_is_current === true;
|
|
585
|
+
}, request.candidateHash, { timeout: request.timeoutMs || 30_000 }).catch(async () => {
|
|
586
|
+
const lastObserved = await page.evaluate(() => {
|
|
587
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
588
|
+
if (!raw)
|
|
589
|
+
return null;
|
|
590
|
+
try {
|
|
591
|
+
return JSON.parse(raw);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
throw new CliError("Studio drive timed out waiting for candidate runtime readiness.", "STUDIO_DRIVE_TIMEOUT", 1, {
|
|
598
|
+
stage: "candidate_runtime", elapsed_ms: request.timeoutMs || 30_000,
|
|
599
|
+
expected_hash: request.candidateHash, ...readinessSummary(lastObserved),
|
|
600
|
+
browser_errors: first.browser,
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
const ready = await page.evaluate(() => {
|
|
604
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
605
|
+
return raw ? JSON.parse(raw) : {};
|
|
606
|
+
});
|
|
607
|
+
const contract = await this.api.request("GET", "/v1/agent/context");
|
|
608
|
+
if (ready.runtime_fingerprint !== contract.runtime_fingerprint) {
|
|
609
|
+
throw new CliError("Candidate runtime fingerprint is out of step with the server.", "STUDIO_RUNTIME_FINGERPRINT_MISMATCH", 3);
|
|
610
|
+
}
|
|
611
|
+
if (ready.state_machine_error || ready.math_graph?.error) {
|
|
612
|
+
throw new CliError("Candidate preview reported a runtime error.", "STUDIO_RUNTIME_ERROR", 1, ready);
|
|
613
|
+
}
|
|
614
|
+
const results = [];
|
|
615
|
+
const observation = async () => page.evaluate(() => {
|
|
616
|
+
const raw = document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent;
|
|
617
|
+
return raw ? JSON.parse(raw) : {};
|
|
618
|
+
});
|
|
619
|
+
const started = Date.now();
|
|
620
|
+
for (const [index, step] of request.scenario.steps.entries()) {
|
|
621
|
+
if (Date.now() - started > 120_000)
|
|
622
|
+
throw new CliError("Semantic scenario exceeded two minutes.", "STUDIO_SCENARIO_TIMEOUT");
|
|
623
|
+
if (isDriveCommand(step)) {
|
|
624
|
+
const id = `step-${index}`;
|
|
625
|
+
await page.evaluate(({ id, step }) => {
|
|
626
|
+
let node = document.getElementById("kheleshikhi-studio-drive-command");
|
|
627
|
+
if (!node) {
|
|
628
|
+
node = document.createElement("div");
|
|
629
|
+
node.id = "kheleshikhi-studio-drive-command";
|
|
630
|
+
node.hidden = true;
|
|
631
|
+
document.body.append(node);
|
|
632
|
+
}
|
|
633
|
+
node.textContent = JSON.stringify({ id, op: step.op, target: step.target, value: step.value });
|
|
634
|
+
}, { id, step });
|
|
635
|
+
const result = await page.waitForFunction((expected) => {
|
|
636
|
+
const raw = document.getElementById("kheleshikhi-studio-drive-result")?.textContent;
|
|
637
|
+
if (!raw)
|
|
638
|
+
return false;
|
|
639
|
+
const reply = JSON.parse(raw);
|
|
640
|
+
return reply.id === expected ? reply : false;
|
|
641
|
+
}, id, { timeout: step.timeoutMs || 10_000 }).then((handle) => handle.jsonValue()).catch(async () => {
|
|
642
|
+
const lastObserved = await page.evaluate(() => document.getElementById("kheleshikhi-studio-drive-result")?.textContent?.slice(0, 500) || null);
|
|
643
|
+
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
|
+
});
|
|
645
|
+
if (result.ok !== true)
|
|
646
|
+
throw new CliError(String(result.error || "Semantic command failed."), "STUDIO_DRIVE_COMMAND_FAILED", 1, { index, step, result });
|
|
647
|
+
results.push({ index, op: step.op, observation: result.observation });
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (step.op === "capture") {
|
|
651
|
+
const filename = `step-${String(index).padStart(2, "0")}${step.name ? `-${step.name}` : ""}.png`;
|
|
652
|
+
const output = path.join(path.dirname(request.out), filename);
|
|
653
|
+
await waitForVisualReady(page, step.timeoutMs || 10_000, `scenario_capture_${index}`);
|
|
654
|
+
await waitForCapturePaint(page);
|
|
655
|
+
await page.screenshot({ path: output, scale: "device" });
|
|
656
|
+
results.push({ index, op: step.op, path: output, observation: await observation() });
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
let current = await observation();
|
|
660
|
+
if (step.op === "wait") {
|
|
661
|
+
const deadline = Date.now() + (step.timeoutMs || 10_000);
|
|
662
|
+
while (!matchesObservation(current, step) && Date.now() < deadline) {
|
|
663
|
+
await page.waitForTimeout(100);
|
|
664
|
+
current = await observation();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
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 });
|
|
669
|
+
}
|
|
670
|
+
results.push({ index, op: step.op, path: step.path, actual: current });
|
|
671
|
+
}
|
|
672
|
+
return { source_hash: request.candidateHash, initial: first, steps: results, final: await observation() };
|
|
673
|
+
}
|
|
674
|
+
/** Local-agent capture from the same browser page and renderer the Maker sees. */
|
|
675
|
+
async capturePreview(request) {
|
|
676
|
+
const dimensions = cssViewport(request.viewport || "480x320");
|
|
677
|
+
const settings = observationSettings({
|
|
678
|
+
mode: request.mode, duration: request.duration, fps: request.fps,
|
|
679
|
+
...dimensions,
|
|
680
|
+
});
|
|
681
|
+
// The requested viewport is the observation frame. The full Studio shell
|
|
682
|
+
// needs additional CSS room for its chrome/panes before we crop and scale
|
|
683
|
+
// the real preview, particularly for 480×320 LLM captures.
|
|
684
|
+
const browserViewport = {
|
|
685
|
+
width: Math.min(1280, Math.max(dimensions.width + 600, 1024)),
|
|
686
|
+
height: Math.min(960, Math.max(dimensions.height + 500, 800)),
|
|
687
|
+
};
|
|
688
|
+
const root = path.resolve(request.out);
|
|
689
|
+
await mkdir(root);
|
|
690
|
+
try {
|
|
691
|
+
const preflight = await this.capture({ ...request, view: "canvas", viewport: `${browserViewport.width}x${browserViewport.height}`, out: path.join(root, "preflight.png") });
|
|
692
|
+
const page = this.page;
|
|
693
|
+
if (!page)
|
|
694
|
+
throw new CliError("Browser preview is unavailable.", "STUDIO_CAPTURE_UNSUPPORTED");
|
|
695
|
+
let baseline = preflight.runtime;
|
|
696
|
+
// The preflight can observe the one-shot certification snapshot before
|
|
697
|
+
// the passive live bridge's next tick. Wait for the latter explicitly.
|
|
698
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
699
|
+
const value = await page.evaluate(() => document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent || "");
|
|
700
|
+
if (value)
|
|
701
|
+
baseline = JSON.parse(value);
|
|
702
|
+
if (baseline.status === "complete" && baseline.preview_is_current === true &&
|
|
703
|
+
baseline.source_revision !== undefined && baseline.preview_bounds)
|
|
704
|
+
break;
|
|
705
|
+
await page.waitForTimeout(100);
|
|
706
|
+
}
|
|
707
|
+
const revision = baseline.source_revision;
|
|
708
|
+
assertCurrentObservation(baseline, request.sourceHash, revision);
|
|
709
|
+
const bounds = baseline.preview_bounds;
|
|
710
|
+
if (!bounds || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) ||
|
|
711
|
+
!Number.isFinite(bounds.width) || !Number.isFinite(bounds.height) ||
|
|
712
|
+
bounds.width < 40 || bounds.height < 40) {
|
|
713
|
+
throw new CliError("Studio did not report a visible preview rectangle.", "STUDIO_CAPTURE_PREVIEW_NOT_VISIBLE");
|
|
714
|
+
}
|
|
715
|
+
const clip = {
|
|
716
|
+
x: Math.max(0, bounds.x), y: Math.max(0, bounds.y),
|
|
717
|
+
width: Math.min(bounds.width, browserViewport.width - Math.max(0, bounds.x)),
|
|
718
|
+
height: Math.min(bounds.height, browserViewport.height - Math.max(0, bounds.y)),
|
|
719
|
+
};
|
|
720
|
+
if (clip.width < 40 || clip.height < 40)
|
|
721
|
+
throw new CliError("The preview is outside the browser viewport.", "STUDIO_CAPTURE_PREVIEW_NOT_VISIBLE");
|
|
722
|
+
const sampler = new ObservationSampler(settings);
|
|
723
|
+
const frameImages = [];
|
|
724
|
+
let encodedBytes = 0;
|
|
725
|
+
await mkdir(path.join(root, "frames"));
|
|
726
|
+
const started = Date.now();
|
|
727
|
+
const read = async () => {
|
|
728
|
+
const value = await page.evaluate(() => document.getElementById("kheleshikhi-studio-runtime-observation")?.textContent || "");
|
|
729
|
+
if (!value)
|
|
730
|
+
throw new CliError("Live preview observation disappeared.", "STUDIO_CAPTURE_UNSUPPORTED");
|
|
731
|
+
const observed = JSON.parse(value);
|
|
732
|
+
assertCurrentObservation(observed, request.sourceHash, revision);
|
|
733
|
+
return observed;
|
|
734
|
+
};
|
|
735
|
+
const sample = async (final = false) => {
|
|
736
|
+
const atMs = Math.min(Date.now() - started, settings.durationMs);
|
|
737
|
+
const selected = sampler.consider(atMs, await read(), final);
|
|
738
|
+
if (!selected)
|
|
739
|
+
return;
|
|
740
|
+
const cropped = await page.screenshot({ type: "jpeg", quality: 70, scale: "css", clip });
|
|
741
|
+
const resized = await page.evaluate(async ({ data, width, height }) => {
|
|
742
|
+
const image = new Image();
|
|
743
|
+
image.src = `data:image/jpeg;base64,${data}`;
|
|
744
|
+
await image.decode();
|
|
745
|
+
const canvas = document.createElement("canvas");
|
|
746
|
+
canvas.width = width;
|
|
747
|
+
canvas.height = height;
|
|
748
|
+
const context = canvas.getContext("2d");
|
|
749
|
+
if (!context)
|
|
750
|
+
throw new Error("Browser cannot encode preview frames.");
|
|
751
|
+
context.drawImage(image, 0, 0, width, height);
|
|
752
|
+
return canvas.toDataURL("image/jpeg", 0.70).split(",")[1];
|
|
753
|
+
}, { data: cropped.toString("base64"), width: settings.width, height: settings.height });
|
|
754
|
+
const bytes = Buffer.from(resized, "base64");
|
|
755
|
+
encodedBytes += bytes.length;
|
|
756
|
+
if (encodedBytes > 12 * 1024 * 1024)
|
|
757
|
+
throw new CliError("Capture exceeded the 12 MiB frame budget.", "STUDIO_CAPTURE_TOO_LARGE");
|
|
758
|
+
const filename = `${String(selected.id - 1).padStart(3, "0")}.jpg`;
|
|
759
|
+
await writeFile(path.join(root, "frames", filename), bytes);
|
|
760
|
+
frameImages.push({ data: bytes.toString("base64"), label: `${selected.id} ${(atMs / 1000).toFixed(2)}s ${selected.label}`, at_ms: atMs });
|
|
761
|
+
};
|
|
762
|
+
await sample();
|
|
763
|
+
while (Date.now() - started < settings.durationMs) {
|
|
764
|
+
await page.waitForTimeout(Math.min(125, settings.durationMs - (Date.now() - started)));
|
|
765
|
+
await sample();
|
|
766
|
+
}
|
|
767
|
+
await sample(true);
|
|
768
|
+
if (frameImages.length && sampler.checkpoints.length) {
|
|
769
|
+
const last = sampler.checkpoints.at(-1);
|
|
770
|
+
frameImages[frameImages.length - 1].label = `${last.id} ${(last.at_ms / 1000).toFixed(2)}s ${last.label}`;
|
|
771
|
+
}
|
|
772
|
+
const sheet = await page.evaluate(async (frames) => {
|
|
773
|
+
const columns = Math.min(3, frames.length);
|
|
774
|
+
const cellWidth = 320;
|
|
775
|
+
const cellHeight = 238;
|
|
776
|
+
const canvas = document.createElement("canvas");
|
|
777
|
+
canvas.width = columns * cellWidth;
|
|
778
|
+
canvas.height = Math.ceil(frames.length / columns) * cellHeight;
|
|
779
|
+
const context = canvas.getContext("2d");
|
|
780
|
+
if (!context)
|
|
781
|
+
throw new Error("Canvas contact sheet is unsupported.");
|
|
782
|
+
context.fillStyle = "#ffffff";
|
|
783
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
784
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
785
|
+
const frame = frames[index];
|
|
786
|
+
const image = new Image();
|
|
787
|
+
image.src = `data:image/jpeg;base64,${frame.data}`;
|
|
788
|
+
await image.decode();
|
|
789
|
+
const x = (index % columns) * cellWidth;
|
|
790
|
+
const y = Math.floor(index / columns) * cellHeight;
|
|
791
|
+
context.drawImage(image, x, y, cellWidth, 210);
|
|
792
|
+
context.fillStyle = "#111827";
|
|
793
|
+
context.font = "14px sans-serif";
|
|
794
|
+
context.fillText(frame.label.slice(0, 42), x + 8, y + 229);
|
|
795
|
+
}
|
|
796
|
+
return canvas.toDataURL("image/jpeg", 0.78).split(",")[1];
|
|
797
|
+
}, frameImages);
|
|
798
|
+
const sheetBytes = Buffer.from(sheet, "base64");
|
|
799
|
+
encodedBytes += sheetBytes.length;
|
|
800
|
+
if (encodedBytes > 16 * 1024 * 1024)
|
|
801
|
+
throw new CliError("Capture exceeded the 16 MiB package budget.", "STUDIO_CAPTURE_TOO_LARGE");
|
|
802
|
+
await read();
|
|
803
|
+
await writeFile(path.join(root, "contact-sheet.jpg"), sheetBytes);
|
|
804
|
+
const timeline = sampler.checkpoints.map(({ id, at_ms, label, observation }) => ({
|
|
805
|
+
frame: id, at_ms, label,
|
|
806
|
+
active_states: observation.active_states,
|
|
807
|
+
host_variables: observation.host_variables,
|
|
808
|
+
interactions: observation.interactions,
|
|
809
|
+
machine_dispatches: observation.machine_dispatches,
|
|
810
|
+
geometry_revision: observation.geometry_revision,
|
|
811
|
+
scene_clock_seconds: observation.scene_clock_seconds,
|
|
812
|
+
}));
|
|
813
|
+
const manifest = {
|
|
814
|
+
schema: "kheleshikhi.studio-preview-capture.v1", asset_id: request.asset,
|
|
815
|
+
source_hash: request.sourceHash, source_revision: revision,
|
|
816
|
+
base_revision_public_id: baseline.base_revision_public_id,
|
|
817
|
+
runtime_fingerprint: baseline.runtime_fingerprint,
|
|
818
|
+
preview_is_current_at_capture: true,
|
|
819
|
+
viewport: browserViewport, frame_size: dimensions,
|
|
820
|
+
settings, duration_ms: Math.min(Date.now() - started, settings.durationMs),
|
|
821
|
+
preview_bounds: clip,
|
|
822
|
+
checkpoints: sampler.checkpoints.length,
|
|
823
|
+
visual_source: "real Studio browser preview screenshot",
|
|
824
|
+
captured_at: new Date().toISOString(),
|
|
825
|
+
};
|
|
826
|
+
await writeFile(path.join(root, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
827
|
+
await writeFile(path.join(root, "timeline.json"), `${JSON.stringify(timeline, null, 2)}\n`);
|
|
828
|
+
await writeFile(path.join(root, "observations.json"), `${JSON.stringify(sampler.checkpoints, null, 2)}\n`);
|
|
829
|
+
return { manifest, timeline, output: root, contact_sheet: path.join(root, "contact-sheet.jpg"), frames: path.join(root, "frames"), browser: preflight.browser };
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
await rm(root, { recursive: true, force: true });
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
//# sourceMappingURL=browser.js.map
|