@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,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cinematic.render` / `cinematic.render.status` / `cinematic.render.cancel`
|
|
3
|
+
* (B5, §8 B5 "render and render-status", "cancel rendering"; ACs: "Render
|
|
4
|
+
* jobs expose progress, current frame, elapsed time, and output paths";
|
|
5
|
+
* "Cancellation removes incomplete temporary frames unless retention is
|
|
6
|
+
* requested"; "Render errors include the failing frame/time and originating
|
|
7
|
+
* subsystem"). See `./render-transport.ts`'s module jsdoc for the job-
|
|
8
|
+
* registry design this wraps.
|
|
9
|
+
*
|
|
10
|
+
* `cinematic.render` is pinned `host: 'node'` + `longRunning: true` + a real
|
|
11
|
+
* `render: true` requirement, per §8 B1's review-hardened addition
|
|
12
|
+
* ("`cinematic.render` is host `node` and a long-running job — it launches
|
|
13
|
+
* its own browser and MUST NOT ride the editor relay's ~5s timeout") — this
|
|
14
|
+
* is the REAL implementation that replaces B1's `operations.ts` stand-in
|
|
15
|
+
* (which always threw `NOT_IMPLEMENTED`; see that file's updated jsdoc).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { OperationError } from '../errors.js';
|
|
20
|
+
import { defineOperation, type ErrorDefinition, type OperationRegistry } from '../registry.js';
|
|
21
|
+
import {
|
|
22
|
+
RenderCinematicFfmpegError,
|
|
23
|
+
type RenderCinematicRequest,
|
|
24
|
+
RenderCinematicRequestError,
|
|
25
|
+
} from '../render/render-cinematic.js';
|
|
26
|
+
import {
|
|
27
|
+
type CinematicRenderJobStatus,
|
|
28
|
+
getCinematicRenderFn,
|
|
29
|
+
getRenderJob,
|
|
30
|
+
startRenderJob,
|
|
31
|
+
} from './render-transport.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Shared error definitions
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const RENDER_REQUEST_INVALID_ERROR: ErrorDefinition = {
|
|
38
|
+
code: 'RENDER_REQUEST_INVALID',
|
|
39
|
+
summary:
|
|
40
|
+
'The render request failed validation (I1 AC: "fail before launching the browser") — no job ' +
|
|
41
|
+
'was created.',
|
|
42
|
+
data: z.object({ message: z.string() }),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const RENDER_CAPABILITY_UNAVAILABLE_ERROR: ErrorDefinition = {
|
|
46
|
+
code: 'RENDER_CAPABILITY_UNAVAILABLE',
|
|
47
|
+
summary: 'ffmpeg/ffprobe preflight failed (A1) — no job was created.',
|
|
48
|
+
data: z.object({ message: z.string() }),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const RENDER_JOB_NOT_FOUND_ERROR: ErrorDefinition = {
|
|
52
|
+
code: 'RENDER_JOB_NOT_FOUND',
|
|
53
|
+
summary: 'No render job with the given jobId exists (this process’s in-memory job registry).',
|
|
54
|
+
data: z.object({ jobId: z.string() }),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const RENDER_JOB_NOT_RUNNING_ERROR: ErrorDefinition = {
|
|
58
|
+
code: 'RENDER_JOB_NOT_RUNNING',
|
|
59
|
+
summary: 'The job has already settled (completed/failed/cancelled) — nothing to cancel.',
|
|
60
|
+
data: z.object({ jobId: z.string(), status: z.string() }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Shared input/result fragments — mirrors I1's RenderCinematicRequest shape.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const CinematicRenderInput = z.object({
|
|
68
|
+
entry: z
|
|
69
|
+
.string()
|
|
70
|
+
.describe(
|
|
71
|
+
'Either an http(s) URL to an already-serving render-mode page, or a filesystem path to a Vite config to spawn.',
|
|
72
|
+
),
|
|
73
|
+
out: z.string().describe('Output file path (mp4/webm) or output directory (png format).'),
|
|
74
|
+
format: z.enum(['mp4', 'webm', 'png']).optional(),
|
|
75
|
+
fps: z.number().positive().optional(),
|
|
76
|
+
start: z.number().min(0).optional().describe('Range start, seconds. Default 0.'),
|
|
77
|
+
end: z.number().optional().describe('Range end, seconds. Supply exactly one of end/frameCount.'),
|
|
78
|
+
frameCount: z.number().int().positive().optional(),
|
|
79
|
+
width: z.number().int().positive().optional(),
|
|
80
|
+
height: z.number().int().positive().optional(),
|
|
81
|
+
dpr: z.number().positive().optional(),
|
|
82
|
+
seed: z.number().int().optional(),
|
|
83
|
+
background: z.enum(['opaque', 'transparent']).optional(),
|
|
84
|
+
audio: z.enum(['auto', 'on', 'off']).optional(),
|
|
85
|
+
overwrite: z.boolean().optional(),
|
|
86
|
+
keepFrames: z
|
|
87
|
+
.boolean()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(
|
|
90
|
+
'Retain the temporary per-frame PNG directory after the render finishes OR is cancelled.',
|
|
91
|
+
),
|
|
92
|
+
port: z.number().int().positive().optional(),
|
|
93
|
+
simulate: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe(
|
|
97
|
+
'I5: advance the FULL fixed-step gameplay loop (physics/gameplay phases/AI/particles/' +
|
|
98
|
+
'participating audio) between captured frames via window.__vgaiRender.simulateSubsteps, ' +
|
|
99
|
+
'instead of the default sequence-controlled (Theatre/GSAP time-seek only) render.',
|
|
100
|
+
),
|
|
101
|
+
simulateSubsteps: z
|
|
102
|
+
.number()
|
|
103
|
+
.int()
|
|
104
|
+
.positive()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe('Fixed gameplay substeps advanced per captured frame under --simulate. Default 4.'),
|
|
107
|
+
simulateWarmupSubsteps: z
|
|
108
|
+
.number()
|
|
109
|
+
.int()
|
|
110
|
+
.min(0)
|
|
111
|
+
.optional()
|
|
112
|
+
.describe(
|
|
113
|
+
'Fixed gameplay substeps advanced ONCE, before frame 0, under --simulate. Default 0 (no warmup).',
|
|
114
|
+
),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const CinematicRenderResult = z.object({
|
|
118
|
+
jobId: z.string().describe('Opaque render job handle — poll with cinematic.render.status.'),
|
|
119
|
+
outPath: z.string().describe('Destination path the job will write to.'),
|
|
120
|
+
resolvedFrameCount: z
|
|
121
|
+
.number()
|
|
122
|
+
.int()
|
|
123
|
+
.describe('Frame count resolved from start/end/frameCount/fps.'),
|
|
124
|
+
resolvedFps: z.number(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const CinematicRenderJobErrorSchema = z.object({
|
|
128
|
+
message: z.string(),
|
|
129
|
+
subsystem: z.string(),
|
|
130
|
+
frame: z.number().optional(),
|
|
131
|
+
time: z.number().optional(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const CinematicRenderStatusResult = z.object({
|
|
135
|
+
jobId: z.string(),
|
|
136
|
+
status: z.enum(['running', 'completed', 'failed', 'cancelled']),
|
|
137
|
+
currentFrame: z.number(),
|
|
138
|
+
frameCount: z.number().optional(),
|
|
139
|
+
elapsedMs: z
|
|
140
|
+
.number()
|
|
141
|
+
.describe('Time since the job started (or, once settled, its total run time).'),
|
|
142
|
+
outputPath: z.string().optional(),
|
|
143
|
+
manifestPath: z.string().optional(),
|
|
144
|
+
durationSeconds: z.number().optional(),
|
|
145
|
+
warnings: z.array(z.string()),
|
|
146
|
+
error: CinematicRenderJobErrorSchema.optional(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Bound on waiting for a cancelled job to actually settle (browser/ffmpeg teardown) before `cinematic.render.cancel` gives up and reports the best-known status anyway — never an unbounded hang. */
|
|
150
|
+
const CANCEL_SETTLE_TIMEOUT_MS = 15_000;
|
|
151
|
+
|
|
152
|
+
const CinematicRenderCancelInput = z.object({
|
|
153
|
+
jobId: z.string(),
|
|
154
|
+
keepFrames: z
|
|
155
|
+
.boolean()
|
|
156
|
+
.optional()
|
|
157
|
+
.describe(
|
|
158
|
+
'Cancel-time retention override — when given, wins over the original request’s own ' +
|
|
159
|
+
'keepFrames for the purpose of this cancellation’s temp-frame cleanup decision.',
|
|
160
|
+
),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const CinematicRenderCancelResult = z.object({
|
|
164
|
+
jobId: z.string(),
|
|
165
|
+
status: z
|
|
166
|
+
.enum(['running', 'completed', 'failed', 'cancelled'])
|
|
167
|
+
.describe(
|
|
168
|
+
'The job’s status after cancellation was requested and awaited (bounded). Usually "cancelled", ' +
|
|
169
|
+
'but a job that finished/failed the instant before the abort signal was observed reports its ' +
|
|
170
|
+
'real terminal status rather than a fabricated "cancelled" — and a job whose browser/ffmpeg ' +
|
|
171
|
+
`teardown takes longer than the ${CANCEL_SETTLE_TIMEOUT_MS}ms bound still reports "running" ` +
|
|
172
|
+
'(abort WAS signalled; it just has not been confirmed yet — poll cinematic.render.status), ' +
|
|
173
|
+
'never a fabricated terminal state.',
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
function elapsedMsFor(job: { startedAt: number; finishedAt?: number }): number {
|
|
178
|
+
return (job.finishedAt ?? Date.now()) - job.startedAt;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function statusResultFor(job: {
|
|
182
|
+
id: string;
|
|
183
|
+
status: CinematicRenderJobStatus;
|
|
184
|
+
startedAt: number;
|
|
185
|
+
finishedAt?: number;
|
|
186
|
+
currentFrame: number;
|
|
187
|
+
frameCount?: number;
|
|
188
|
+
outputPath?: string;
|
|
189
|
+
manifestPath?: string;
|
|
190
|
+
durationSeconds?: number;
|
|
191
|
+
warnings: string[];
|
|
192
|
+
error?: { message: string; subsystem: string; frame?: number; time?: number };
|
|
193
|
+
}): z.infer<typeof CinematicRenderStatusResult> {
|
|
194
|
+
return {
|
|
195
|
+
jobId: job.id,
|
|
196
|
+
status: job.status,
|
|
197
|
+
currentFrame: job.currentFrame,
|
|
198
|
+
...(job.frameCount !== undefined ? { frameCount: job.frameCount } : {}),
|
|
199
|
+
elapsedMs: elapsedMsFor(job),
|
|
200
|
+
...(job.outputPath !== undefined ? { outputPath: job.outputPath } : {}),
|
|
201
|
+
...(job.manifestPath !== undefined ? { manifestPath: job.manifestPath } : {}),
|
|
202
|
+
...(job.durationSeconds !== undefined ? { durationSeconds: job.durationSeconds } : {}),
|
|
203
|
+
warnings: job.warnings,
|
|
204
|
+
...(job.error !== undefined ? { error: job.error } : {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** I5 fragment of {@link toRenderCinematicRequest} — split out purely to
|
|
209
|
+
* keep that function's own branch count down (same "require*"/"resolve*"
|
|
210
|
+
* helper-splitting convention `render-cinematic.ts` itself uses). */
|
|
211
|
+
function toRenderCinematicSimulateFields(
|
|
212
|
+
input: z.infer<typeof CinematicRenderInput>,
|
|
213
|
+
): Pick<RenderCinematicRequest, 'simulate' | 'simulateSubsteps' | 'simulateWarmupSubsteps'> {
|
|
214
|
+
return {
|
|
215
|
+
...(input.simulate !== undefined ? { simulate: input.simulate } : {}),
|
|
216
|
+
...(input.simulateSubsteps !== undefined ? { simulateSubsteps: input.simulateSubsteps } : {}),
|
|
217
|
+
...(input.simulateWarmupSubsteps !== undefined
|
|
218
|
+
? { simulateWarmupSubsteps: input.simulateWarmupSubsteps }
|
|
219
|
+
: {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Rebuild a `RenderCinematicRequest` from the Zod-validated input via
|
|
225
|
+
* conditional spreads — required under `exactOptionalPropertyTypes: true`
|
|
226
|
+
* (Zod's `.optional()` infers `field?: T | undefined`; `RenderCinematicRequest`
|
|
227
|
+
* declares plain `field?: T`, so passing the raw parsed object through
|
|
228
|
+
* directly does not structurally match).
|
|
229
|
+
*/
|
|
230
|
+
function toRenderCinematicRequest(
|
|
231
|
+
input: z.infer<typeof CinematicRenderInput>,
|
|
232
|
+
): RenderCinematicRequest {
|
|
233
|
+
return {
|
|
234
|
+
entry: input.entry,
|
|
235
|
+
out: input.out,
|
|
236
|
+
...(input.format !== undefined ? { format: input.format } : {}),
|
|
237
|
+
...(input.fps !== undefined ? { fps: input.fps } : {}),
|
|
238
|
+
...(input.start !== undefined ? { start: input.start } : {}),
|
|
239
|
+
...(input.end !== undefined ? { end: input.end } : {}),
|
|
240
|
+
...(input.frameCount !== undefined ? { frameCount: input.frameCount } : {}),
|
|
241
|
+
...(input.width !== undefined ? { width: input.width } : {}),
|
|
242
|
+
...(input.height !== undefined ? { height: input.height } : {}),
|
|
243
|
+
...(input.dpr !== undefined ? { dpr: input.dpr } : {}),
|
|
244
|
+
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
245
|
+
...(input.background !== undefined ? { background: input.background } : {}),
|
|
246
|
+
...(input.audio !== undefined ? { audio: input.audio } : {}),
|
|
247
|
+
...(input.overwrite !== undefined ? { overwrite: input.overwrite } : {}),
|
|
248
|
+
...(input.keepFrames !== undefined ? { keepFrames: input.keepFrames } : {}),
|
|
249
|
+
...(input.port !== undefined ? { port: input.port } : {}),
|
|
250
|
+
...toRenderCinematicSimulateFields(input),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// cinematic.render
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export const cinematicRender = defineOperation({
|
|
259
|
+
name: 'cinematic.render',
|
|
260
|
+
summary: 'Render a Theatre-driven cinematic sequence to a video or frame-sequence artifact.',
|
|
261
|
+
description:
|
|
262
|
+
'WRAPS packages/vgai-sdk/src/render/render-cinematic.ts’s renderCinematic (I0/I2-I8) as a background job: ' +
|
|
263
|
+
'validates + resolves the request synchronously (failing BEFORE any browser launches, I1 AC), ' +
|
|
264
|
+
'then fires the real pipeline WITHOUT awaiting it and returns a jobId immediately — poll ' +
|
|
265
|
+
'cinematic.render.status for progress/current-frame/elapsed-time/output-paths, or ' +
|
|
266
|
+
'cinematic.render.cancel to abort. Preview (cinematic.preview) and this op drive the exact same ' +
|
|
267
|
+
'window.__vgaiRender render-control harness / AnimationClock seam — the SAME canonical clock ' +
|
|
268
|
+
'implementation (§8 B5 AC).',
|
|
269
|
+
input: CinematicRenderInput,
|
|
270
|
+
result: CinematicRenderResult,
|
|
271
|
+
errors: [RENDER_REQUEST_INVALID_ERROR, RENDER_CAPABILITY_UNAVAILABLE_ERROR],
|
|
272
|
+
requires: { render: true },
|
|
273
|
+
host: 'node',
|
|
274
|
+
mutates: true,
|
|
275
|
+
supportsDryRun: false,
|
|
276
|
+
longRunning: true,
|
|
277
|
+
permission: {
|
|
278
|
+
risk: 'write',
|
|
279
|
+
summary: 'Launches a headless browser + ffmpeg; writes rendered video/frame artifacts to disk.',
|
|
280
|
+
},
|
|
281
|
+
async impl(input, ctx) {
|
|
282
|
+
const renderFn = getCinematicRenderFn(ctx);
|
|
283
|
+
let job: ReturnType<typeof startRenderJob>;
|
|
284
|
+
try {
|
|
285
|
+
job = startRenderJob(toRenderCinematicRequest(input), renderFn);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err instanceof RenderCinematicRequestError) {
|
|
288
|
+
throw new OperationError('RENDER_REQUEST_INVALID', err.message, { message: err.message });
|
|
289
|
+
}
|
|
290
|
+
if (err instanceof RenderCinematicFfmpegError) {
|
|
291
|
+
throw new OperationError('RENDER_CAPABILITY_UNAVAILABLE', err.message, {
|
|
292
|
+
message: err.message,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
jobId: job.id,
|
|
299
|
+
outPath: input.out,
|
|
300
|
+
resolvedFrameCount: job.frameCount ?? 0,
|
|
301
|
+
resolvedFps: job.fps ?? 30,
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// cinematic.render.status
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
const CinematicRenderStatusInput = z.object({ jobId: z.string() });
|
|
311
|
+
|
|
312
|
+
export const cinematicRenderStatus = defineOperation({
|
|
313
|
+
name: 'cinematic.render.status',
|
|
314
|
+
summary: 'Poll a cinematic.render job’s progress, current frame, elapsed time, and output paths.',
|
|
315
|
+
description:
|
|
316
|
+
'Reads the SAME in-memory job the dispatching cinematic.render call created — see ' +
|
|
317
|
+
'./render-transport.ts. Errors (when status is "failed") include the failing frame/time and ' +
|
|
318
|
+
'originating subsystem (§8 B5 AC).',
|
|
319
|
+
input: CinematicRenderStatusInput,
|
|
320
|
+
result: CinematicRenderStatusResult,
|
|
321
|
+
errors: [RENDER_JOB_NOT_FOUND_ERROR],
|
|
322
|
+
requires: { render: true },
|
|
323
|
+
host: 'node',
|
|
324
|
+
mutates: false,
|
|
325
|
+
supportsDryRun: false,
|
|
326
|
+
permission: { risk: 'read', summary: 'Reads this process’s in-memory render job registry only.' },
|
|
327
|
+
async impl(input) {
|
|
328
|
+
const job = getRenderJob(input.jobId);
|
|
329
|
+
if (!job) {
|
|
330
|
+
throw new OperationError('RENDER_JOB_NOT_FOUND', `No render job "${input.jobId}".`, {
|
|
331
|
+
jobId: input.jobId,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return statusResultFor(job);
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// cinematic.render.cancel
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
export const cinematicRenderCancel = defineOperation({
|
|
343
|
+
name: 'cinematic.render.cancel',
|
|
344
|
+
summary: 'Cancel a running cinematic.render job.',
|
|
345
|
+
description:
|
|
346
|
+
'Aborts the job’s render-cinematic.ts pipeline via its (B5-additive) AbortSignal seam, then ' +
|
|
347
|
+
`waits (bounded, ${CANCEL_SETTLE_TIMEOUT_MS}ms) for it to actually settle before returning the ` +
|
|
348
|
+
'real terminal status. Unless retention is requested (this call’s own keepFrames, or — absent ' +
|
|
349
|
+
'that override — the original request’s), incomplete temporary frames are removed (§8 B5 AC).',
|
|
350
|
+
input: CinematicRenderCancelInput,
|
|
351
|
+
result: CinematicRenderCancelResult,
|
|
352
|
+
errors: [RENDER_JOB_NOT_FOUND_ERROR, RENDER_JOB_NOT_RUNNING_ERROR],
|
|
353
|
+
requires: { render: true },
|
|
354
|
+
host: 'node',
|
|
355
|
+
mutates: true,
|
|
356
|
+
supportsDryRun: false,
|
|
357
|
+
permission: {
|
|
358
|
+
risk: 'destructive',
|
|
359
|
+
summary: 'Aborts a running render job and may delete its temporary frame files.',
|
|
360
|
+
},
|
|
361
|
+
async impl(input) {
|
|
362
|
+
const job = getRenderJob(input.jobId);
|
|
363
|
+
if (!job) {
|
|
364
|
+
throw new OperationError('RENDER_JOB_NOT_FOUND', `No render job "${input.jobId}".`, {
|
|
365
|
+
jobId: input.jobId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (job.status !== 'running') {
|
|
369
|
+
throw new OperationError(
|
|
370
|
+
'RENDER_JOB_NOT_RUNNING',
|
|
371
|
+
`Render job "${input.jobId}" has already settled (${job.status}) — nothing to cancel.`,
|
|
372
|
+
{ jobId: input.jobId, status: job.status },
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
// Precedence for the pipeline's temp-frame cleanup decision:
|
|
376
|
+
// cancel-time override (this call's keepFrames, if GIVEN)
|
|
377
|
+
// > request-time keepFrames (resolved.keepFrames, the render request's own)
|
|
378
|
+
// > default false.
|
|
379
|
+
// Critical: when this cancel supplies NO override, pass NO reason at all
|
|
380
|
+
// — so `signal.reason` carries no `keepFrames`, `keepFramesOverride`
|
|
381
|
+
// returns undefined, and the pipeline's `err.keepFrames ?? resolved.keepFrames`
|
|
382
|
+
// fallback genuinely consults the RESOLVED request's keepFrames. Passing
|
|
383
|
+
// `{ keepFrames: false }` here (the old `?? false`) would collapse
|
|
384
|
+
// "no override" into an EXPLICIT false, silently deleting the frames of a
|
|
385
|
+
// render that was started with request-time keepFrames:true — contradicting
|
|
386
|
+
// the AC ("unless retention is requested").
|
|
387
|
+
if (input.keepFrames !== undefined) {
|
|
388
|
+
job.abortController.abort({ keepFrames: input.keepFrames });
|
|
389
|
+
} else {
|
|
390
|
+
job.abortController.abort();
|
|
391
|
+
}
|
|
392
|
+
await Promise.race([
|
|
393
|
+
job.donePromise,
|
|
394
|
+
new Promise((resolve) => setTimeout(resolve, CANCEL_SETTLE_TIMEOUT_MS)),
|
|
395
|
+
]);
|
|
396
|
+
// `job.status` is STILL 'running' iff the timeout won the race (an
|
|
397
|
+
// extremely slow browser/ffmpeg teardown) — reported as-is (see the
|
|
398
|
+
// result schema's own doc), never fabricated into a terminal state
|
|
399
|
+
// before it is actually true.
|
|
400
|
+
return { jobId: job.id, status: job.status };
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/** Register cinematic.render / .status / .cancel onto `registry`. */
|
|
405
|
+
export function registerRenderOperations(registry: OperationRegistry): void {
|
|
406
|
+
registry.register(cinematicRender);
|
|
407
|
+
registry.register(cinematicRenderStatus);
|
|
408
|
+
registry.register(cinematicRenderCancel);
|
|
409
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job registry + transport seam for `cinematic.render` / `.status` / `.cancel`
|
|
3
|
+
* (B5, §8 B5 "render and render-status", "cancel rendering", plus the B5 AC:
|
|
4
|
+
* "Render jobs expose progress, current frame, elapsed time, and output
|
|
5
|
+
* paths" / "Cancellation removes incomplete temporary frames unless
|
|
6
|
+
* retention is requested" / "Render errors include the failing frame/time
|
|
7
|
+
* and originating subsystem").
|
|
8
|
+
*
|
|
9
|
+
* `cinematic.render` WRAPS the real, proven pipeline
|
|
10
|
+
* (`../render/render-cinematic.ts`'s `renderCinematic` — I0/I2-I8) as a
|
|
11
|
+
* background job: `startRenderJob` fires `renderCinematic(...)` WITHOUT
|
|
12
|
+
* awaiting it, records progress via `onProgress`, and returns a `jobId`
|
|
13
|
+
* synchronously — this is what makes `cinematic.render`'s own op resolve
|
|
14
|
+
* immediately (never riding the editor relay's short timeout, per §8 B1's
|
|
15
|
+
* review-hardened `host: 'node'` + `longRunning: true` requirement) while
|
|
16
|
+
* `cinematic.render.status` polls the SAME in-memory job for progress/
|
|
17
|
+
* current-frame/elapsed-time/output-paths, and `cinematic.render.cancel`
|
|
18
|
+
* aborts it via `RenderCinematicOptions.signal` (the B5-additive seam added
|
|
19
|
+
* to `render-cinematic.ts` — see that file's own jsdoc on
|
|
20
|
+
* `RenderCinematicCancelledError`).
|
|
21
|
+
*
|
|
22
|
+
* DEPENDENCY INJECTION: every op reads `ctx['cinematicRenderFn']` (falling
|
|
23
|
+
* back to the real `renderCinematic`) — the SAME "transport injected via
|
|
24
|
+
* ctx" convention `../play/transport.ts`/`../editor/transport.ts` establish,
|
|
25
|
+
* so unit tests can prove the whole job-registry state machine (progress
|
|
26
|
+
* tracking, status polling, cancellation, error classification) against a
|
|
27
|
+
* FAKE renderer that resolves/rejects/aborts on the test's own schedule —
|
|
28
|
+
* no real Playwright/ffmpeg process anywhere in that test file. The REAL
|
|
29
|
+
* proof (an actual Chromium + ffmpeg render dispatched through this exact
|
|
30
|
+
* registry) is a separate, deliberately NOT-vitest script — see this unit's
|
|
31
|
+
* commit message / build report for the invocation and its `ls -la`/
|
|
32
|
+
* `ffprobe` output.
|
|
33
|
+
*
|
|
34
|
+
* JOB REGISTRY LIFETIME: a plain in-process `Map`, matching this SDK's
|
|
35
|
+
* `node`-hosted, non-durable design elsewhere — jobs do not survive a
|
|
36
|
+
* process restart, and there is no persistence layer (a fresh CLI/HTTP/MCP
|
|
37
|
+
* process starts with an empty job map, same as e.g. `~/.vgai/editor-
|
|
38
|
+
* sessions.json` is NOT what this is — that registry is for discovering
|
|
39
|
+
* OTHER long-lived processes; a render job's lifetime is exactly this one
|
|
40
|
+
* process's).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
RenderCinematicCancelledError,
|
|
45
|
+
RenderCinematicFfmpegError,
|
|
46
|
+
type RenderCinematicOptions,
|
|
47
|
+
type RenderCinematicProgress,
|
|
48
|
+
type RenderCinematicRequest,
|
|
49
|
+
RenderCinematicRequestError,
|
|
50
|
+
type RenderCinematicResult,
|
|
51
|
+
RenderCinematicVerificationError,
|
|
52
|
+
renderCinematic,
|
|
53
|
+
resolveRenderCinematicRequest,
|
|
54
|
+
} from '../render/render-cinematic.js';
|
|
55
|
+
import type { OperationContext } from '../types.js';
|
|
56
|
+
|
|
57
|
+
export type CinematicRenderFn = (
|
|
58
|
+
request: RenderCinematicRequest,
|
|
59
|
+
options?: RenderCinematicOptions,
|
|
60
|
+
) => Promise<RenderCinematicResult>;
|
|
61
|
+
|
|
62
|
+
/** Resolve the render function to use: `ctx['cinematicRenderFn']` when injected (tests), else the real pipeline. */
|
|
63
|
+
export function getCinematicRenderFn(ctx: OperationContext): CinematicRenderFn {
|
|
64
|
+
const injected = ctx['cinematicRenderFn'];
|
|
65
|
+
return (injected as CinematicRenderFn | undefined) ?? renderCinematic;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type CinematicRenderJobStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
|
69
|
+
|
|
70
|
+
/** Maps a render-pipeline phase (`RenderCinematicProgress['phase']`) to the subsystem an error there should be attributed to (B5 AC: "originating subsystem"). */
|
|
71
|
+
function subsystemForPhase(phase: string | undefined): string {
|
|
72
|
+
switch (phase) {
|
|
73
|
+
case undefined:
|
|
74
|
+
case 'preflight':
|
|
75
|
+
return 'preflight';
|
|
76
|
+
case 'server-launch':
|
|
77
|
+
case 'server-ready':
|
|
78
|
+
return 'dev-server';
|
|
79
|
+
case 'browser-launch':
|
|
80
|
+
case 'page-ready':
|
|
81
|
+
return 'browser';
|
|
82
|
+
case 'frame':
|
|
83
|
+
return 'capture';
|
|
84
|
+
case 'audio-detect':
|
|
85
|
+
case 'audio-render':
|
|
86
|
+
case 'audio-render-done':
|
|
87
|
+
case 'audio-mux-start':
|
|
88
|
+
case 'audio-mux-done':
|
|
89
|
+
return 'audio';
|
|
90
|
+
case 'encode-start':
|
|
91
|
+
case 'encode-done':
|
|
92
|
+
return 'ffmpeg';
|
|
93
|
+
case 'verify':
|
|
94
|
+
return 'ffprobe';
|
|
95
|
+
default:
|
|
96
|
+
return 'render-pipeline';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CinematicRenderJobError {
|
|
101
|
+
readonly message: string;
|
|
102
|
+
readonly subsystem: string;
|
|
103
|
+
/** The frame index in progress (or last completed) when the failure occurred, when known. */
|
|
104
|
+
readonly frame?: number;
|
|
105
|
+
/** The corresponding time in seconds (frame / fps), when known. */
|
|
106
|
+
readonly time?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CinematicRenderJob {
|
|
110
|
+
readonly id: string;
|
|
111
|
+
status: CinematicRenderJobStatus;
|
|
112
|
+
readonly startedAt: number;
|
|
113
|
+
finishedAt?: number;
|
|
114
|
+
currentFrame: number;
|
|
115
|
+
frameCount?: number;
|
|
116
|
+
fps?: number;
|
|
117
|
+
lastPhase?: string;
|
|
118
|
+
outputPath?: string;
|
|
119
|
+
manifestPath?: string;
|
|
120
|
+
durationSeconds?: number;
|
|
121
|
+
warnings: string[];
|
|
122
|
+
error?: CinematicRenderJobError;
|
|
123
|
+
readonly abortController: AbortController;
|
|
124
|
+
/** Resolves once the job has fully settled (completed/failed/cancelled) — awaited (bounded) by `cinematic.render.cancel`. Assigned once, immediately after construction (see `startRenderJob`); not `readonly` purely because it is built from a promise chain that itself closes over this same job object. */
|
|
125
|
+
donePromise: Promise<void>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const jobs = new Map<string, CinematicRenderJob>();
|
|
129
|
+
|
|
130
|
+
let jobCounter = 0;
|
|
131
|
+
function nextJobId(): string {
|
|
132
|
+
jobCounter += 1;
|
|
133
|
+
return `render-${Date.now().toString(36)}-${jobCounter}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getRenderJob(jobId: string): CinematicRenderJob | undefined {
|
|
137
|
+
return jobs.get(jobId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Test-only: clear the module-level job registry between test files/cases so job ids never leak across tests. */
|
|
141
|
+
export function _clearRenderJobsForTests(): void {
|
|
142
|
+
jobs.clear();
|
|
143
|
+
jobCounter = 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function classifyError(err: unknown, job: CinematicRenderJob): CinematicRenderJobError {
|
|
147
|
+
const time = job.fps ? job.currentFrame / job.fps : undefined;
|
|
148
|
+
const base = { frame: job.currentFrame, ...(time !== undefined ? { time } : {}) };
|
|
149
|
+
if (err instanceof RenderCinematicRequestError) {
|
|
150
|
+
return { message: err.message, subsystem: 'request', ...base };
|
|
151
|
+
}
|
|
152
|
+
if (err instanceof RenderCinematicFfmpegError) {
|
|
153
|
+
return { message: err.message, subsystem: 'ffmpeg', ...base };
|
|
154
|
+
}
|
|
155
|
+
if (err instanceof RenderCinematicVerificationError) {
|
|
156
|
+
return { message: err.message, subsystem: 'ffprobe', ...base };
|
|
157
|
+
}
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
return { message, subsystem: subsystemForPhase(job.lastPhase), ...base };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start a render job: fires `renderFn(request, {...})` WITHOUT awaiting it,
|
|
164
|
+
* returns the new job immediately. `request` must already be validated
|
|
165
|
+
* (callers resolve/validate via `resolveRenderCinematicRequest` BEFORE
|
|
166
|
+
* calling this, per I1 AC "fail before launching the browser" — a request
|
|
167
|
+
* that fails validation never creates a job at all).
|
|
168
|
+
*/
|
|
169
|
+
export function startRenderJob(
|
|
170
|
+
request: RenderCinematicRequest,
|
|
171
|
+
renderFn: CinematicRenderFn,
|
|
172
|
+
): CinematicRenderJob {
|
|
173
|
+
const id = nextJobId();
|
|
174
|
+
const abortController = new AbortController();
|
|
175
|
+
const resolved = resolveRenderCinematicRequest(request);
|
|
176
|
+
|
|
177
|
+
const job: CinematicRenderJob = {
|
|
178
|
+
id,
|
|
179
|
+
status: 'running',
|
|
180
|
+
startedAt: Date.now(),
|
|
181
|
+
currentFrame: 0,
|
|
182
|
+
// Known up-front from the RESOLVED request (I1: fps/frameCount are
|
|
183
|
+
// computed synchronously by resolveRenderCinematicRequest before any
|
|
184
|
+
// browser launches) — not left undefined until the first 'frame'
|
|
185
|
+
// progress event arrives, which would otherwise make
|
|
186
|
+
// cinematic.render's OWN immediate result (`resolvedFrameCount`) report
|
|
187
|
+
// a false "0" for the entire time before the first frame is captured.
|
|
188
|
+
frameCount: resolved.frameCount,
|
|
189
|
+
fps: resolved.fps,
|
|
190
|
+
warnings: [],
|
|
191
|
+
abortController,
|
|
192
|
+
donePromise: Promise.resolve(), // reassigned immediately below, once the real chain exists
|
|
193
|
+
};
|
|
194
|
+
jobs.set(id, job);
|
|
195
|
+
|
|
196
|
+
const onProgress = (event: RenderCinematicProgress): void => {
|
|
197
|
+
job.lastPhase = event.phase;
|
|
198
|
+
if (event.phase === 'frame') {
|
|
199
|
+
job.currentFrame = event.n;
|
|
200
|
+
job.frameCount = event.of;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
job.donePromise = renderFn(request, { onProgress, signal: abortController.signal })
|
|
205
|
+
.then((result) => {
|
|
206
|
+
job.status = 'completed';
|
|
207
|
+
job.finishedAt = Date.now();
|
|
208
|
+
job.currentFrame = result.frameCount;
|
|
209
|
+
job.frameCount = result.frameCount;
|
|
210
|
+
job.outputPath = result.outputPath;
|
|
211
|
+
job.manifestPath = result.manifestPath;
|
|
212
|
+
job.durationSeconds = result.durationSeconds;
|
|
213
|
+
job.warnings = [...result.warnings];
|
|
214
|
+
})
|
|
215
|
+
.catch((err: unknown) => {
|
|
216
|
+
job.finishedAt = Date.now();
|
|
217
|
+
// Detect cancellation via `abortController.signal.aborted` — NOT
|
|
218
|
+
// merely `err instanceof RenderCinematicCancelledError` — for the
|
|
219
|
+
// same reason `render-cinematic.ts`'s own `cleanupAfterFailure` and
|
|
220
|
+
// the CLI's SIGINT handling do (see `cleanupAfterFailure`'s jsdoc in
|
|
221
|
+
// `render-cinematic.ts`): an abort landing mid-flight can race ahead of
|
|
222
|
+
// `renderCinematic`'s cooperative `signal?.aborted` checks, so the
|
|
223
|
+
// in-flight `page.evaluate`/screenshot call itself rejects with a raw
|
|
224
|
+
// Playwright error ("Target page, context or browser has been
|
|
225
|
+
// closed") instead of `RenderCinematicCancelledError` — even though
|
|
226
|
+
// the render was genuinely cancelled via THIS job's own
|
|
227
|
+
// `abortController`. Gating on the error type alone misreported that
|
|
228
|
+
// race as `status: 'failed'`.
|
|
229
|
+
if (err instanceof RenderCinematicCancelledError || abortController.signal.aborted) {
|
|
230
|
+
job.status = 'cancelled';
|
|
231
|
+
} else {
|
|
232
|
+
job.status = 'failed';
|
|
233
|
+
job.error = classifyError(err, job);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return job;
|
|
238
|
+
}
|