@vgai/sdk 0.4.0-canary.20260715.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/package.json +27 -0
- package/src/cinematic/capabilities-operations.ts +128 -0
- package/src/cinematic/cue-operations.ts +198 -0
- package/src/cinematic/gsap-operations.ts +126 -0
- package/src/cinematic/index.ts +59 -0
- package/src/cinematic/preview-operations.ts +279 -0
- package/src/cinematic/preview-transport.ts +244 -0
- package/src/cinematic/render-operations.ts +409 -0
- package/src/cinematic/render-transport.ts +238 -0
- package/src/cinematic/theatre-operations.ts +306 -0
- package/src/editor/camera-operations.ts +169 -0
- package/src/editor/console-operations.ts +87 -0
- package/src/editor/hierarchy-operations.ts +95 -0
- package/src/editor/index.ts +62 -0
- package/src/editor/open-operations.ts +209 -0
- package/src/editor/screenshot-operations.ts +99 -0
- package/src/editor/selection-operations.ts +144 -0
- package/src/editor/session-operations.ts +73 -0
- package/src/editor/source-location-operations.ts +106 -0
- package/src/editor/transport.ts +647 -0
- package/src/errors.ts +72 -0
- package/src/http/http-projection.ts +349 -0
- package/src/http/index.ts +11 -0
- package/src/index.ts +67 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/mcp-projection.ts +288 -0
- package/src/operations.ts +83 -0
- package/src/play/control-operations.ts +205 -0
- package/src/play/debug-command-operations.ts +245 -0
- package/src/play/index.ts +66 -0
- package/src/play/input-operations.ts +316 -0
- package/src/play/lifecycle-operations.ts +271 -0
- package/src/play/log-operations.ts +279 -0
- package/src/play/run-ticks-operations.ts +141 -0
- package/src/play/state-operations.ts +210 -0
- package/src/play/status-operations.ts +160 -0
- package/src/play/transport.ts +728 -0
- package/src/project/asset-operations.ts +243 -0
- package/src/project/component-operations.ts +337 -0
- package/src/project/discovery-operations.ts +269 -0
- package/src/project/entity-operations.ts +366 -0
- package/src/project/index.ts +55 -0
- package/src/project/input-map-operations.ts +233 -0
- package/src/project/manifest-operations.ts +355 -0
- package/src/project/scene-operations.ts +426 -0
- package/src/project/shared.ts +299 -0
- package/src/registry.ts +285 -0
- package/src/render/capabilities/ffmpeg.ts +141 -0
- package/src/render/index.ts +15 -0
- package/src/render/render-cinematic.ts +1847 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport seam for B3's `editor.*` operations
|
|
3
|
+
* (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B3).
|
|
4
|
+
*
|
|
5
|
+
* Every `editor.*` op needs two things this module provides:
|
|
6
|
+
*
|
|
7
|
+
* 1. DETERMINISTIC SESSION SELECTION (`resolveEditorSession`) — when more
|
|
8
|
+
* than one `vgai edit` dev server is running, which one does an op
|
|
9
|
+
* without an explicit target mean? The rule (documented on
|
|
10
|
+
* `resolveEditorSession` below) mirrors the CLI's own existing
|
|
11
|
+
* precedent (`packages/vgai-cli/src/index.ts`'s `getClient()`: explicit
|
|
12
|
+
* override > cwd/project match > default) and adds an explicit,
|
|
13
|
+
* documented tie-break for the *multi-session, no-match* case: lowest
|
|
14
|
+
* port wins.
|
|
15
|
+
*
|
|
16
|
+
* 2. A NAMED-TIMEOUT, NEVER-HANG TRANSPORT (`EditorTransport` +
|
|
17
|
+
* `HttpEditorTransport`) — every network call this module makes is
|
|
18
|
+
* wrapped in `withTimeout`, so a dead/unreachable/slow editor process
|
|
19
|
+
* resolves to a structured failure within a bounded time instead of
|
|
20
|
+
* hanging `dispatch()` forever (§8 B3 AC: "Operations fail with
|
|
21
|
+
* EDITOR_NOT_RUNNING rather than hanging").
|
|
22
|
+
*
|
|
23
|
+
* `EditorTransport` is INJECTABLE via `ctx['editorTransport']` (a plain
|
|
24
|
+
* bag key on `OperationContext`'s open `[key: string]: unknown` — no change
|
|
25
|
+
* to `types.ts` needed) so tests can swap in a mock/fake transport and
|
|
26
|
+
* exercise every op headlessly, with no real browser or dev server
|
|
27
|
+
* (see `../../test/editor-operations.test.ts`).
|
|
28
|
+
*
|
|
29
|
+
* REAL TRANSPORT SURFACE — what `HttpEditorTransport` actually talks to,
|
|
30
|
+
* and the honest limits of today's wire protocol:
|
|
31
|
+
*
|
|
32
|
+
* - Session discovery reads `~/.vgai/editor-sessions.json` (the file-format
|
|
33
|
+
* CONTRACT documented at `packages/editor/server/session-registry.ts` —
|
|
34
|
+
* "the CLI has no editor-package dependency ... the FILE FORMAT is the
|
|
35
|
+
* contract"; this module is the vgai-sdk-side analogue of the CLI's own
|
|
36
|
+
* light reader, `packages/vgai-cli/src/editor-sessions.ts`), then
|
|
37
|
+
* PID-liveness-filters and probes each survivor with `GET
|
|
38
|
+
* /__editor/project` (always answered by a running dev server, whether
|
|
39
|
+
* or not a browser tab is attached — see `editor-server.ts`).
|
|
40
|
+
* - Selection read: `GET /__editor/state` — real, works end to end
|
|
41
|
+
* (`EditorState.selectedEntityId`/`selectedEntityIds`, always populated).
|
|
42
|
+
* - Selection set / scene open / asset open / camera set (via
|
|
43
|
+
* focus-entity/focus-selection/view-preset/set-camera): `POST
|
|
44
|
+
* /__editor/command` — the existing SSE command relay (`editor-server.ts`);
|
|
45
|
+
* these command `type`s are real, already-handled cases in the browser's
|
|
46
|
+
* `command-listener.ts` `handleCommand` switch, so they are genuinely
|
|
47
|
+
* wired end to end. Every command relayed through this path that the
|
|
48
|
+
* switch does NOT recognize now fails loudly (`{ok:false}`) instead of
|
|
49
|
+
* silently acking (B3-followup's false-ack fix — see command-listener.ts)
|
|
50
|
+
* — this module never has to work around that fallthrough by avoiding a
|
|
51
|
+
* command send the way `openStory` below still does.
|
|
52
|
+
* - Hierarchy inspect / viewport camera read (B3-followup, now genuinely
|
|
53
|
+
* wired): `collectState` (`packages/editor/src/command-listener.ts`) now
|
|
54
|
+
* reports the real flattened entity tree (`entities`, reusing
|
|
55
|
+
* `EditorStore.entities` — the same nested `SceneEntity[]` data model
|
|
56
|
+
* `GameHierarchy` renders from) and the real live viewport camera pose
|
|
57
|
+
* (`camera`, off `EditorStore.cameraPose` — the bound Three.js camera +
|
|
58
|
+
* orbit target `ViewportPanel.tsx` binds via `bindScene`/`setOrbitTarget`)
|
|
59
|
+
* alongside the pre-existing `entityCount`/`selectedEntityId` fields.
|
|
60
|
+
* `HttpEditorTransport.getHierarchy`/`getCamera` read them off `GET
|
|
61
|
+
* /__editor/state`. Both fields are absent only in the narrow window
|
|
62
|
+
* before any browser tab has POSTed state at least once (a session IS
|
|
63
|
+
* connected — a timing gap, not a fabricated tree/pose); the operations'
|
|
64
|
+
* declared error codes (`HIERARCHY_UNAVAILABLE` / `CAMERA_STATE_UNAVAILABLE`)
|
|
65
|
+
* cover exactly that window, distinct from `EDITOR_NOT_RUNNING`.
|
|
66
|
+
* - Viewport camera SET now also supports an arbitrary pose (not just the
|
|
67
|
+
* three original relay verbs): `{mode:'pose', position, target, fov?}`
|
|
68
|
+
* sends `{type:'set-camera', ...}`, a real, handled case that calls
|
|
69
|
+
* `EditorViewport.setPose` (moves camera position + orbit target, updates
|
|
70
|
+
* fov/projection matrix when given).
|
|
71
|
+
* - Source-location lookup: `GET /__ui-source/index` — the OID -> {file,
|
|
72
|
+
* line, col, component, tag} map built by `vite-plugin-ui-oid.ts`'s
|
|
73
|
+
* `OidStore` (`packages/editor/src/ui-source/oid-transform.ts`). This
|
|
74
|
+
* endpoint is PURELY a GET of an in-memory index; it never calls any of
|
|
75
|
+
* that plugin's write endpoints (`/__ui-source/write|css|text|prop|
|
|
76
|
+
* struct|struct-many|restore`), so it structurally cannot rewrite JSX.
|
|
77
|
+
* - Viewport screenshot (B3-followup, now genuinely wired for the common
|
|
78
|
+
* case): `getScreenshot` first tries `POST /__editor/command`
|
|
79
|
+
* `{type:'capture-viewport'}` — a real, handled case that renders the
|
|
80
|
+
* LIVE viewport on demand (`EditorStore.captureViewportImage`, the same
|
|
81
|
+
* offscreen-render-target technique the periodic autosave thumbnail
|
|
82
|
+
* already used) and returns PNG bytes through the command-result payload
|
|
83
|
+
* (`commandResponseFor`'s `data` passthrough, `server-utils.ts`). Falls
|
|
84
|
+
* back to `GET /__editor/project-thumbnail?path=<project>` — the
|
|
85
|
+
* LAST-SAVED thumbnail (written via `POST /__editor/save-thumbnail`,
|
|
86
|
+
* which the editor UI also calls on its own cadence) — only when no
|
|
87
|
+
* browser tab can answer the relay, or the viewport hasn't bound a
|
|
88
|
+
* renderer/scene/camera yet. The result schema's `fresh` field names
|
|
89
|
+
* which path answered.
|
|
90
|
+
* - Console/log subscription metadata: describes the real, existing SSE
|
|
91
|
+
* endpoint (`GET /__editor/events`) + log endpoints
|
|
92
|
+
* (`POST /__editor/log-session`, `GET/POST /__editor/log-entries`) and
|
|
93
|
+
* reports the CURRENTLY buffered play-mode log-entry count — metadata
|
|
94
|
+
* about how to subscribe, not a live streaming call (a request/response
|
|
95
|
+
* SDK op cannot itself be a stream).
|
|
96
|
+
* - Story open remains an HONEST GAP: the editor has no Storybook/CSF
|
|
97
|
+
* story-preview surface at all today — there is no browser-side command
|
|
98
|
+
* handler to add one FOR (`command-listener.ts`'s `handleCommand` switch
|
|
99
|
+
* has no such case, and there is no story runner anywhere in
|
|
100
|
+
* `packages/editor/src` to wire one to). `HttpEditorTransport.openStory`
|
|
101
|
+
* therefore never sends a command; it reports the gap as a declared
|
|
102
|
+
* `STORY_OPEN_UNSUPPORTED` error instead of a fabricated success. (Had it
|
|
103
|
+
* sent an unrecognized command anyway, the B3-followup false-ack fix means
|
|
104
|
+
* it would now fail loudly rather than silently ack — but this path is
|
|
105
|
+
* deliberately never exercised in the first place.)
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
109
|
+
import { homedir } from 'node:os';
|
|
110
|
+
import { join, resolve } from 'node:path';
|
|
111
|
+
import { z } from 'zod';
|
|
112
|
+
import { OperationError } from '../errors.js';
|
|
113
|
+
import type { ErrorDefinition } from '../registry.js';
|
|
114
|
+
import type { OperationContext } from '../types.js';
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Named timeouts (§8 B3 AC: "enforce a named timeout, don't block forever")
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Bound on discovering + probe-verifying live editor sessions. */
|
|
121
|
+
export const EDITOR_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
|
|
122
|
+
/** Bound on a single per-session liveness probe (GET /__editor/project). */
|
|
123
|
+
export const EDITOR_PROBE_TIMEOUT_MS = 1500;
|
|
124
|
+
/** Bound on a read (GET /__editor/state, /__ui-source/index, thumbnail, log-entries). */
|
|
125
|
+
export const EDITOR_READ_TIMEOUT_MS = 3000;
|
|
126
|
+
/**
|
|
127
|
+
* Bound on a relayed command (POST /__editor/command). Mirrors the real
|
|
128
|
+
* relay's own `COMMAND_TIMEOUT_MS` (`packages/editor/server/editor-server.ts`)
|
|
129
|
+
* — an editor IS connected but did not answer in time.
|
|
130
|
+
*/
|
|
131
|
+
export const EDITOR_COMMAND_TIMEOUT_MS = 5000;
|
|
132
|
+
|
|
133
|
+
/** Thrown by `withTimeout` when the wrapped promise doesn't settle in time. */
|
|
134
|
+
export class EditorTimeoutError extends Error {
|
|
135
|
+
constructor(label: string, ms: number) {
|
|
136
|
+
super(`${label} timed out after ${ms}ms`);
|
|
137
|
+
this.name = 'EditorTimeoutError';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Race `promise` against a `ms`-bounded timer. Guarantees THIS function
|
|
143
|
+
* returns/rejects within `ms` regardless of what `promise` (real fetch, or a
|
|
144
|
+
* test's deliberately-never-resolving mock transport) actually does — the
|
|
145
|
+
* mechanism that makes "no hang" true for every `editor.*` op, not just the
|
|
146
|
+
* ones talking to a well-behaved transport.
|
|
147
|
+
*/
|
|
148
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
149
|
+
return new Promise<T>((resolvePromise, reject) => {
|
|
150
|
+
const timer = setTimeout(() => reject(new EditorTimeoutError(label, ms)), ms);
|
|
151
|
+
promise.then(
|
|
152
|
+
(v) => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
resolvePromise(v);
|
|
155
|
+
},
|
|
156
|
+
(err) => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Shared shapes
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
export interface EditorSessionInfo {
|
|
169
|
+
/** The port this session's dev server is bound to. */
|
|
170
|
+
port: number;
|
|
171
|
+
/** Canonical project root path currently open, or null when none. */
|
|
172
|
+
project: string | null;
|
|
173
|
+
/** Dev-server process id, when known from the local session registry (null for an unregistered/legacy server the caller only probed by port). */
|
|
174
|
+
pid: number | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface EditorCommandResult {
|
|
178
|
+
ok: boolean;
|
|
179
|
+
error?: string;
|
|
180
|
+
/** Optional payload for commands that answer with data (e.g. `capture-viewport`'s base64 PNG). */
|
|
181
|
+
data?: Record<string, unknown>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface OidSourceEntry {
|
|
185
|
+
file: string;
|
|
186
|
+
line: number;
|
|
187
|
+
col: number;
|
|
188
|
+
component: string | null;
|
|
189
|
+
tag: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface HierarchyNodeInfo {
|
|
193
|
+
id: string;
|
|
194
|
+
name: string;
|
|
195
|
+
childIds: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface HierarchySummary {
|
|
199
|
+
savePath: string | null;
|
|
200
|
+
entityCount: number;
|
|
201
|
+
/** Full node list, or null when the connected editor's wire protocol doesn't carry a tree (see module jsdoc). */
|
|
202
|
+
entities: HierarchyNodeInfo[] | null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface CameraPose {
|
|
206
|
+
position: { x: number; y: number; z: number };
|
|
207
|
+
target: { x: number; y: number; z: number };
|
|
208
|
+
fov: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type CameraSetRequest =
|
|
212
|
+
| { mode: 'focusEntity'; entityId: string }
|
|
213
|
+
| { mode: 'focusSelection' }
|
|
214
|
+
| { mode: 'viewPreset'; preset: 'top' | 'front' | 'right' | 'perspective' }
|
|
215
|
+
| {
|
|
216
|
+
mode: 'pose';
|
|
217
|
+
position: { x: number; y: number; z: number };
|
|
218
|
+
target: { x: number; y: number; z: number };
|
|
219
|
+
fov?: number | undefined;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export interface ScreenshotResult {
|
|
223
|
+
base64: string;
|
|
224
|
+
mimeType: string;
|
|
225
|
+
/** True when this came from an on-demand `capture-viewport` render just now; false for the last-saved thumbnail fallback. */
|
|
226
|
+
fresh: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface ConsoleSubscriptionMetadata {
|
|
230
|
+
sseUrl: string;
|
|
231
|
+
events: string[];
|
|
232
|
+
logEntriesUrl: string;
|
|
233
|
+
logSessionUrl: string;
|
|
234
|
+
bufferedLogCount: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The transport seam every `editor.*` op's `impl` goes through. Real
|
|
239
|
+
* production dispatch uses `HttpEditorTransport` (the default, lazily
|
|
240
|
+
* constructed); tests inject a fake via `ctx['editorTransport']` — see
|
|
241
|
+
* `getTransport` below.
|
|
242
|
+
*/
|
|
243
|
+
export interface EditorTransport {
|
|
244
|
+
listSessions(timeoutMs: number): Promise<EditorSessionInfo[]>;
|
|
245
|
+
getSelection(
|
|
246
|
+
session: EditorSessionInfo,
|
|
247
|
+
timeoutMs: number,
|
|
248
|
+
): Promise<{ selectedEntityId: string | null; selectedEntityIds: string[] } | undefined>;
|
|
249
|
+
getHierarchy(
|
|
250
|
+
session: EditorSessionInfo,
|
|
251
|
+
timeoutMs: number,
|
|
252
|
+
): Promise<HierarchySummary | undefined>;
|
|
253
|
+
getCamera(session: EditorSessionInfo, timeoutMs: number): Promise<CameraPose | undefined>;
|
|
254
|
+
setCamera(
|
|
255
|
+
session: EditorSessionInfo,
|
|
256
|
+
request: CameraSetRequest,
|
|
257
|
+
timeoutMs: number,
|
|
258
|
+
): Promise<EditorCommandResult>;
|
|
259
|
+
/** The whole live OID -> source-location index, or undefined if unreachable. */
|
|
260
|
+
getSourceIndex(
|
|
261
|
+
session: EditorSessionInfo,
|
|
262
|
+
timeoutMs: number,
|
|
263
|
+
): Promise<Record<string, OidSourceEntry> | undefined>;
|
|
264
|
+
getScreenshot(
|
|
265
|
+
session: EditorSessionInfo,
|
|
266
|
+
timeoutMs: number,
|
|
267
|
+
): Promise<ScreenshotResult | undefined>;
|
|
268
|
+
getConsoleSubscriptionMetadata(
|
|
269
|
+
session: EditorSessionInfo,
|
|
270
|
+
timeoutMs: number,
|
|
271
|
+
): Promise<ConsoleSubscriptionMetadata | undefined>;
|
|
272
|
+
sendCommand(
|
|
273
|
+
session: EditorSessionInfo,
|
|
274
|
+
command: Record<string, unknown>,
|
|
275
|
+
timeoutMs: number,
|
|
276
|
+
): Promise<EditorCommandResult>;
|
|
277
|
+
/** Undefined = the live editor has no wire support for opening a story today (see module jsdoc). */
|
|
278
|
+
openStory(
|
|
279
|
+
session: EditorSessionInfo,
|
|
280
|
+
path: string,
|
|
281
|
+
timeoutMs: number,
|
|
282
|
+
): Promise<EditorCommandResult | undefined>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Real transport
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
interface RegistrySessionEntry {
|
|
290
|
+
project: string | null;
|
|
291
|
+
port: number;
|
|
292
|
+
pid: number;
|
|
293
|
+
startedAt: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
|
|
297
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
298
|
+
const s = v as Record<string, unknown>;
|
|
299
|
+
return (
|
|
300
|
+
(typeof s['project'] === 'string' || s['project'] === null) &&
|
|
301
|
+
typeof s['port'] === 'number' &&
|
|
302
|
+
typeof s['pid'] === 'number' &&
|
|
303
|
+
typeof s['startedAt'] === 'string'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function pidAlive(pid: number): boolean {
|
|
308
|
+
try {
|
|
309
|
+
process.kill(pid, 0);
|
|
310
|
+
return true;
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read `~/.vgai/editor-sessions.json` (the format contract owned by
|
|
318
|
+
* `packages/editor/server/session-registry.ts`), PID-liveness-filtered.
|
|
319
|
+
* Deliberate light duplicate of the read half — see module jsdoc.
|
|
320
|
+
*/
|
|
321
|
+
function readRegisteredSessions(): RegistrySessionEntry[] {
|
|
322
|
+
const registryFile = join(homedir(), '.vgai', 'editor-sessions.json');
|
|
323
|
+
if (!existsSync(registryFile)) return [];
|
|
324
|
+
try {
|
|
325
|
+
const raw: unknown = JSON.parse(readFileSync(registryFile, 'utf8'));
|
|
326
|
+
return Array.isArray(raw)
|
|
327
|
+
? raw.filter(isRegistrySessionEntry).filter((s) => pidAlive(s.pid))
|
|
328
|
+
: [];
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function fetchJson(url: string, timeoutMs: number): Promise<unknown | undefined> {
|
|
335
|
+
try {
|
|
336
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
337
|
+
if (!res.ok) return undefined;
|
|
338
|
+
return await res.json();
|
|
339
|
+
} catch {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function postJson(
|
|
345
|
+
url: string,
|
|
346
|
+
body: unknown,
|
|
347
|
+
timeoutMs: number,
|
|
348
|
+
): Promise<{ status: number; json: unknown } | undefined> {
|
|
349
|
+
try {
|
|
350
|
+
const res = await fetch(url, {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'Content-Type': 'application/json' },
|
|
353
|
+
body: JSON.stringify(body),
|
|
354
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
355
|
+
});
|
|
356
|
+
const json = await res.json().catch(() => undefined);
|
|
357
|
+
return { status: res.status, json };
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function baseUrl(session: EditorSessionInfo): string {
|
|
364
|
+
return `http://localhost:${session.port}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The real, production transport — HTTP against a live `vgai edit` dev server. */
|
|
368
|
+
export class HttpEditorTransport implements EditorTransport {
|
|
369
|
+
async listSessions(timeoutMs: number): Promise<EditorSessionInfo[]> {
|
|
370
|
+
const registered = readRegisteredSessions();
|
|
371
|
+
const perProbeTimeout = Math.min(EDITOR_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
|
|
372
|
+
const probes = await Promise.all(
|
|
373
|
+
registered.map(async (s) => {
|
|
374
|
+
const body = await fetchJson(
|
|
375
|
+
`${baseUrl({ port: s.port, project: null, pid: s.pid })}/__editor/project`,
|
|
376
|
+
perProbeTimeout,
|
|
377
|
+
);
|
|
378
|
+
if (body === undefined) return undefined;
|
|
379
|
+
const project = (body as { project?: { path?: string } | null }).project?.path ?? null;
|
|
380
|
+
const info: EditorSessionInfo = { port: s.port, project, pid: s.pid };
|
|
381
|
+
return info;
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
return probes.filter((p): p is EditorSessionInfo => p !== undefined);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async getSelection(
|
|
388
|
+
session: EditorSessionInfo,
|
|
389
|
+
timeoutMs: number,
|
|
390
|
+
): Promise<{ selectedEntityId: string | null; selectedEntityIds: string[] } | undefined> {
|
|
391
|
+
const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
|
|
392
|
+
| { selectedEntityId?: string | null; selectedEntityIds?: string[] }
|
|
393
|
+
| undefined;
|
|
394
|
+
if (!body) return undefined;
|
|
395
|
+
return {
|
|
396
|
+
selectedEntityId: body.selectedEntityId ?? null,
|
|
397
|
+
selectedEntityIds: body.selectedEntityIds ?? [],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async getHierarchy(
|
|
402
|
+
session: EditorSessionInfo,
|
|
403
|
+
timeoutMs: number,
|
|
404
|
+
): Promise<HierarchySummary | undefined> {
|
|
405
|
+
const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
|
|
406
|
+
| { savePath?: string | null; entityCount?: number; entities?: HierarchyNodeInfo[] }
|
|
407
|
+
| undefined;
|
|
408
|
+
if (!body) return undefined;
|
|
409
|
+
// `collectState` (packages/editor/src/command-listener.ts) now reports the
|
|
410
|
+
// real flattened entity tree alongside entityCount — `entities` is real
|
|
411
|
+
// data whenever a browser tab has POSTed state at least once, `null` only
|
|
412
|
+
// if none ever has (e.g. a session probed before its first command/connect
|
|
413
|
+
// report, which the state route's initial `editorState = {}` also implies).
|
|
414
|
+
return {
|
|
415
|
+
savePath: body.savePath ?? null,
|
|
416
|
+
entityCount: body.entityCount ?? 0,
|
|
417
|
+
entities: body.entities ?? null,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async getCamera(session: EditorSessionInfo, timeoutMs: number): Promise<CameraPose | undefined> {
|
|
422
|
+
const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
|
|
423
|
+
| { camera?: CameraPose }
|
|
424
|
+
| undefined;
|
|
425
|
+
// `collectState` now reports the live viewport camera pose (position,
|
|
426
|
+
// orbit target, fov) whenever `ViewportPanel.tsx` has bound one — `camera`
|
|
427
|
+
// is absent only pre-mount / in a headless store (see EditorStore.cameraPose).
|
|
428
|
+
return body?.camera;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async setCamera(
|
|
432
|
+
session: EditorSessionInfo,
|
|
433
|
+
request: CameraSetRequest,
|
|
434
|
+
timeoutMs: number,
|
|
435
|
+
): Promise<EditorCommandResult> {
|
|
436
|
+
const command =
|
|
437
|
+
request.mode === 'focusEntity'
|
|
438
|
+
? { type: 'focus-entity', id: request.entityId }
|
|
439
|
+
: request.mode === 'focusSelection'
|
|
440
|
+
? { type: 'focus-selection' }
|
|
441
|
+
: request.mode === 'viewPreset'
|
|
442
|
+
? { type: 'view-preset', preset: request.preset }
|
|
443
|
+
: {
|
|
444
|
+
type: 'set-camera',
|
|
445
|
+
position: request.position,
|
|
446
|
+
target: request.target,
|
|
447
|
+
...(request.fov !== undefined ? { fov: request.fov } : {}),
|
|
448
|
+
};
|
|
449
|
+
return this.sendCommand(session, command, timeoutMs);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async getSourceIndex(
|
|
453
|
+
session: EditorSessionInfo,
|
|
454
|
+
timeoutMs: number,
|
|
455
|
+
): Promise<Record<string, OidSourceEntry> | undefined> {
|
|
456
|
+
const body = await fetchJson(`${baseUrl(session)}/__ui-source/index`, timeoutMs);
|
|
457
|
+
if (!body || typeof body !== 'object') return undefined;
|
|
458
|
+
return body as Record<string, OidSourceEntry>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async getScreenshot(
|
|
462
|
+
session: EditorSessionInfo,
|
|
463
|
+
timeoutMs: number,
|
|
464
|
+
): Promise<ScreenshotResult | undefined> {
|
|
465
|
+
// Try a fresh, on-demand capture first (`capture-viewport` — a real,
|
|
466
|
+
// already-handled case in command-listener.ts's handleCommand switch as
|
|
467
|
+
// of B3-followup). Falls back to the last-saved thumbnail snapshot below
|
|
468
|
+
// when no browser tab is attached to answer the relay, or the viewport
|
|
469
|
+
// hasn't bound a renderer/scene/camera yet (e.g. right after page load).
|
|
470
|
+
const fresh = await this.sendCommand(session, { type: 'capture-viewport' }, timeoutMs).catch(
|
|
471
|
+
() => undefined,
|
|
472
|
+
);
|
|
473
|
+
if (fresh?.ok && fresh.data) {
|
|
474
|
+
const base64 = fresh.data['base64'];
|
|
475
|
+
const mimeType = fresh.data['mimeType'];
|
|
476
|
+
if (typeof base64 === 'string' && typeof mimeType === 'string') {
|
|
477
|
+
return { base64, mimeType, fresh: true };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return this.getSavedThumbnail(session, timeoutMs);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The LAST-SAVED thumbnail PNG (fallback path — see `getScreenshot`). */
|
|
484
|
+
private async getSavedThumbnail(
|
|
485
|
+
session: EditorSessionInfo,
|
|
486
|
+
timeoutMs: number,
|
|
487
|
+
): Promise<ScreenshotResult | undefined> {
|
|
488
|
+
if (!session.project) return undefined;
|
|
489
|
+
try {
|
|
490
|
+
const res = await fetch(
|
|
491
|
+
`${baseUrl(session)}/__editor/project-thumbnail?path=${encodeURIComponent(session.project)}`,
|
|
492
|
+
{ signal: AbortSignal.timeout(timeoutMs) },
|
|
493
|
+
);
|
|
494
|
+
if (!res.ok) return undefined;
|
|
495
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
496
|
+
let binary = '';
|
|
497
|
+
for (const byte of buf) binary += String.fromCharCode(byte);
|
|
498
|
+
return { base64: btoa(binary), mimeType: 'image/png', fresh: false };
|
|
499
|
+
} catch {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async getConsoleSubscriptionMetadata(
|
|
505
|
+
session: EditorSessionInfo,
|
|
506
|
+
timeoutMs: number,
|
|
507
|
+
): Promise<ConsoleSubscriptionMetadata | undefined> {
|
|
508
|
+
const body = (await fetchJson(`${baseUrl(session)}/__editor/log-entries`, timeoutMs)) as
|
|
509
|
+
| { entries?: unknown[] }
|
|
510
|
+
| undefined;
|
|
511
|
+
if (!body) return undefined;
|
|
512
|
+
return {
|
|
513
|
+
sseUrl: `${baseUrl(session)}/__editor/events`,
|
|
514
|
+
events: ['editor-command', 'project-changed', 'asset-moved', 'scene-changed'],
|
|
515
|
+
logEntriesUrl: `${baseUrl(session)}/__editor/log-entries`,
|
|
516
|
+
logSessionUrl: `${baseUrl(session)}/__editor/log-session`,
|
|
517
|
+
bufferedLogCount: Array.isArray(body.entries) ? body.entries.length : 0,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async sendCommand(
|
|
522
|
+
session: EditorSessionInfo,
|
|
523
|
+
command: Record<string, unknown>,
|
|
524
|
+
timeoutMs: number,
|
|
525
|
+
): Promise<EditorCommandResult> {
|
|
526
|
+
const result = await postJson(`${baseUrl(session)}/__editor/command`, command, timeoutMs);
|
|
527
|
+
if (!result) return { ok: false, error: 'Command relay unreachable.' };
|
|
528
|
+
const json = result.json as
|
|
529
|
+
| { ok?: boolean; error?: string; [key: string]: unknown }
|
|
530
|
+
| undefined;
|
|
531
|
+
if (json?.ok) {
|
|
532
|
+
// `commandResponseFor` (editor-server.ts) spreads a command's `data`
|
|
533
|
+
// payload alongside `ok: true` at the top level — collect everything
|
|
534
|
+
// that isn't `ok` itself back into `data` for callers like getScreenshot.
|
|
535
|
+
const { ok: _ok, ...data } = json;
|
|
536
|
+
return Object.keys(data).length > 0 ? { ok: true, data } : { ok: true };
|
|
537
|
+
}
|
|
538
|
+
return { ok: false, error: json?.error ?? `Command relay returned status ${result.status}.` };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async openStory(
|
|
542
|
+
_session: EditorSessionInfo,
|
|
543
|
+
_path: string,
|
|
544
|
+
_timeoutMs: number,
|
|
545
|
+
): Promise<EditorCommandResult | undefined> {
|
|
546
|
+
// No browser-side command handler exists for this today (see module
|
|
547
|
+
// jsdoc) — undefined signals the gap; the op turns this into a declared
|
|
548
|
+
// STORY_OPEN_UNSUPPORTED error rather than sending a command the
|
|
549
|
+
// browser would silently no-op and falsely ack.
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const defaultTransport = new HttpEditorTransport();
|
|
555
|
+
|
|
556
|
+
/** Resolve the transport to use: `ctx['editorTransport']` when injected (tests), else the shared real transport. */
|
|
557
|
+
export function getTransport(ctx: OperationContext): EditorTransport {
|
|
558
|
+
const injected = ctx['editorTransport'];
|
|
559
|
+
return (injected as EditorTransport | undefined) ?? defaultTransport;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// EDITOR_NOT_RUNNING + deterministic session resolution
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
export const EDITOR_NOT_RUNNING_ERROR: ErrorDefinition = {
|
|
567
|
+
code: 'EDITOR_NOT_RUNNING',
|
|
568
|
+
summary:
|
|
569
|
+
'No live, responsive editor session is available to target (none running, none matching ' +
|
|
570
|
+
'the requested target, or discovery timed out).',
|
|
571
|
+
data: z.object({ editorUrl: z.string().optional() }),
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
function canonicalize(p: string): string {
|
|
575
|
+
return resolve(p);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
|
|
579
|
+
function portOf(url: string): number | undefined {
|
|
580
|
+
try {
|
|
581
|
+
const port = new URL(url).port;
|
|
582
|
+
return port ? Number(port) : undefined;
|
|
583
|
+
} catch {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Deterministic session selection (§8 B3 AC: "Session selection is
|
|
590
|
+
* deterministic when multiple editors are running"). Precedence, in order:
|
|
591
|
+
*
|
|
592
|
+
* 1. `ctx.editorUrl` given -> the live session on THAT exact port, or
|
|
593
|
+
* EDITOR_NOT_RUNNING if no live session answers there. (Explicit
|
|
594
|
+
* targeting always wins — mirrors `vgai-cli`'s own `--url` override.)
|
|
595
|
+
* 2. `ctx.projectRoot` given (no editorUrl) -> the live session whose
|
|
596
|
+
* canonical project path matches `ctx.projectRoot`'s canonical path.
|
|
597
|
+
* (Mirrors `packages/vgai-cli/src/index.ts`'s `getClient()` cwd rule —
|
|
598
|
+
* "run `vgai play` from inside a project and it targets THAT project's
|
|
599
|
+
* editor, whichever port it landed on".)
|
|
600
|
+
* 3. Neither given, or neither matched -> the LOWEST-numbered port among
|
|
601
|
+
* all live sessions (a total order over the only session field that is
|
|
602
|
+
* always present and stable, so the rule never depends on registration
|
|
603
|
+
* order or wall-clock time).
|
|
604
|
+
* 4. Zero live sessions at all -> EDITOR_NOT_RUNNING.
|
|
605
|
+
*
|
|
606
|
+
* Bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS` via `withTimeout` — a
|
|
607
|
+
* transport whose `listSessions` never resolves (or takes too long) still
|
|
608
|
+
* surfaces EDITOR_NOT_RUNNING within that bound, never a hang.
|
|
609
|
+
*/
|
|
610
|
+
export async function resolveEditorSession(
|
|
611
|
+
ctx: OperationContext,
|
|
612
|
+
transport: EditorTransport,
|
|
613
|
+
): Promise<EditorSessionInfo> {
|
|
614
|
+
const editorUrl = ctx.editorUrl;
|
|
615
|
+
const notRunning = (): never => {
|
|
616
|
+
throw new OperationError('EDITOR_NOT_RUNNING', 'No editor connected.', {
|
|
617
|
+
...(editorUrl !== undefined ? { editorUrl } : {}),
|
|
618
|
+
});
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
let sessions: EditorSessionInfo[];
|
|
622
|
+
try {
|
|
623
|
+
sessions = await withTimeout(
|
|
624
|
+
transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
|
|
625
|
+
EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
|
|
626
|
+
'editor session discovery',
|
|
627
|
+
);
|
|
628
|
+
} catch {
|
|
629
|
+
return notRunning();
|
|
630
|
+
}
|
|
631
|
+
if (sessions.length === 0) return notRunning();
|
|
632
|
+
|
|
633
|
+
if (editorUrl !== undefined) {
|
|
634
|
+
const port = portOf(editorUrl);
|
|
635
|
+
const match = sessions.find((s) => s.port === port);
|
|
636
|
+
if (!match) return notRunning();
|
|
637
|
+
return match;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (ctx.projectRoot !== undefined) {
|
|
641
|
+
const canon = canonicalize(ctx.projectRoot);
|
|
642
|
+
const match = sessions.find((s) => s.project !== null && canonicalize(s.project) === canon);
|
|
643
|
+
if (match) return match;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return [...sessions].sort((a, b) => a.port - b.port)[0]!;
|
|
647
|
+
}
|