@vgai/live 0.5.42 → 0.5.44
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/.tsbuildinfo +1 -1
- package/dist/editor-document.d.ts +39 -2
- package/dist/editor-document.js +51 -2
- package/dist/editor.d.ts +140 -3
- package/dist/editor.js +158 -11
- package/dist/game-client/capture-notes.d.ts +26 -8
- package/dist/game-client/capture-notes.js +34 -7
- package/dist/game-client/client.d.ts +24 -1
- package/dist/game-client/client.js +37 -1
- package/dist/game-client/relay-transport.d.ts +1 -1
- package/dist/game-client/relay-transport.js +9 -6
- package/dist/game-client/screenshot-target.d.ts +17 -0
- package/dist/game-client/screenshot-target.js +22 -3
- package/dist/game.d.ts +2 -2
- package/dist/game.js +7 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/package.json +3 -3
- package/src/editor-document.ts +72 -2
- package/src/editor.ts +204 -5
- package/src/game-client/capture-notes.ts +49 -8
- package/src/game-client/client.ts +43 -2
- package/src/game-client/relay-transport.ts +10 -5
- package/src/game-client/screenshot-target.ts +43 -3
- package/src/game.ts +20 -6
- package/src/index.ts +2 -2
package/src/editor.ts
CHANGED
|
@@ -17,18 +17,27 @@ import type {
|
|
|
17
17
|
AssetKind,
|
|
18
18
|
AssetPreviewCapture,
|
|
19
19
|
AssetPreviewOptions,
|
|
20
|
+
AssetPreviewShotSetDefinition,
|
|
20
21
|
AssetPreviewSource,
|
|
22
|
+
CaptureDimensions,
|
|
23
|
+
DocumentLookOutcome,
|
|
24
|
+
EditorChromeCapture,
|
|
21
25
|
EditorClient,
|
|
22
26
|
EditorState,
|
|
23
27
|
EditorView,
|
|
28
|
+
EditorWorkspaceName,
|
|
24
29
|
HistoryStep,
|
|
25
30
|
InspectedFieldWrite,
|
|
26
31
|
InspectedHierarchy,
|
|
27
32
|
InspectedInspection,
|
|
33
|
+
LabeledShotSetCapture,
|
|
28
34
|
OpenedDocument,
|
|
29
35
|
PresentedEditorView,
|
|
30
36
|
RagdollGenerationResult,
|
|
31
37
|
ShadingMode,
|
|
38
|
+
StructureOp,
|
|
39
|
+
StructureOpOptions,
|
|
40
|
+
StructureOpResult,
|
|
32
41
|
ViewPreset,
|
|
33
42
|
ViewportCapture,
|
|
34
43
|
} from '@vgai/editor-sdk';
|
|
@@ -56,6 +65,23 @@ const EXTENSION_KIND: Record<string, AssetKind> = {
|
|
|
56
65
|
'.glsl': 'source',
|
|
57
66
|
'.vert': 'source',
|
|
58
67
|
'.frag': 'source',
|
|
68
|
+
// PROJECT SCRIPTS ARE SOURCE. Without these the guess below falls through to
|
|
69
|
+
// `'json'`, the asset-document router sends the file to the generic JSON
|
|
70
|
+
// viewer (`asset-documents.tsx#assetDocumentViewerRoute`: `spec.kind ===
|
|
71
|
+
// 'json'` is decided before any content routing), and the LIVE MODELING
|
|
72
|
+
// DOCUMENT never mounts — `editor.openAsset('src/lib/fox/fox.model.ts')`
|
|
73
|
+
// silently shows a text pane instead of the model. Only `kind: 'source'`
|
|
74
|
+
// reaches `SourceAssetViewer`, which is what content-routes a project script
|
|
75
|
+
// to `LiveModuleDocument`. The set matches that viewer's own
|
|
76
|
+
// `isProjectScriptPath` regex, `/\.(?:[cm]?[jt]sx?)$/`.
|
|
77
|
+
'.ts': 'source',
|
|
78
|
+
'.tsx': 'source',
|
|
79
|
+
'.mts': 'source',
|
|
80
|
+
'.cts': 'source',
|
|
81
|
+
'.js': 'source',
|
|
82
|
+
'.jsx': 'source',
|
|
83
|
+
'.mjs': 'source',
|
|
84
|
+
'.cjs': 'source',
|
|
59
85
|
};
|
|
60
86
|
|
|
61
87
|
/**
|
|
@@ -125,9 +151,32 @@ export class LiveEditor {
|
|
|
125
151
|
return this.#client.currentView();
|
|
126
152
|
}
|
|
127
153
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Capture the same center document the human is currently looking at.
|
|
156
|
+
*
|
|
157
|
+
* A number is a SQUARE of that size — the default, and the right shape for
|
|
158
|
+
* an unstaged look at a model. `{width, height}` asks for a shaped frame, so
|
|
159
|
+
* a video-aspect look needs no crop afterwards. Both are bounded by the
|
|
160
|
+
* relay budget (64-1024 per side, total no larger than a 1024 square); see
|
|
161
|
+
* `@vgai/editor-sdk`'s `CaptureDimensions`.
|
|
162
|
+
* Supply a view to present and photograph it in one editor request.
|
|
163
|
+
*/
|
|
164
|
+
async captureActiveDocument(
|
|
165
|
+
size?: CaptureDimensions,
|
|
166
|
+
view?: EditorView,
|
|
167
|
+
): Promise<ActiveDocumentCapture> {
|
|
168
|
+
return this.#client.captureActiveDocument(size, view);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Photograph the editor PAGE — every panel, tab strip and viewport as the
|
|
173
|
+
* person sees it. `vgai screenshot editor` is this verb from the shell. The
|
|
174
|
+
* one door for judging chrome sighted: a skin, a workspace arrangement or a
|
|
175
|
+
* contributed panel is looked at through this, never guessed at from DOM
|
|
176
|
+
* probes. Returns the page at its own size.
|
|
177
|
+
*/
|
|
178
|
+
async captureEditorChrome(): Promise<EditorChromeCapture> {
|
|
179
|
+
return this.#client.captureEditorChrome();
|
|
131
180
|
}
|
|
132
181
|
|
|
133
182
|
async play(opts?: { seed?: number }): Promise<void> {
|
|
@@ -184,8 +233,57 @@ export class LiveEditor {
|
|
|
184
233
|
* `assetPreview(..., { stage: 'scene' })`): a framing that silently missed
|
|
185
234
|
* would otherwise be indistinguishable from one that worked.
|
|
186
235
|
*/
|
|
187
|
-
async frame(entityId: string): Promise<void
|
|
188
|
-
|
|
236
|
+
async frame(entityId: string): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Bare `frame()` frames the OPEN Object3D document's subject instead — its
|
|
239
|
+
* selection if it has one, else the whole model: the toolbar's own Frame
|
|
240
|
+
* button, reachable from a script. `fit` scales the fitted distance (1 is
|
|
241
|
+
* that button's tight fit, 1.5 stands back a little for a shot).
|
|
242
|
+
*/
|
|
243
|
+
async frame(options?: { readonly fit?: number }): Promise<void>;
|
|
244
|
+
async frame(target?: string | { readonly fit?: number }): Promise<void> {
|
|
245
|
+
if (typeof target === 'string') {
|
|
246
|
+
await this.#client.frameEntity(target);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await this.#client.frameDocument(target?.fit);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* WATCH THE AGENT LOOK AROUND THE MODEL.
|
|
254
|
+
*
|
|
255
|
+
* Swings the open Object3D document's camera — the camera the human's tab is
|
|
256
|
+
* showing — around the framed subject by `azimuth`/`elevation` RADIANS,
|
|
257
|
+
* animated over `duration` seconds (default 0.6), and resolves when the move
|
|
258
|
+
* ends. This is deliberately not a jump cut: the point of the verb is that a
|
|
259
|
+
* person watching sees the agent walk around the thing it is working on.
|
|
260
|
+
*
|
|
261
|
+
* `await editor.orbit({ azimuth: Math.PI / 2 })` — a quarter turn to the right.
|
|
262
|
+
*
|
|
263
|
+
* There is ONE camera, and the human owns it: a drag during the move cancels
|
|
264
|
+
* it exactly where it is, and the resolved outcome says `cancelledBy:
|
|
265
|
+
* 'human'` rather than throwing. A second look verb supersedes the first.
|
|
266
|
+
* The move is drawn by the document's own frame loop, so a document that
|
|
267
|
+
* isn't being drawn (background tab, inactive panel) doesn't orbit.
|
|
268
|
+
*/
|
|
269
|
+
async orbit(options: {
|
|
270
|
+
readonly azimuth?: number;
|
|
271
|
+
readonly elevation?: number;
|
|
272
|
+
readonly duration?: number;
|
|
273
|
+
}): Promise<DocumentLookOutcome> {
|
|
274
|
+
return this.#client.orbitDocument(options);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A slow full revolution of the open document's subject — {@link orbit} with
|
|
279
|
+
* the turns spelled out and a constant angular rate. Resolves at the end of
|
|
280
|
+
* the last revolution.
|
|
281
|
+
*/
|
|
282
|
+
async turntable(options?: {
|
|
283
|
+
readonly seconds?: number;
|
|
284
|
+
readonly revolutions?: number;
|
|
285
|
+
}): Promise<DocumentLookOutcome> {
|
|
286
|
+
return this.#client.turntableDocument(options);
|
|
189
287
|
}
|
|
190
288
|
|
|
191
289
|
async view(preset: ViewPreset): Promise<void> {
|
|
@@ -206,6 +304,25 @@ export class LiveEditor {
|
|
|
206
304
|
await this.#client.setInstanceCount(countOrNames);
|
|
207
305
|
}
|
|
208
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Switch the editor's NAMED WORKSPACE — `await editor.workspace('model')`.
|
|
309
|
+
*
|
|
310
|
+
* A workspace is a task-named LAYOUT MEMORY over the one dock
|
|
311
|
+
* (ARCHITECTURE-CORE §Editor chrome): `game` (the default, the editor's
|
|
312
|
+
* standing arrangement), `model`, `sculpt`, `texture`, `animate`, `look`.
|
|
313
|
+
* Switching is an EXPLICIT act — nothing in the editor moves chrome on its
|
|
314
|
+
* own, opening a document included — and this is the session door to it,
|
|
315
|
+
* beside `Window → Workspace` and the registered actions.
|
|
316
|
+
*
|
|
317
|
+
* Resolves once the dock has finished rebuilding, so a capture taken
|
|
318
|
+
* immediately after photographs the arrangement that was asked for. Each
|
|
319
|
+
* workspace remembers the user's own hand-tuning per project, so switching
|
|
320
|
+
* away and back is lossless.
|
|
321
|
+
*/
|
|
322
|
+
async workspace(id: EditorWorkspaceName): Promise<void> {
|
|
323
|
+
await this.#client.setWorkspace(id);
|
|
324
|
+
}
|
|
325
|
+
|
|
209
326
|
/** `vgai show <viewport <edit|play>|inspector|console|build>`'s four sub-verbs, folded into one action name. */
|
|
210
327
|
async showPanel(name: PanelName): Promise<void> {
|
|
211
328
|
switch (name) {
|
|
@@ -232,6 +349,20 @@ export class LiveEditor {
|
|
|
232
349
|
await this.#client.openAsset(path, kind ?? inferAssetKind(path));
|
|
233
350
|
}
|
|
234
351
|
|
|
352
|
+
/**
|
|
353
|
+
* SELECT a project asset — the browser's single click, which fills the
|
|
354
|
+
* Inspector without opening a document. `openAsset` is the double click.
|
|
355
|
+
*
|
|
356
|
+
* This is how a project's own `asset.inspector` section is reached: select
|
|
357
|
+
* the file it matches, then `inspect()` lists the verbs that section
|
|
358
|
+
* declares and `runAction(id)` runs one. Selecting a path nothing matches
|
|
359
|
+
* is not an error — the Inspector shows what it has, exactly as it does
|
|
360
|
+
* for a human.
|
|
361
|
+
*/
|
|
362
|
+
async selectAsset(path: string): Promise<void> {
|
|
363
|
+
await this.#client.selectAsset(path);
|
|
364
|
+
}
|
|
365
|
+
|
|
235
366
|
/**
|
|
236
367
|
* Fit native Rapier bodies and joints to a rigged GLB/glTF, copy the reusable
|
|
237
368
|
* project capability, and open the generated TSX prefab's Setup story.
|
|
@@ -261,6 +392,27 @@ export class LiveEditor {
|
|
|
261
392
|
);
|
|
262
393
|
}
|
|
263
394
|
|
|
395
|
+
/**
|
|
396
|
+
* The same subject photographed as a LABELED SHOT SET instead of the four
|
|
397
|
+
* views — a caller-supplied definition of turntable yaws and bone-anchored
|
|
398
|
+
* crops, rendered against the asset's own skeleton, with a contact sheet.
|
|
399
|
+
* Every source {@link assetPreview} takes works here, GLB bytes included:
|
|
400
|
+
* a shot set stages its own subject, so it needs no place to stand.
|
|
401
|
+
*
|
|
402
|
+
* Sole in-repo caller today: `project.bake.preview`'s `--orbit` lane.
|
|
403
|
+
*/
|
|
404
|
+
async assetPreviewShots(
|
|
405
|
+
source: string | AssetPreviewSource,
|
|
406
|
+
definition: AssetPreviewShotSetDefinition,
|
|
407
|
+
options?: AssetPreviewOptions,
|
|
408
|
+
): Promise<LabeledShotSetCapture> {
|
|
409
|
+
return this.#client.captureShotSetPreview(
|
|
410
|
+
typeof source === 'string' ? { assetPath: source } : source,
|
|
411
|
+
definition,
|
|
412
|
+
options,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
264
416
|
async grid(on: boolean): Promise<void> {
|
|
265
417
|
await this.#client.setGrid(on);
|
|
266
418
|
}
|
|
@@ -322,6 +474,53 @@ export class LiveEditor {
|
|
|
322
474
|
return this.#client.runInspectionAction(actionId);
|
|
323
475
|
}
|
|
324
476
|
|
|
477
|
+
/**
|
|
478
|
+
* RESTRUCTURE the authored tree — the hierarchy context menu's own verbs.
|
|
479
|
+
*
|
|
480
|
+
* `create`, `delete`, `duplicate`, `reparent`, `reorder`, `wrap`, `unwrap`,
|
|
481
|
+
* `group`, `ungroup`, `copy`, `cut`, `paste`; `extractComponent` and
|
|
482
|
+
* `forkComponent` are the two that write whole new files and have their own
|
|
483
|
+
* doors below. All of them run the SAME `authoring/consumer-actions.ts`
|
|
484
|
+
* helpers the menu items call, so there is one implementation of each op and
|
|
485
|
+
* not a second that can disagree with what a human gets.
|
|
486
|
+
*
|
|
487
|
+
* It exists because the menu is a POINTER surface: every one of these ops was
|
|
488
|
+
* reachable only by right-clicking a hierarchy row, which is nothing an agent
|
|
489
|
+
* can do — so for an ingest root, whose only authoring surface IS the editor,
|
|
490
|
+
* structure was closed entirely.
|
|
491
|
+
*
|
|
492
|
+
* `id`/`ids` default to the current selection. The answer carries the same
|
|
493
|
+
* per-edit `write` ack `setField` does, so `write.persisted` tells a saved
|
|
494
|
+
* restructure from a live-only one. An op the active adapter does not provide
|
|
495
|
+
* REJECTS by name — never a silent no-op.
|
|
496
|
+
*/
|
|
497
|
+
async structure(op: StructureOp, options?: StructureOpOptions): Promise<StructureOpResult> {
|
|
498
|
+
return this.#client.structureOp(op, options ?? {});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* "Extract Component…" — lift the selected native subtree into its own
|
|
503
|
+
* component file (plus a story) and replace the callsite with it.
|
|
504
|
+
*
|
|
505
|
+
* Answers the action's own sentence, which NAMES both new files, because
|
|
506
|
+
* undo owns the callsite edit and will not remove them.
|
|
507
|
+
*/
|
|
508
|
+
async extractComponent(options?: { id?: string; name?: string }): Promise<string> {
|
|
509
|
+
return (await this.#client.extractComponent(options ?? {})).hint;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* "Fork Component…" — copy the selected instance's component definition to a
|
|
514
|
+
* new file and retarget THIS CALLSITE at it.
|
|
515
|
+
*
|
|
516
|
+
* One callsite is the unit of the edit; when that callsite sits inside a
|
|
517
|
+
* component rendered many times, every one of those renders now renders the
|
|
518
|
+
* fork.
|
|
519
|
+
*/
|
|
520
|
+
async forkComponent(options?: { id?: string }): Promise<string> {
|
|
521
|
+
return (await this.#client.forkComponent(options ?? {})).hint;
|
|
522
|
+
}
|
|
523
|
+
|
|
325
524
|
/**
|
|
326
525
|
* READ the hierarchy panel, as data — the rows a human is looking at right
|
|
327
526
|
* now, nested exactly as the panel nests them.
|
|
@@ -2,17 +2,25 @@
|
|
|
2
2
|
* What a capture knows about ITSELF beyond its pixels — and the one place the
|
|
3
3
|
* caveat sentence is spelled.
|
|
4
4
|
*
|
|
5
|
-
* A PNG is silent about the conditions it was taken under.
|
|
6
|
-
* conditions
|
|
7
|
-
*
|
|
5
|
+
* A PNG is silent about the conditions it was taken under. Four of those
|
|
6
|
+
* conditions matter, and each is already measured elsewhere in the stack.
|
|
7
|
+
* Three change what the frame is worth as EVIDENCE: the editor page reports
|
|
8
8
|
* `loopRecoveryFrame` when the host loop was starved and the runtime had to
|
|
9
9
|
* render one deterministic tick on demand (`command-listener.ts`'s
|
|
10
10
|
* `handleBridgeScreenshot`), and it reports
|
|
11
11
|
* a `flatness.warning` sentence when the frame is nine-tenths one flat surface
|
|
12
|
-
* (`composite-screenshot.ts`'s `measureFlatness`)
|
|
12
|
+
* (`composite-screenshot.ts`'s `measureFlatness`), and it names the CLIP a
|
|
13
|
+
* frame of a recorded run belongs to (`command-listener.ts`'s
|
|
14
|
+
* `screenshotRecordingNotice`). Until now these stopped at a
|
|
13
15
|
* `console.warn` inside the relay transport — visible to a human watching a
|
|
14
16
|
* terminal, invisible to anything that later reads the file.
|
|
15
17
|
*
|
|
18
|
+
* The fourth is not about the frame at all but about what taking it COST: a
|
|
19
|
+
* capture written under the served project root goes through the dev server's
|
|
20
|
+
* file watcher on every shot (measured ~3x slower frame rates), and the
|
|
21
|
+
* out-path resolver is the only place that knows
|
|
22
|
+
* (`screenshot-target.ts`'s `underWatchedProjectRoot`).
|
|
23
|
+
*
|
|
16
24
|
* So the transport seam carries them back as {@link CaptureNotes}, and
|
|
17
25
|
* {@link describeCaptureCaveat} turns them into the ONE sentence every surface
|
|
18
26
|
* says. Its two callers are the transport's own console warning and
|
|
@@ -21,7 +29,7 @@
|
|
|
21
29
|
* record carries beside the frame cannot drift apart.
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
|
-
/** The conditions a capture was taken under, as facts.
|
|
32
|
+
/** The conditions a capture was taken under, as facts. Every field is
|
|
25
33
|
* optional and absent means "not so": an ordinary frame carries no notes. */
|
|
26
34
|
export interface CaptureNotes {
|
|
27
35
|
/** The host loop was starved, so the frame exists only because the runtime
|
|
@@ -30,6 +38,16 @@ export interface CaptureNotes {
|
|
|
30
38
|
/** The page's own near-blank-frame sentence, verbatim (it owns the wording;
|
|
31
39
|
* see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
|
|
32
40
|
readonly flatnessWarning?: string;
|
|
41
|
+
/** The clip this frame is one frame OF — every `vgai play` records
|
|
42
|
+
* (`play-recording.ts`). Not a degradation: the still is delivered and is
|
|
43
|
+
* the right instrument for a look question. It is here because a still of a
|
|
44
|
+
* MOVING game answers a temporal question only by luck, and a reader of the
|
|
45
|
+
* persisted record has to be able to find the door that answers it. */
|
|
46
|
+
readonly recordingPath?: string;
|
|
47
|
+
/** The SERVED project root this capture was written under, when it was.
|
|
48
|
+
* Not a degradation of the frame — a cost of taking it; see
|
|
49
|
+
* {@link WATCHED_CAPTURE_PATH_CAVEAT}. */
|
|
50
|
+
readonly watchedProjectRoot?: string;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
/** One capture, as reported to a {@link CaptureListener} after the bytes are
|
|
@@ -53,6 +71,20 @@ export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
|
|
|
53
71
|
/** The loop-recovery sentence. Spelled once because it is said in two places
|
|
54
72
|
* (a live console warning and a persisted record) and a second copy is a
|
|
55
73
|
* second wording. */
|
|
74
|
+
/**
|
|
75
|
+
* The cost of writing a capture INSIDE the served project.
|
|
76
|
+
*
|
|
77
|
+
* The dev server watches the project root, and its ignore list names build
|
|
78
|
+
* output only — nothing about capture output — so every PNG written under the
|
|
79
|
+
* root goes through the watcher. Measured cost class: ~3x slower frame rates
|
|
80
|
+
* while a capture loop wrote there. Named, not fixed: where a capture goes is
|
|
81
|
+
* the caller's decision, and this door's job is to stop that decision being
|
|
82
|
+
* made blind.
|
|
83
|
+
*/
|
|
84
|
+
const WATCHED_CAPTURE_PATH_CAVEAT =
|
|
85
|
+
'writing captures under the project root triggers the dev server’s file watcher; expect ~3× ' +
|
|
86
|
+
'slower frame rates — write outside the project or to the session’s own capture dir';
|
|
87
|
+
|
|
56
88
|
const LOOP_RECOVERY_FRAME_CAVEAT =
|
|
57
89
|
'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
|
|
58
90
|
'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
|
|
@@ -61,9 +93,9 @@ const LOOP_RECOVERY_FRAME_CAVEAT =
|
|
|
61
93
|
* What a reader must be told about this frame, or `null` when there is nothing
|
|
62
94
|
* to tell.
|
|
63
95
|
*
|
|
64
|
-
*
|
|
65
|
-
* out near-blank), and
|
|
66
|
-
* must not report only the first reason.
|
|
96
|
+
* Any of them can be true at once (an on-demand recovery tick that also came
|
|
97
|
+
* out near-blank, in a recorded run), and each is said — a capture that is
|
|
98
|
+
* degraded twice over must not report only the first reason.
|
|
67
99
|
*/
|
|
68
100
|
export function describeCaptureCaveat(notes: CaptureNotes): string | null {
|
|
69
101
|
const parts: string[] = [];
|
|
@@ -71,5 +103,14 @@ export function describeCaptureCaveat(notes: CaptureNotes): string | null {
|
|
|
71
103
|
if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
|
|
72
104
|
parts.push(notes.flatnessWarning);
|
|
73
105
|
}
|
|
106
|
+
if (typeof notes.recordingPath === 'string' && notes.recordingPath !== '') {
|
|
107
|
+
parts.push(
|
|
108
|
+
`ONE FRAME of a recorded run — ${notes.recordingPath} holds the whole of it. A still ` +
|
|
109
|
+
'answers what it looks like; a temporal question (did the jump land) needs the clip.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (typeof notes.watchedProjectRoot === 'string' && notes.watchedProjectRoot !== '') {
|
|
113
|
+
parts.push(`${WATCHED_CAPTURE_PATH_CAVEAT} (${notes.watchedProjectRoot}).`);
|
|
114
|
+
}
|
|
74
115
|
return parts.length === 0 ? null : parts.join(' ');
|
|
75
116
|
}
|
|
@@ -256,6 +256,11 @@ export interface GameClientOptions {
|
|
|
256
256
|
* actually running). */
|
|
257
257
|
fenceWallMs: number;
|
|
258
258
|
artifactsDir?: string | undefined;
|
|
259
|
+
/** The project root the editor session is SERVING, when the caller knows it
|
|
260
|
+
* (`@vgai/live`'s `connect()` does). Used for one thing: saying, in a
|
|
261
|
+
* capture's own notes, that its destination falls under the dev server's
|
|
262
|
+
* file watcher — see `capture-notes.ts`. Never used to resolve a path. */
|
|
263
|
+
projectRoot?: string | undefined;
|
|
259
264
|
/** Set by the caller when it reused an already-running game server rather
|
|
260
265
|
* than booting a fresh one for this run. Threaded through so `unwrap` can
|
|
261
266
|
* append the warm-session staleness hint to a
|
|
@@ -572,6 +577,8 @@ export class GameClient {
|
|
|
572
577
|
readonly #transport: BridgeTransport;
|
|
573
578
|
/** Where a labelled `screenshot()` lands — project-scoped by `@vgai/live`, cwd-relative otherwise. Public: a caller reading it is asking a fair question, and `screenshot()` returns a path under it anyway. */
|
|
574
579
|
readonly artifactsDir: string;
|
|
580
|
+
/** See `GameClientOptions.projectRoot`. */
|
|
581
|
+
readonly #projectRoot: string | undefined;
|
|
575
582
|
/** See `GameClientOptions.warmSession`. */
|
|
576
583
|
readonly #warmSession: boolean;
|
|
577
584
|
/** See `GameClientOptions.testTitle`. */
|
|
@@ -603,6 +610,7 @@ export class GameClient {
|
|
|
603
610
|
this.fenceSimSeconds = opts.fenceSimSeconds;
|
|
604
611
|
this.fenceWallMs = opts.fenceWallMs;
|
|
605
612
|
this.artifactsDir = opts.artifactsDir ?? resolve('.vgai/last-run');
|
|
613
|
+
this.#projectRoot = opts.projectRoot;
|
|
606
614
|
this.#warmSession = opts.warmSession ?? false;
|
|
607
615
|
this.#testTitle = opts.testTitle ?? 'test';
|
|
608
616
|
this.#bridgeHeartbeat = { lastEmitWallMs: Date.now() };
|
|
@@ -862,15 +870,32 @@ export class GameClient {
|
|
|
862
870
|
artifactsDir: this.artifactsDir,
|
|
863
871
|
sequence: this.#screenshotCounter + 1,
|
|
864
872
|
cwd: process.cwd(),
|
|
873
|
+
projectRoot: this.#projectRoot,
|
|
865
874
|
});
|
|
866
875
|
if (target.consumedSequence) this.#screenshotCounter += 1;
|
|
867
876
|
await mkdir(dirname(target.path), { recursive: true });
|
|
868
|
-
const
|
|
877
|
+
const transportNotes = await this.#transport.screenshot(target.path);
|
|
878
|
+
// The out-path resolver is the only thing that knows the destination fell
|
|
879
|
+
// under the served project root, and the notes are where a capture says
|
|
880
|
+
// what it cost — so the two are joined here rather than at either end.
|
|
881
|
+
const notes: CaptureNotes = {
|
|
882
|
+
...transportNotes,
|
|
883
|
+
...(target.underWatchedProjectRoot === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: { watchedProjectRoot: target.underWatchedProjectRoot }),
|
|
886
|
+
};
|
|
869
887
|
const capture = {
|
|
870
888
|
label: labelOrPath,
|
|
871
889
|
path: target.path,
|
|
872
890
|
caveat: describeCaptureCaveat(notes),
|
|
873
891
|
};
|
|
892
|
+
// ONE place says the sentence, with the WHOLE note set — a human watching
|
|
893
|
+
// this terminal and a run record read beside the frame must not be told
|
|
894
|
+
// different things (`capture-notes.ts`'s contract). The transport used to
|
|
895
|
+
// warn from its own partial set, which silently dropped every note this
|
|
896
|
+
// layer adds.
|
|
897
|
+
if (capture.caveat !== null)
|
|
898
|
+
console.warn(`vgai screenshot: ${capture.path} — ${capture.caveat}`);
|
|
874
899
|
// Sequential and AWAITED: a listener may need to read the game to stamp
|
|
875
900
|
// this capture, and it must have finished before the path is handed back —
|
|
876
901
|
// a caller that files the path is entitled to assume the record of it is
|
|
@@ -941,11 +966,27 @@ export class GameClient {
|
|
|
941
966
|
* mount's own url space, so what you touch IS the running game — never a
|
|
942
967
|
* phantom second copy. Dev-server sessions only; a shipped build's curated
|
|
943
968
|
* surface is its adapter exports.
|
|
969
|
+
*
|
|
970
|
+
* `modules(path)` IS ASYNC — it dynamic-imports that url — so the callback
|
|
971
|
+
* is `async` and the call is `await`ed, in every example on this page and
|
|
972
|
+
* everywhere else. Skipping it does not fail quietly: reading a member off
|
|
973
|
+
* the unawaited promise throws a message naming the fix, because
|
|
974
|
+
* `TypeError: modules(...).simHost is not a function` (measured, cold fox
|
|
975
|
+
* #3) says nothing about promises.
|
|
976
|
+
*
|
|
977
|
+
* `modules` IS A FUNCTION, not a table — there is no module registry. To ask
|
|
978
|
+
* what the mount has loaded, read `modules.loaded`, the project-relative
|
|
979
|
+
* paths you can pass straight back in:
|
|
980
|
+
*
|
|
981
|
+
* ```js
|
|
982
|
+
* await game.run(({ modules }) => modules.loaded)
|
|
983
|
+
* // → ['src/scenes/MainScene.tsx', 'src/world.tsx', …]
|
|
984
|
+
* ```
|
|
944
985
|
*/
|
|
945
986
|
async run<T = unknown>(
|
|
946
987
|
step: (scope: {
|
|
947
988
|
page: Page;
|
|
948
|
-
modules: (path: string) => Promise<Record<string, unknown
|
|
989
|
+
modules: ((path: string) => Promise<Record<string, unknown>>) & { readonly loaded: string[] };
|
|
949
990
|
instanceId: string;
|
|
950
991
|
}) => T | Promise<T>,
|
|
951
992
|
opts?: { instance?: string },
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
23
23
|
import { dirname } from 'node:path';
|
|
24
24
|
import type { BridgeCallOutcome, BridgeTransport } from './bridge-transport.js';
|
|
25
|
-
import {
|
|
25
|
+
import type { CaptureNotes } from './capture-notes.js';
|
|
26
26
|
|
|
27
27
|
export interface RelayTransportOptions {
|
|
28
28
|
/** The live editor session's dev-server port (e.g. from
|
|
@@ -310,15 +310,20 @@ export class RelayTransport implements BridgeTransport {
|
|
|
310
310
|
// is indistinguishable from a good one until somebody opens the file —
|
|
311
311
|
// which is exactly how two probes cited blank captures as evidence. The
|
|
312
312
|
// page measured both conditions; carry them back so the caller can persist
|
|
313
|
-
// them beside the frame
|
|
314
|
-
//
|
|
313
|
+
// them beside the frame.
|
|
314
|
+
//
|
|
315
|
+
// The SENTENCE is not said here. `GameClient.screenshot` composes it from
|
|
316
|
+
// these notes PLUS the ones only it has (the out-path's relation to the
|
|
317
|
+
// served project root), and `capture-notes.ts`'s whole contract is that
|
|
318
|
+
// the terminal and the persisted record cannot drift — which a second
|
|
319
|
+
// `console.warn` on a partial set is exactly how they would.
|
|
315
320
|
const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
|
|
321
|
+
const recordingPath = (body['recording'] as { path?: string } | undefined)?.path;
|
|
316
322
|
const notes: CaptureNotes = {
|
|
317
323
|
...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
|
|
318
324
|
...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
|
|
325
|
+
...(typeof recordingPath === 'string' ? { recordingPath } : {}),
|
|
319
326
|
};
|
|
320
|
-
const caveat = describeCaptureCaveat(notes);
|
|
321
|
-
if (caveat !== null) console.warn(`vgai screenshot: ${path} — ${caveat}`);
|
|
322
327
|
return notes;
|
|
323
328
|
}
|
|
324
329
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* already documents.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { isAbsolute, resolve } from 'node:path';
|
|
23
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
24
24
|
|
|
25
25
|
/** A caller's argument, classified. */
|
|
26
26
|
export type ScreenshotArgKind = 'path' | 'label';
|
|
@@ -57,6 +57,10 @@ export interface ScreenshotTargetInput {
|
|
|
57
57
|
readonly sequence: number;
|
|
58
58
|
/** Base for resolving a relative path/artifactsDir — the process cwd. */
|
|
59
59
|
readonly cwd: string;
|
|
60
|
+
/** The project root the dev server is SERVING, when it is known. Only used
|
|
61
|
+
* to say whether the destination lands under the file watcher — see
|
|
62
|
+
* {@link ScreenshotTarget.underWatchedProjectRoot}. */
|
|
63
|
+
readonly projectRoot?: string | undefined;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface ScreenshotTarget {
|
|
@@ -66,6 +70,34 @@ export interface ScreenshotTarget {
|
|
|
66
70
|
/** True when the caller's own ordinal was consumed (label form only), so a
|
|
67
71
|
* path-form call never perturbs the numbering of the artifacts around it. */
|
|
68
72
|
readonly consumedSequence: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The served project root this destination falls under, when it does.
|
|
75
|
+
*
|
|
76
|
+
* The dev server watches the project root for source changes and its
|
|
77
|
+
* `server.watch.ignored` list names build output only (`dist/`, `.vercel/`,
|
|
78
|
+
* `logs/`, `.claude/worktrees/`) — nothing about capture output. So a PNG
|
|
79
|
+
* written anywhere under the root goes through the watcher on every shot,
|
|
80
|
+
* and the cost is not theoretical: measured ~3x slower frame rates while a
|
|
81
|
+
* capture loop wrote under the project. This does not CHANGE where anything
|
|
82
|
+
* is written; it lets the capture say what it costs
|
|
83
|
+
* ({@link import('./capture-notes.js').describeCaptureCaveat}).
|
|
84
|
+
*/
|
|
85
|
+
readonly underWatchedProjectRoot?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Is `file` inside `root` (not merely sharing a path prefix)? */
|
|
89
|
+
function isUnder(root: string, file: string): boolean {
|
|
90
|
+
const rel = relative(resolve(root), file);
|
|
91
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The watched-root note, when the destination has one. */
|
|
95
|
+
function watchedRoot(
|
|
96
|
+
path: string,
|
|
97
|
+
projectRoot: string | undefined,
|
|
98
|
+
): { underWatchedProjectRoot: string } | Record<string, never> {
|
|
99
|
+
if (projectRoot === undefined || projectRoot === '') return {};
|
|
100
|
+
return isUnder(projectRoot, path) ? { underWatchedProjectRoot: resolve(projectRoot) } : {};
|
|
69
101
|
}
|
|
70
102
|
|
|
71
103
|
/**
|
|
@@ -77,15 +109,23 @@ export function resolveScreenshotTarget(input: ScreenshotTargetInput): Screensho
|
|
|
77
109
|
const kind = classifyScreenshotArg(input.arg);
|
|
78
110
|
if (kind === 'path') {
|
|
79
111
|
const withExtension = /\.[a-zA-Z0-9]{1,8}$/.test(input.arg) ? input.arg : `${input.arg}.png`;
|
|
112
|
+
const path = isAbsolute(withExtension) ? withExtension : resolve(input.cwd, withExtension);
|
|
80
113
|
return {
|
|
81
114
|
kind,
|
|
82
|
-
path
|
|
115
|
+
path,
|
|
83
116
|
consumedSequence: false,
|
|
117
|
+
...watchedRoot(path, input.projectRoot),
|
|
84
118
|
};
|
|
85
119
|
}
|
|
86
120
|
const fileName = `${String(input.sequence).padStart(3, '0')}-${sanitizeScreenshotLabel(input.arg)}.png`;
|
|
87
121
|
const dir = isAbsolute(input.artifactsDir)
|
|
88
122
|
? input.artifactsDir
|
|
89
123
|
: resolve(input.cwd, input.artifactsDir);
|
|
90
|
-
|
|
124
|
+
const path = resolve(dir, fileName);
|
|
125
|
+
return {
|
|
126
|
+
kind,
|
|
127
|
+
path,
|
|
128
|
+
consumedSequence: true,
|
|
129
|
+
...watchedRoot(path, input.projectRoot),
|
|
130
|
+
};
|
|
91
131
|
}
|
package/src/game.ts
CHANGED
|
@@ -66,7 +66,12 @@ export interface LiveGame extends GameClient {
|
|
|
66
66
|
* fence. `pageErrors`/`consoleErrors` are always empty — relay mode has no
|
|
67
67
|
* separate page handle to listen on.
|
|
68
68
|
*/
|
|
69
|
-
function gameClientFor(
|
|
69
|
+
function gameClientFor(
|
|
70
|
+
port: number,
|
|
71
|
+
artifactsDir?: string,
|
|
72
|
+
instance?: string,
|
|
73
|
+
projectRoot?: string,
|
|
74
|
+
): GameClient {
|
|
70
75
|
return new GameClient({
|
|
71
76
|
transport: new RelayTransport(instance === undefined ? { port } : { port, instance }),
|
|
72
77
|
pageErrors: [],
|
|
@@ -77,14 +82,19 @@ function gameClientFor(port: number, artifactsDir?: string, instance?: string):
|
|
|
77
82
|
fenceWallMs: Date.now(),
|
|
78
83
|
warmSession: false,
|
|
79
84
|
artifactsDir,
|
|
85
|
+
projectRoot,
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
/** The unaddressed `game` client — targets the sole live instance and refuses
|
|
84
90
|
* when several are mounted. Kept as a named export for callers/tests that
|
|
85
91
|
* want just the base client. */
|
|
86
|
-
export function createGameClient(
|
|
87
|
-
|
|
92
|
+
export function createGameClient(
|
|
93
|
+
port: number,
|
|
94
|
+
artifactsDir?: string,
|
|
95
|
+
projectRoot?: string,
|
|
96
|
+
): GameClient {
|
|
97
|
+
return gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/** Query the editor's live instance ids over the session wire.
|
|
@@ -110,13 +120,17 @@ async function listInstanceIds(port: number): Promise<string[]> {
|
|
|
110
120
|
|
|
111
121
|
/** Build the `LiveGame` — the base `game` client plus its instance-addressing
|
|
112
122
|
* surface. */
|
|
113
|
-
export function createLiveGame(
|
|
114
|
-
|
|
123
|
+
export function createLiveGame(
|
|
124
|
+
port: number,
|
|
125
|
+
artifactsDir?: string,
|
|
126
|
+
projectRoot?: string,
|
|
127
|
+
): LiveGame {
|
|
128
|
+
const base = gameClientFor(port, artifactsDir, undefined, projectRoot);
|
|
115
129
|
// Tag each addressed handle with the mount id it drives, so a caller can pass
|
|
116
130
|
// `handle.id` to `tools.run(..., { instance })`. The id is already known here
|
|
117
131
|
// (it is what parameterizes the relay); attaching it just hands it back.
|
|
118
132
|
const instance = (id: string): AddressedGameClient =>
|
|
119
|
-
Object.assign(gameClientFor(port, artifactsDir, id), { id });
|
|
133
|
+
Object.assign(gameClientFor(port, artifactsDir, id, projectRoot), { id });
|
|
120
134
|
const instances = async (): Promise<AddressedGameClient[]> =>
|
|
121
135
|
(await listInstanceIds(port)).map(instance);
|
|
122
136
|
return Object.assign(base, { instance, instances });
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*
|
|
30
30
|
* `editor.document` is the OTHER surface door and the one that is NOT
|
|
31
31
|
* play-mode gated: `page(step)` is rooted at the running GAME container, so
|
|
32
|
-
* `editor.document.{query,click,key,paste}` is how an editor surface that is
|
|
32
|
+
* `editor.document.{query,click,key,paste,select}` is how an editor surface that is
|
|
33
33
|
* not a game — a capability's workspace document, the Data sheet — gets read
|
|
34
34
|
* and driven through the product. It is scoped to the ACTIVE document and
|
|
35
35
|
* refuses anything outside it by name (`editor-document.ts`).
|
|
@@ -125,7 +125,7 @@ export interface LiveBindings {
|
|
|
125
125
|
function bindTo(port: number, projectRoot: string): LiveBindings {
|
|
126
126
|
const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
|
|
127
127
|
const editor = new LiveEditor(client);
|
|
128
|
-
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
|
|
128
|
+
const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'), projectRoot);
|
|
129
129
|
const step: GameClient['page'] = (fn) => game.page(fn);
|
|
130
130
|
const page: PageStep = Object.assign(step, { reload: () => game.reloadPage() });
|
|
131
131
|
const tools = new LiveTools(client);
|