@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.
Files changed (51) hide show
  1. package/package.json +27 -0
  2. package/src/cinematic/capabilities-operations.ts +128 -0
  3. package/src/cinematic/cue-operations.ts +198 -0
  4. package/src/cinematic/gsap-operations.ts +126 -0
  5. package/src/cinematic/index.ts +59 -0
  6. package/src/cinematic/preview-operations.ts +279 -0
  7. package/src/cinematic/preview-transport.ts +244 -0
  8. package/src/cinematic/render-operations.ts +409 -0
  9. package/src/cinematic/render-transport.ts +238 -0
  10. package/src/cinematic/theatre-operations.ts +306 -0
  11. package/src/editor/camera-operations.ts +169 -0
  12. package/src/editor/console-operations.ts +87 -0
  13. package/src/editor/hierarchy-operations.ts +95 -0
  14. package/src/editor/index.ts +62 -0
  15. package/src/editor/open-operations.ts +209 -0
  16. package/src/editor/screenshot-operations.ts +99 -0
  17. package/src/editor/selection-operations.ts +144 -0
  18. package/src/editor/session-operations.ts +73 -0
  19. package/src/editor/source-location-operations.ts +106 -0
  20. package/src/editor/transport.ts +647 -0
  21. package/src/errors.ts +72 -0
  22. package/src/http/http-projection.ts +349 -0
  23. package/src/http/index.ts +11 -0
  24. package/src/index.ts +67 -0
  25. package/src/mcp/index.ts +16 -0
  26. package/src/mcp/mcp-projection.ts +288 -0
  27. package/src/operations.ts +83 -0
  28. package/src/play/control-operations.ts +205 -0
  29. package/src/play/debug-command-operations.ts +245 -0
  30. package/src/play/index.ts +66 -0
  31. package/src/play/input-operations.ts +316 -0
  32. package/src/play/lifecycle-operations.ts +271 -0
  33. package/src/play/log-operations.ts +279 -0
  34. package/src/play/run-ticks-operations.ts +141 -0
  35. package/src/play/state-operations.ts +210 -0
  36. package/src/play/status-operations.ts +160 -0
  37. package/src/play/transport.ts +728 -0
  38. package/src/project/asset-operations.ts +243 -0
  39. package/src/project/component-operations.ts +337 -0
  40. package/src/project/discovery-operations.ts +269 -0
  41. package/src/project/entity-operations.ts +366 -0
  42. package/src/project/index.ts +55 -0
  43. package/src/project/input-map-operations.ts +233 -0
  44. package/src/project/manifest-operations.ts +355 -0
  45. package/src/project/scene-operations.ts +426 -0
  46. package/src/project/shared.ts +299 -0
  47. package/src/registry.ts +285 -0
  48. package/src/render/capabilities/ffmpeg.ts +141 -0
  49. package/src/render/index.ts +15 -0
  50. package/src/render/render-cinematic.ts +1847 -0
  51. 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
+ }