@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,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cinematic.preview` / `cinematic.pause` / `cinematic.seek` / `cinematic.stop`
|
|
3
|
+
* (B5, §8 B5 "preview, pause, seek, and stop"). See
|
|
4
|
+
* `./preview-transport.ts`'s module jsdoc for the "real headless clock-seek
|
|
5
|
+
* preview, not a named gap" decision and why it proves "preview and render
|
|
6
|
+
* use the same canonical clock implementation" (§8 B5 AC) by construction.
|
|
7
|
+
*
|
|
8
|
+
* Flat names (`cinematic.pause`/`.seek`/`.stop`, not `cinematic.preview.
|
|
9
|
+
* pause`), matching the spec's own §5.7 example listing (`vgai.cinematic.
|
|
10
|
+
* preview(...)`) and this SDK's `play.*` precedent (`play.pause`/`play.
|
|
11
|
+
* resume`, not `play.control.pause`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { OperationError } from '../errors.js';
|
|
18
|
+
import { defineOperation, type ErrorDefinition, type OperationRegistry } from '../registry.js';
|
|
19
|
+
import {
|
|
20
|
+
createPreviewSession,
|
|
21
|
+
getCinematicOpenPreviewFn,
|
|
22
|
+
getPreviewSession,
|
|
23
|
+
pausePreviewSession,
|
|
24
|
+
seekPreviewSession,
|
|
25
|
+
stopPreviewSession,
|
|
26
|
+
} from './preview-transport.js';
|
|
27
|
+
|
|
28
|
+
const PREVIEW_SESSION_NOT_FOUND_ERROR: ErrorDefinition = {
|
|
29
|
+
code: 'PREVIEW_SESSION_NOT_FOUND',
|
|
30
|
+
summary:
|
|
31
|
+
'No open preview session with the given previewId exists (this process’s in-memory registry).',
|
|
32
|
+
data: z.object({ previewId: z.string() }),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PreviewStateResult = z.object({
|
|
36
|
+
previewId: z.string(),
|
|
37
|
+
currentFrame: z.number(),
|
|
38
|
+
fps: z.number(),
|
|
39
|
+
frameCount: z
|
|
40
|
+
.number()
|
|
41
|
+
.describe('Total frames in the resolved preview range; last valid frame is frameCount - 1.'),
|
|
42
|
+
playing: z.boolean(),
|
|
43
|
+
ended: z
|
|
44
|
+
.boolean()
|
|
45
|
+
.describe(
|
|
46
|
+
'True once autoplay has advanced to (and stopped at) the last frame — distinguishes "played to the end" from an explicit pause.',
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function stateOf(session: {
|
|
51
|
+
id: string;
|
|
52
|
+
currentFrame: number;
|
|
53
|
+
playing: boolean;
|
|
54
|
+
ended: boolean;
|
|
55
|
+
handle: { fps: number; frameCount: number };
|
|
56
|
+
}): z.infer<typeof PreviewStateResult> {
|
|
57
|
+
return {
|
|
58
|
+
previewId: session.id,
|
|
59
|
+
currentFrame: session.currentFrame,
|
|
60
|
+
fps: session.handle.fps,
|
|
61
|
+
frameCount: session.handle.frameCount,
|
|
62
|
+
playing: session.playing,
|
|
63
|
+
ended: session.ended,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireSession(previewId: string) {
|
|
68
|
+
const session = getPreviewSession(previewId);
|
|
69
|
+
if (!session) {
|
|
70
|
+
throw new OperationError(
|
|
71
|
+
'PREVIEW_SESSION_NOT_FOUND',
|
|
72
|
+
`No open preview session "${previewId}".`,
|
|
73
|
+
{ previewId },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return session;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// cinematic.preview
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const CinematicPreviewInput = z.object({
|
|
84
|
+
entry: z
|
|
85
|
+
.string()
|
|
86
|
+
.describe(
|
|
87
|
+
'Either an http(s) URL to an already-serving render-mode page, or a filesystem path to a Vite config to spawn.',
|
|
88
|
+
),
|
|
89
|
+
start: z.number().min(0).optional().describe('Initial time, seconds. Default 0.'),
|
|
90
|
+
end: z
|
|
91
|
+
.number()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe(
|
|
94
|
+
'Range end, seconds — bounds how far autoplay advances. Supply at most one of end/frameCount; default is a 1s range (render-cinematic.ts’s own default).',
|
|
95
|
+
),
|
|
96
|
+
frameCount: z
|
|
97
|
+
.number()
|
|
98
|
+
.int()
|
|
99
|
+
.positive()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe('Explicit frame count for the preview range (alternative to end).'),
|
|
102
|
+
fps: z.number().positive().optional(),
|
|
103
|
+
width: z.number().int().positive().optional(),
|
|
104
|
+
height: z.number().int().positive().optional(),
|
|
105
|
+
dpr: z.number().positive().optional(),
|
|
106
|
+
seed: z.number().int().optional(),
|
|
107
|
+
background: z.enum(['opaque', 'transparent']).optional(),
|
|
108
|
+
port: z.number().int().positive().optional(),
|
|
109
|
+
autoplay: z
|
|
110
|
+
.boolean()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('Start the forward-advancing loop immediately. Default true.'),
|
|
113
|
+
playbackRate: z.number().positive().optional().describe('Autoplay speed multiplier. Default 1.'),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const cinematicPreview = defineOperation({
|
|
117
|
+
name: 'cinematic.preview',
|
|
118
|
+
summary:
|
|
119
|
+
'Open a headless preview session against a render-control-mode page and (by default) start playing it.',
|
|
120
|
+
description:
|
|
121
|
+
'Launches a real headless Chromium render-control page (the SAME `window.__vgaiRender` harness ' +
|
|
122
|
+
'cinematic.render drives — see ./preview-transport.ts module jsdoc) and, unless autoplay:false, ' +
|
|
123
|
+
'starts a forward-advancing loop calling seekFrame/renderOnce once per output frame. Returns a ' +
|
|
124
|
+
'previewId — control it with cinematic.pause/.seek/.stop.',
|
|
125
|
+
input: CinematicPreviewInput,
|
|
126
|
+
result: PreviewStateResult,
|
|
127
|
+
errors: [],
|
|
128
|
+
requires: { render: true },
|
|
129
|
+
host: 'runtime-page',
|
|
130
|
+
mutates: true,
|
|
131
|
+
supportsDryRun: false,
|
|
132
|
+
permission: {
|
|
133
|
+
risk: 'write',
|
|
134
|
+
summary: 'Launches a headless browser page for interactive preview.',
|
|
135
|
+
},
|
|
136
|
+
async impl(input, ctx) {
|
|
137
|
+
const openFn = getCinematicOpenPreviewFn(ctx);
|
|
138
|
+
const handle = await openFn({
|
|
139
|
+
entry: input.entry,
|
|
140
|
+
// Unused by openRenderCinematicPage (which never encodes/writes an
|
|
141
|
+
// artifact) — a placeholder to satisfy RenderCinematicRequest's
|
|
142
|
+
// required `out` field.
|
|
143
|
+
out: join(tmpdir(), 'vgai-preview-unused.mp4'),
|
|
144
|
+
// NOTE: `input.start` is deliberately NOT forwarded to the render
|
|
145
|
+
// request. The harness frame index is absolute (frame n == time
|
|
146
|
+
// n/fps from 0), and resolveRenderCinematicRequest computes
|
|
147
|
+
// frameCount as (end - start) * fps — forwarding `start` would SHRINK
|
|
148
|
+
// the resolved frameCount and misalign it from the absolute index the
|
|
149
|
+
// preview loop seeks. `start` is applied purely as the local initial
|
|
150
|
+
// frame index below (round(start * fps)), within the 0..frameCount-1
|
|
151
|
+
// range that `end`/`frameCount` (absolute, from time 0) define.
|
|
152
|
+
...(input.end !== undefined ? { end: input.end } : {}),
|
|
153
|
+
...(input.frameCount !== undefined ? { frameCount: input.frameCount } : {}),
|
|
154
|
+
...(input.fps !== undefined ? { fps: input.fps } : {}),
|
|
155
|
+
...(input.width !== undefined ? { width: input.width } : {}),
|
|
156
|
+
...(input.height !== undefined ? { height: input.height } : {}),
|
|
157
|
+
...(input.dpr !== undefined ? { dpr: input.dpr } : {}),
|
|
158
|
+
...(input.seed !== undefined ? { seed: input.seed } : {}),
|
|
159
|
+
...(input.background !== undefined ? { background: input.background } : {}),
|
|
160
|
+
...(input.port !== undefined ? { port: input.port } : {}),
|
|
161
|
+
});
|
|
162
|
+
const startFrame = input.start !== undefined ? Math.round(input.start * handle.fps) : 0;
|
|
163
|
+
// Establish a defined initial pose BEFORE autoplay's own loop (if any)
|
|
164
|
+
// fires its first tick — mirrors the render fixtures' own `clock.seek(0)`
|
|
165
|
+
// "defined initial pose before any external seek arrives" convention.
|
|
166
|
+
await handle.seekFrame(startFrame);
|
|
167
|
+
const session = createPreviewSession(
|
|
168
|
+
handle,
|
|
169
|
+
startFrame,
|
|
170
|
+
input.autoplay ?? true,
|
|
171
|
+
input.playbackRate ?? 1,
|
|
172
|
+
);
|
|
173
|
+
return stateOf(session);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// cinematic.pause
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
const PreviewIdInput = z.object({ previewId: z.string() });
|
|
182
|
+
|
|
183
|
+
export const cinematicPause = defineOperation({
|
|
184
|
+
name: 'cinematic.pause',
|
|
185
|
+
summary: 'Stop a preview session’s autoplay loop without closing it.',
|
|
186
|
+
description:
|
|
187
|
+
'The session stays open at its current frame — seek/resume-by-autoplay-restart remain available.',
|
|
188
|
+
input: PreviewIdInput,
|
|
189
|
+
result: PreviewStateResult,
|
|
190
|
+
errors: [PREVIEW_SESSION_NOT_FOUND_ERROR],
|
|
191
|
+
requires: { render: true },
|
|
192
|
+
host: 'runtime-page',
|
|
193
|
+
mutates: true,
|
|
194
|
+
supportsDryRun: false,
|
|
195
|
+
permission: { risk: 'write', summary: 'Pauses a headless preview session only.' },
|
|
196
|
+
async impl(input) {
|
|
197
|
+
const session = requireSession(input.previewId);
|
|
198
|
+
pausePreviewSession(session);
|
|
199
|
+
return stateOf(session);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// cinematic.seek
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const CinematicSeekInput = z.object({
|
|
208
|
+
previewId: z.string(),
|
|
209
|
+
time: z
|
|
210
|
+
.number()
|
|
211
|
+
.min(0)
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('Seek to this time, seconds. Supply exactly one of time/frame.'),
|
|
214
|
+
frame: z.number().int().min(0).optional().describe('Seek to this exact frame index.'),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
export const cinematicSeek = defineOperation({
|
|
218
|
+
name: 'cinematic.seek',
|
|
219
|
+
summary: 'Seek a preview session to an exact time or frame.',
|
|
220
|
+
description:
|
|
221
|
+
'Works regardless of play state — a playing session continues autoplaying forward from the ' +
|
|
222
|
+
'new position on its next tick.',
|
|
223
|
+
input: CinematicSeekInput,
|
|
224
|
+
result: PreviewStateResult,
|
|
225
|
+
errors: [
|
|
226
|
+
PREVIEW_SESSION_NOT_FOUND_ERROR,
|
|
227
|
+
{
|
|
228
|
+
code: 'BAD_INPUT',
|
|
229
|
+
summary: 'Neither time nor frame was given, or both were.',
|
|
230
|
+
data: z.object({}),
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
requires: { render: true },
|
|
234
|
+
host: 'runtime-page',
|
|
235
|
+
mutates: true,
|
|
236
|
+
supportsDryRun: false,
|
|
237
|
+
permission: { risk: 'write', summary: 'Seeks a headless preview session only.' },
|
|
238
|
+
async impl(input) {
|
|
239
|
+
const session = requireSession(input.previewId);
|
|
240
|
+
if ((input.time === undefined) === (input.frame === undefined)) {
|
|
241
|
+
throw new OperationError('BAD_INPUT', 'Supply exactly one of time or frame.', {});
|
|
242
|
+
}
|
|
243
|
+
const frame = input.frame ?? Math.round(input.time! * session.handle.fps);
|
|
244
|
+
await seekPreviewSession(session, frame);
|
|
245
|
+
return stateOf(session);
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// cinematic.stop
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
export const cinematicStop = defineOperation({
|
|
254
|
+
name: 'cinematic.stop',
|
|
255
|
+
summary: 'Close a preview session (its page/browser) and forget it.',
|
|
256
|
+
description:
|
|
257
|
+
'Stops any running autoplay loop, closes the underlying headless browser page, and removes the session from the registry.',
|
|
258
|
+
input: PreviewIdInput,
|
|
259
|
+
result: z.object({ previewId: z.string() }),
|
|
260
|
+
errors: [PREVIEW_SESSION_NOT_FOUND_ERROR],
|
|
261
|
+
requires: { render: true },
|
|
262
|
+
host: 'runtime-page',
|
|
263
|
+
mutates: true,
|
|
264
|
+
supportsDryRun: false,
|
|
265
|
+
permission: { risk: 'write', summary: 'Closes a headless preview session’s browser page.' },
|
|
266
|
+
async impl(input) {
|
|
267
|
+
const session = requireSession(input.previewId);
|
|
268
|
+
await stopPreviewSession(session);
|
|
269
|
+
return { previewId: input.previewId };
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/** Register cinematic.preview / .pause / .seek / .stop onto `registry`. */
|
|
274
|
+
export function registerPreviewOperations(registry: OperationRegistry): void {
|
|
275
|
+
registry.register(cinematicPreview);
|
|
276
|
+
registry.register(cinematicPause);
|
|
277
|
+
registry.register(cinematicSeek);
|
|
278
|
+
registry.register(cinematicStop);
|
|
279
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry + transport seam for `cinematic.preview` / `.pause` /
|
|
3
|
+
* `.seek` / `.stop` (B5, §8 B5 "preview, pause, seek, and stop").
|
|
4
|
+
*
|
|
5
|
+
* DECISION (per this unit's brief — "may target a runtime-page harness; if
|
|
6
|
+
* no live preview harness, a named error ... OR a headless clock-seek
|
|
7
|
+
* preview — decide + document"): a REAL headless clock-seek preview, not a
|
|
8
|
+
* named gap. There is no persistent, addressable "preview session" concept
|
|
9
|
+
* anywhere in this repo today (unlike `play.*`, which targets an EXISTING
|
|
10
|
+
* connected editor's browser tab over the `/__editor/command` relay) — the
|
|
11
|
+
* only thing that can drive `window.__vgaiRender` is a render-control-mode
|
|
12
|
+
* page (`?vgai-render=1`), and the only launcher for THAT page is
|
|
13
|
+
* `../render/render-cinematic.ts`'s own Playwright/Chromium launch,
|
|
14
|
+
* which is per-request-scoped, not a long-lived interactive session. Rather
|
|
15
|
+
* than declaring "no preview capability exists" (an honest but much less
|
|
16
|
+
* useful answer, and not actually true — the harness itself is real and
|
|
17
|
+
* launchable), this module launches its OWN headless page per preview
|
|
18
|
+
* session (via `openRenderCinematicPage`, the SAME additive I8 helper
|
|
19
|
+
* `renderCinematic` itself is built from) and drives it through
|
|
20
|
+
* `window.__vgaiRender.seekFrame`/`.renderOnce()` — THE EXACT SAME two
|
|
21
|
+
* calls `render-cinematic.ts`'s `captureFrames` makes for every captured
|
|
22
|
+
* frame. This is what makes "preview and render use the same canonical
|
|
23
|
+
* clock implementation" (§8 B5 AC) a literal, provable fact rather than an
|
|
24
|
+
* assertion: both paths bottom out in the identical harness call sequence.
|
|
25
|
+
*
|
|
26
|
+
* VCR SEMANTICS: `cinematic.preview` opens a session and (unless
|
|
27
|
+
* `autoplay: false`) starts a forward-advancing loop — one frame per tick,
|
|
28
|
+
* paced at the resolved fps (bounded below to avoid a runaway timer) —
|
|
29
|
+
* `cinematic.pause` stops that loop without closing the session,
|
|
30
|
+
* `cinematic.seek` jumps to an exact frame/time regardless of play state
|
|
31
|
+
* (continuing to play from the new position if it was playing),
|
|
32
|
+
* `cinematic.stop` closes the underlying page/browser and removes the
|
|
33
|
+
* session.
|
|
34
|
+
*
|
|
35
|
+
* DEPENDENCY INJECTION: `ctx['cinematicOpenPreviewFn']` overrides the real
|
|
36
|
+
* launcher (the same convention `./render-transport.ts`/`../play/
|
|
37
|
+
* transport.ts` use) with a narrow `CinematicPreviewHandle` — `seekFrame`/
|
|
38
|
+
* `fps`/`close()` only, never Playwright's full `Page` — so a unit test can
|
|
39
|
+
* prove the whole session state machine (autoplay pacing, pause/seek/stop,
|
|
40
|
+
* not-found handling) without a real browser anywhere in that test file.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { RenderCinematicRequest } from '../render/render-cinematic.js';
|
|
44
|
+
import { openRenderCinematicPage } from '../render/render-cinematic.js';
|
|
45
|
+
import type { OperationContext } from '../types.js';
|
|
46
|
+
|
|
47
|
+
/** The narrow surface preview sessions need — deliberately NOT Playwright's `Page` (see module jsdoc). */
|
|
48
|
+
export interface CinematicPreviewHandle {
|
|
49
|
+
readonly fps: number;
|
|
50
|
+
/**
|
|
51
|
+
* Total frames in the resolved preview range (`resolveRenderCinematicRequest`'s
|
|
52
|
+
* `frameCount`) — the autoplay loop clamps at `frameCount - 1` (the last
|
|
53
|
+
* frame) so an abandoned autoplaying session can never tick a live headless
|
|
54
|
+
* Chromium forever. Valid frame indices are `0 .. frameCount - 1`.
|
|
55
|
+
*/
|
|
56
|
+
readonly frameCount: number;
|
|
57
|
+
/** Seeks `window.__vgaiRender` to frame `n` (via `seekFrame(n, fps)`) and calls `renderOnce()` — the identical two-call sequence `render-cinematic.ts`'s `captureFrames` makes per frame. */
|
|
58
|
+
seekFrame(n: number): Promise<void>;
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type CinematicOpenPreviewFn = (
|
|
63
|
+
request: RenderCinematicRequest,
|
|
64
|
+
) => Promise<CinematicPreviewHandle>;
|
|
65
|
+
|
|
66
|
+
interface HarnessGlobal {
|
|
67
|
+
__vgaiRender: {
|
|
68
|
+
seekFrame(n: number, fps: number): void;
|
|
69
|
+
renderOnce(): Promise<void>;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The real launcher — opens an actual headless Chromium render-control page. */
|
|
74
|
+
async function openRealPreviewHandle(
|
|
75
|
+
request: RenderCinematicRequest,
|
|
76
|
+
): Promise<CinematicPreviewHandle> {
|
|
77
|
+
const session = await openRenderCinematicPage(request);
|
|
78
|
+
const fps = session.resolved.fps;
|
|
79
|
+
return {
|
|
80
|
+
fps,
|
|
81
|
+
frameCount: session.resolved.frameCount,
|
|
82
|
+
async seekFrame(n: number) {
|
|
83
|
+
await session.page.evaluate(
|
|
84
|
+
async ({ n, fps }: { n: number; fps: number }) => {
|
|
85
|
+
const harness = (globalThis as unknown as HarnessGlobal).__vgaiRender;
|
|
86
|
+
harness.seekFrame(n, fps);
|
|
87
|
+
await harness.renderOnce();
|
|
88
|
+
},
|
|
89
|
+
{ n, fps },
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
async close() {
|
|
93
|
+
await session.close();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve the preview-page opener to use: `ctx['cinematicOpenPreviewFn']` when injected (tests), else the real launcher. */
|
|
99
|
+
export function getCinematicOpenPreviewFn(ctx: OperationContext): CinematicOpenPreviewFn {
|
|
100
|
+
const injected = ctx['cinematicOpenPreviewFn'];
|
|
101
|
+
return (injected as CinematicOpenPreviewFn | undefined) ?? openRealPreviewHandle;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Session registry
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** A tick is never scheduled faster than this, regardless of a very high fps/playbackRate — a sanity floor against a runaway timer. */
|
|
109
|
+
const MIN_TICK_MS = 10;
|
|
110
|
+
|
|
111
|
+
export interface CinematicPreviewSession {
|
|
112
|
+
readonly id: string;
|
|
113
|
+
readonly handle: CinematicPreviewHandle;
|
|
114
|
+
currentFrame: number;
|
|
115
|
+
playing: boolean;
|
|
116
|
+
/** True once autoplay has reached the sequence's last frame and stopped on its own (as opposed to an explicit `cinematic.pause`). Surfaced so a caller can tell "played to the end" from "paused mid-way". */
|
|
117
|
+
ended: boolean;
|
|
118
|
+
playbackRate: number;
|
|
119
|
+
closed: boolean;
|
|
120
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The last valid frame index of a session's range (`frameCount - 1`, never negative). */
|
|
124
|
+
function lastFrameOf(session: CinematicPreviewSession): number {
|
|
125
|
+
return Math.max(0, session.handle.frameCount - 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sessions = new Map<string, CinematicPreviewSession>();
|
|
129
|
+
|
|
130
|
+
let sessionCounter = 0;
|
|
131
|
+
function nextSessionId(): string {
|
|
132
|
+
sessionCounter += 1;
|
|
133
|
+
return `preview-${Date.now().toString(36)}-${sessionCounter}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getPreviewSession(id: string): CinematicPreviewSession | undefined {
|
|
137
|
+
const session = sessions.get(id);
|
|
138
|
+
return session && !session.closed ? session : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Test-only: clear the module-level session registry between test files/cases. */
|
|
142
|
+
export function _clearPreviewSessionsForTests(): void {
|
|
143
|
+
for (const session of sessions.values()) {
|
|
144
|
+
if (session.timer !== undefined) clearTimeout(session.timer);
|
|
145
|
+
}
|
|
146
|
+
sessions.clear();
|
|
147
|
+
sessionCounter = 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function scheduleNextTick(session: CinematicPreviewSession): void {
|
|
151
|
+
if (!session.playing || session.closed) return;
|
|
152
|
+
const intervalMs = Math.max(MIN_TICK_MS, 1000 / (session.handle.fps * session.playbackRate));
|
|
153
|
+
session.timer = setTimeout(() => {
|
|
154
|
+
void tick(session);
|
|
155
|
+
}, intervalMs);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function tick(session: CinematicPreviewSession): Promise<void> {
|
|
159
|
+
if (!session.playing || session.closed) return;
|
|
160
|
+
// BOUND (FOLD 1): never advance past the sequence's last frame. Without
|
|
161
|
+
// this an abandoned autoplaying preview would increment currentFrame
|
|
162
|
+
// unboundedly and keep seeking a live headless Chromium forever until an
|
|
163
|
+
// explicit cinematic.stop. At the end we stop ticking and mark `ended`.
|
|
164
|
+
if (session.currentFrame >= lastFrameOf(session)) {
|
|
165
|
+
session.playing = false;
|
|
166
|
+
session.ended = true;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
session.currentFrame += 1;
|
|
170
|
+
try {
|
|
171
|
+
await session.handle.seekFrame(session.currentFrame);
|
|
172
|
+
} catch {
|
|
173
|
+
// A tick failure stops autoplay rather than throwing into an
|
|
174
|
+
// unobserved timer callback — the session stays open (its LAST good
|
|
175
|
+
// frame stands) so a caller can still inspect/seek/stop it explicitly.
|
|
176
|
+
session.playing = false;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (session.currentFrame >= lastFrameOf(session)) {
|
|
180
|
+
// Reached the end ON this frame — stop now rather than scheduling one
|
|
181
|
+
// more no-op tick.
|
|
182
|
+
session.playing = false;
|
|
183
|
+
session.ended = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
scheduleNextTick(session);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createPreviewSession(
|
|
190
|
+
handle: CinematicPreviewHandle,
|
|
191
|
+
startFrame: number,
|
|
192
|
+
autoplay: boolean,
|
|
193
|
+
playbackRate: number,
|
|
194
|
+
): CinematicPreviewSession {
|
|
195
|
+
const session: CinematicPreviewSession = {
|
|
196
|
+
id: nextSessionId(),
|
|
197
|
+
handle,
|
|
198
|
+
currentFrame: startFrame,
|
|
199
|
+
playing: false,
|
|
200
|
+
ended: false,
|
|
201
|
+
playbackRate,
|
|
202
|
+
closed: false,
|
|
203
|
+
timer: undefined,
|
|
204
|
+
};
|
|
205
|
+
sessions.set(session.id, session);
|
|
206
|
+
// Only arm autoplay if there is somewhere still to advance to — a preview
|
|
207
|
+
// opened AT (or past) the last frame is already `ended`, never a
|
|
208
|
+
// perpetually-"playing" session that never ticks.
|
|
209
|
+
if (autoplay && startFrame < lastFrameOf(session)) {
|
|
210
|
+
session.playing = true;
|
|
211
|
+
scheduleNextTick(session);
|
|
212
|
+
} else if (autoplay) {
|
|
213
|
+
session.ended = true;
|
|
214
|
+
}
|
|
215
|
+
return session;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function pausePreviewSession(session: CinematicPreviewSession): void {
|
|
219
|
+
session.playing = false;
|
|
220
|
+
if (session.timer !== undefined) {
|
|
221
|
+
clearTimeout(session.timer);
|
|
222
|
+
session.timer = undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function seekPreviewSession(
|
|
227
|
+
session: CinematicPreviewSession,
|
|
228
|
+
frame: number,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
await session.handle.seekFrame(frame);
|
|
231
|
+
session.currentFrame = frame;
|
|
232
|
+
// A seek that lands before the end re-arms `ended` (the session is no
|
|
233
|
+
// longer stopped-at-the-end); a seek that lands AT the last frame leaves
|
|
234
|
+
// `ended` reflecting that. This keeps `ended` an honest "are we parked at
|
|
235
|
+
// the final frame" signal regardless of how we got there.
|
|
236
|
+
session.ended = frame >= lastFrameOf(session);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function stopPreviewSession(session: CinematicPreviewSession): Promise<void> {
|
|
240
|
+
pausePreviewSession(session);
|
|
241
|
+
session.closed = true;
|
|
242
|
+
sessions.delete(session.id);
|
|
243
|
+
await session.handle.close();
|
|
244
|
+
}
|