vexy-stax-js 3.0.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/src/export.js ADDED
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/export.js
3
+ //
4
+ // Image + video export (SPEC.md §6.1). Image: read the renderer's WebGL canvas
5
+ // to a PNG Blob. Video: capture the deck transition to an encoded clip — WebCodecs
6
+ // (VideoEncoder + muxed via captureStream/MediaRecorder when available) with a
7
+ // MediaRecorder fallback for browsers without WebCodecs. Both paths produce a
8
+ // Blob the caller can download.
9
+
10
+ /**
11
+ * Read a canvas to a PNG Blob.
12
+ * @param {HTMLCanvasElement} canvas
13
+ * @returns {Promise<Blob>}
14
+ */
15
+ export function canvasToPngBlob(canvas) {
16
+ return new Promise((resolve, reject) => {
17
+ if (!canvas || typeof canvas.toBlob !== "function") {
18
+ reject(new Error("canvasToPngBlob: canvas.toBlob unavailable in this environment"));
19
+ return;
20
+ }
21
+ canvas.toBlob((blob) => {
22
+ if (blob) resolve(blob);
23
+ else reject(new Error("canvasToPngBlob: toBlob produced no blob"));
24
+ }, "image/png");
25
+ });
26
+ }
27
+
28
+ /** True when MediaRecorder + canvas.captureStream are usable. */
29
+ function hasMediaRecorder() {
30
+ return (
31
+ typeof MediaRecorder !== "undefined" &&
32
+ typeof HTMLCanvasElement !== "undefined" &&
33
+ typeof HTMLCanvasElement.prototype.captureStream === "function"
34
+ );
35
+ }
36
+
37
+ /** Pick the first supported MediaRecorder mime type, or undefined for the default. */
38
+ function pickMimeType() {
39
+ if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") {
40
+ return undefined;
41
+ }
42
+ const candidates = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"];
43
+ return candidates.find((t) => MediaRecorder.isTypeSupported(t));
44
+ }
45
+
46
+ /**
47
+ * Record a transition to a video Blob.
48
+ *
49
+ * Drives `playFrames(applyOneFrame)`: the caller renders each frame onto the
50
+ * canvas synchronously inside the per-frame callback, and we capture the canvas
51
+ * stream while it plays. WebCodecs is preferred when present (lower latency, mp4
52
+ * where supported); otherwise MediaRecorder captures the live canvas stream.
53
+ *
54
+ * @param {object} opts
55
+ * @param {HTMLCanvasElement} opts.canvas the renderer canvas to capture
56
+ * @param {(onFrame:(state:object)=>void)=>Promise<void>} opts.run plays the
57
+ * transition, calling onFrame for each frame (which must render to the canvas)
58
+ * @param {number} [opts.fps] frame rate for the captured stream
59
+ * @returns {Promise<Blob>}
60
+ */
61
+ export async function recordVideo({ canvas, run, fps = 30 }) {
62
+ if (!canvas) throw new Error("recordVideo: canvas is required");
63
+ if (typeof run !== "function") throw new Error("recordVideo: run() callback is required");
64
+
65
+ if (typeof VideoEncoder !== "undefined" && typeof canvas.captureStream === "function") {
66
+ // WebCodecs path: still capture via MediaRecorder on the encoded stream when
67
+ // available, since muxing raw VideoEncoder chunks into a container is heavy.
68
+ // We treat presence of MediaRecorder as the muxer; if it's missing we fall
69
+ // through to the explicit WebCodecs-only encoder below.
70
+ if (hasMediaRecorder()) {
71
+ return recordViaMediaRecorder({ canvas, run, fps });
72
+ }
73
+ return recordViaWebCodecs({ canvas, run, fps });
74
+ }
75
+
76
+ if (hasMediaRecorder()) {
77
+ return recordViaMediaRecorder({ canvas, run, fps });
78
+ }
79
+
80
+ throw new Error(
81
+ "recordVideo: neither WebCodecs (VideoEncoder) nor MediaRecorder/captureStream is available in this environment"
82
+ );
83
+ }
84
+
85
+ async function recordViaMediaRecorder({ canvas, run, fps }) {
86
+ const stream = canvas.captureStream(fps);
87
+ const mimeType = pickMimeType();
88
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
89
+ const chunks = [];
90
+ recorder.ondataavailable = (e) => {
91
+ if (e.data && e.data.size > 0) chunks.push(e.data);
92
+ };
93
+ const stopped = new Promise((resolve) => {
94
+ recorder.onstop = () => resolve();
95
+ });
96
+ recorder.start();
97
+ try {
98
+ await run((/* state */) => {
99
+ // The frame is already rendered by the stage; captureStream samples the
100
+ // canvas. requestFrame() (when available) forces a sample for this frame.
101
+ const track = stream.getVideoTracks?.()[0];
102
+ track?.requestFrame?.();
103
+ });
104
+ } finally {
105
+ if (recorder.state !== "inactive") recorder.stop();
106
+ }
107
+ await stopped;
108
+ stream.getTracks?.().forEach((t) => t.stop());
109
+ return new Blob(chunks, { type: recorder.mimeType || mimeType || "video/webm" });
110
+ }
111
+
112
+ async function recordViaWebCodecs({ canvas, run, fps }) {
113
+ // Minimal WebCodecs path: encode each frame; emit raw chunks wrapped as a Blob.
114
+ // (Full container muxing is out of scope; MediaRecorder is the primary path and
115
+ // this branch only runs where MediaRecorder is unavailable but VideoEncoder is.)
116
+ const chunks = [];
117
+ const encoder = new VideoEncoder({
118
+ output: (chunk) => {
119
+ const buf = new ArrayBuffer(chunk.byteLength);
120
+ chunk.copyTo(buf);
121
+ chunks.push(new Uint8Array(buf));
122
+ },
123
+ error: (e) => {
124
+ throw e;
125
+ },
126
+ });
127
+ encoder.configure({
128
+ codec: "vp09.00.10.08",
129
+ width: canvas.width,
130
+ height: canvas.height,
131
+ framerate: fps,
132
+ });
133
+ let frameIndex = 0;
134
+ await run(() => {
135
+ const frame = new VideoFrame(canvas, { timestamp: (frameIndex * 1e6) / fps });
136
+ encoder.encode(frame, { keyFrame: frameIndex % fps === 0 });
137
+ frame.close();
138
+ frameIndex += 1;
139
+ });
140
+ await encoder.flush();
141
+ encoder.close();
142
+ return new Blob(chunks, { type: "video/webm" });
143
+ }
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/geometry.js
3
+ //
4
+ // Engine-agnostic view geometry per SPEC.md §3, mirroring
5
+ // vexy-stax-py/src/vexy_stax/geometry.py EXACTLY (same formulas, same easings,
6
+ // same camera math). Pure functions only. The numeric outputs must match the
7
+ // Python for the same scene.
8
+ //
9
+ // Coordinate convention (three.js Y-up — SPEC.md §1, §3):
10
+ // - X = plate width (centered at 0); Y = vertical/up (plates centered at Y=0);
11
+ // Z = depth/stacking, front plate at Z=0, index 0 (farthest) at Z=-stackDepth,
12
+ // +Z toward the viewer. Deck center is at Z = -stackDepth/2.
13
+ // - Compact camera sits head-on on +Z; expanded orbits to azimuth/elevation.
14
+ // - Camera framing uses scene.size as the plate size so Python and JS agree.
15
+ // - Plate width/height in points == pixel dimensions of the source image.
16
+
17
+ import { resolvedOpacity } from "./scene.js";
18
+
19
+ export const MIN_GAP = 3.0;
20
+ export const FILL = 0.85;
21
+
22
+ /** Per-slide gap (points), falling back to camera.gap when unset (null). */
23
+ export function plateGaps(scene) {
24
+ return scene.slides.map((s) => (s.gap === null || s.gap === undefined ? scene.camera.gap : s.gap));
25
+ }
26
+
27
+ /**
28
+ * Total deck depth along Z. Compact collapses every gap to MIN_GAP; expanded
29
+ * sums the (N-1) inter-plate gaps (gaps[0] is before slide 0 and unused).
30
+ */
31
+ export function stackDepth(scene, view) {
32
+ const n = scene.slides.length;
33
+ if (n <= 1) return 0.0;
34
+ if (view === "compact") return (n - 1) * MIN_GAP;
35
+ const gaps = plateGaps(scene);
36
+ let sum = 0;
37
+ for (let i = 1; i < gaps.length; i++) sum += gaps[i];
38
+ return sum;
39
+ }
40
+
41
+ /** Per-plate Z (front plate at 0, index 0 at -stackDepth). Mirrors _stack_positions. */
42
+ function stackPositions(gaps) {
43
+ const n = gaps.length;
44
+ if (n === 0) return [];
45
+ let depth = 0;
46
+ for (let i = 1; i < n; i++) depth += gaps[i];
47
+ const cum = [0.0];
48
+ for (let i = 1; i < n; i++) cum.push(cum[cum.length - 1] + gaps[i]);
49
+ return cum.map((c) => c - depth);
50
+ }
51
+
52
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
53
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
54
+ const cross = (a, b) => [
55
+ a[1] * b[2] - a[2] * b[1],
56
+ a[2] * b[0] - a[0] * b[2],
57
+ a[0] * b[1] - a[1] * b[0],
58
+ ];
59
+ function normalize(a) {
60
+ const n = Math.sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) || 1.0;
61
+ return [a[0] / n, a[1] / n, a[2] / n];
62
+ }
63
+
64
+ /** Resolve a distance spec to absolute points (number, numeric string, or "P%"). */
65
+ function parseDistance(distance, viewportWidth) {
66
+ if (typeof distance === "number") return distance;
67
+ const text = String(distance).trim();
68
+ if (text.endsWith("%")) return (parseFloat(text.slice(0, -1)) / 100.0) * viewportWidth;
69
+ return parseFloat(text);
70
+ }
71
+
72
+ /**
73
+ * Angled hero camera framing the expanded *deck* (SPEC.md §3). Fits the plate
74
+ * bounding box (not the floor diagonal) so the deck fills FILL of the frame on
75
+ * its tighter axis. Plate size = scene.size so JS and Python agree exactly.
76
+ * Returns { position:[x,y,z], target:[x,y,z], fov, near }.
77
+ */
78
+ export function expandedCamera(scene) {
79
+ const cam = scene.camera;
80
+ const depth = stackDepth(scene, "expanded");
81
+ const target = [0.0, 0.0, -depth / 2.0];
82
+
83
+ // Direction target -> camera (azimuth swings toward -X, elevation lifts +Y).
84
+ const az = (cam.angle * Math.PI) / 180.0;
85
+ const el = (cam.elevation * Math.PI) / 180.0;
86
+ const toCam = normalize([
87
+ -Math.sin(az) * Math.cos(el),
88
+ Math.sin(el),
89
+ Math.cos(az) * Math.cos(el),
90
+ ]);
91
+ const look = [-toCam[0], -toCam[1], -toCam[2]]; // camera -> target
92
+
93
+ const upWorld = [0.0, 1.0, 0.0];
94
+ let right = cross(look, upWorld);
95
+ right = Math.abs(dot(look, upWorld)) < 0.999 ? normalize(right) : [1.0, 0.0, 0.0];
96
+ const up = normalize(cross(right, look));
97
+
98
+ const halfWPlate = scene.size.width / 2.0;
99
+ const halfHPlate = scene.size.height / 2.0;
100
+ const zPositions = stackPositions(plateGaps(scene));
101
+ let halfW = 0.0;
102
+ let halfH = 0.0;
103
+ for (const z of zPositions) {
104
+ for (const sx of [-halfWPlate, halfWPlate]) {
105
+ for (const sy of [-halfHPlate, halfHPlate]) {
106
+ const rel = sub([sx, sy, z], target);
107
+ halfW = Math.max(halfW, Math.abs(dot(rel, right)));
108
+ halfH = Math.max(halfH, Math.abs(dot(rel, up)));
109
+ }
110
+ }
111
+ }
112
+
113
+ const hfov = (cam.fov * Math.PI) / 180.0;
114
+ const aspect = scene.size.width / scene.size.height;
115
+ const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
116
+ const dW = halfW / (FILL * Math.tan(hfov / 2.0));
117
+ const dH = halfH / (FILL * Math.tan(vfov / 2.0));
118
+ const distance = Math.max(dW, dH);
119
+ const near = Math.max(1.0, distance * 0.005);
120
+ const position = [
121
+ target[0] + toCam[0] * distance,
122
+ target[1] + toCam[1] * distance,
123
+ target[2] + toCam[2] * distance,
124
+ ];
125
+ return { position, target, fov: cam.fov, near };
126
+ }
127
+
128
+ /**
129
+ * Head-on camera on +Z aimed at the deck center. `distance` is "P%" of viewport
130
+ * width or absolute points; near plane scales with distance.
131
+ * Returns { position:[x,y,z], target:[x,y,z], fov, near }.
132
+ */
133
+ export function compactCamera(scene) {
134
+ const cam = scene.camera;
135
+ const depth = stackDepth(scene, "compact");
136
+ const target = [0.0, 0.0, -depth / 2.0];
137
+ const distance = parseDistance(cam.distance, scene.size.width);
138
+ const near = Math.max(1.0, distance * 0.005);
139
+ const position = [target[0], target[1], target[2] + distance];
140
+ return { position, target, fov: cam.fov, near };
141
+ }
142
+
143
+ /** Evaluate a shared easing curve at t (clamped to [0, 1]). */
144
+ export function ease(name, t) {
145
+ t = Math.max(0.0, Math.min(1.0, t));
146
+ if (name === "linear") return t;
147
+ if (name === "easeInCubic") return t * t * t;
148
+ if (name === "easeOutCubic") {
149
+ const u = 1.0 - t;
150
+ return 1.0 - u * u * u;
151
+ }
152
+ if (name === "easeInOutCubic") {
153
+ if (t < 0.5) return 4.0 * t * t * t;
154
+ const u = -2.0 * t + 2.0;
155
+ return 1.0 - (u * u * u) / 2.0;
156
+ }
157
+ throw new Error(`Unknown easing: ${JSON.stringify(name)}`);
158
+ }
159
+
160
+ /**
161
+ * Opacity at morph progress tExpanded in [0, 1] (0=compact, 1=expanded). Lerps
162
+ * the compact and expanded per-view values. Mirrors interpolate_opacity.
163
+ */
164
+ export function interpolateOpacity(slide, tExpanded) {
165
+ const t = Math.max(0.0, Math.min(1.0, tExpanded));
166
+ const lo = resolvedOpacity(slide, "compact");
167
+ const hi = resolvedOpacity(slide, "expanded");
168
+ const value = lo + (hi - lo) * t;
169
+ return Math.max(0.0, Math.min(1.0, value));
170
+ }
171
+
172
+ function lerp3(a, b, t) {
173
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
174
+ }
175
+
176
+ function poseAt(compact, expanded, t) {
177
+ return {
178
+ position: lerp3(compact.position, expanded.position, t),
179
+ target: lerp3(compact.target, expanded.target, t),
180
+ fov: compact.fov + (expanded.fov - compact.fov) * t,
181
+ near: compact.near + (expanded.near - compact.near) * t,
182
+ };
183
+ }
184
+
185
+ /** Build a frame state at morph factor t (already eased) from precomputed poses. */
186
+ function frameState(scene, compact, expanded, t) {
187
+ const expandedGaps = plateGaps(scene);
188
+ const gaps = expandedGaps.map((g) => MIN_GAP + (g - MIN_GAP) * t);
189
+ const opacities = scene.slides.map((s) => interpolateOpacity(s, t));
190
+ return { camera: poseAt(compact, expanded, t), gaps, opacities };
191
+ }
192
+
193
+ /**
194
+ * Build a FrameState at an already-eased morph factor t (0=compact, 1=expanded).
195
+ * Computes the compact/expanded endpoint poses on demand; used by the playable
196
+ * transition driver and scrollspy so a single t produces the full morph state
197
+ * (camera pose + per-plate gaps + per-slide opacities).
198
+ * @param {object} scene parsed scene
199
+ * @param {number} t eased morph factor
200
+ */
201
+ export function frameStateAt(scene, t) {
202
+ const compact = compactCamera(scene);
203
+ const expanded = expandedCamera(scene);
204
+ return frameState(scene, compact, expanded, Math.max(0, Math.min(1, t)));
205
+ }
206
+
207
+ // Each transition is a sequence of legs expressed as morph endpoints
208
+ // (0=compact, 1=expanded). Mirrors _LEGS in geometry.py.
209
+ const LEGS = {
210
+ expand: [[0.0, 1.0]],
211
+ collapse: [[1.0, 0.0]],
212
+ expand_collapse: [
213
+ [0.0, 1.0],
214
+ [1.0, 0.0],
215
+ ],
216
+ collapse_expand: [
217
+ [1.0, 0.0],
218
+ [0.0, 1.0],
219
+ ],
220
+ };
221
+
222
+ /**
223
+ * Per-frame states for scene.transition (empty when no transition). Each leg
224
+ * renders round(duration*fps) frames; a hold of round(wait*fps) frames is
225
+ * inserted at the far end of each leg. Mirrors frame_plan in geometry.py.
226
+ */
227
+ export function framePlan(scene) {
228
+ const tr = scene.transition;
229
+ if (!tr) return [];
230
+ const compact = compactCamera(scene);
231
+ const expanded = expandedCamera(scene);
232
+ const legFrames = Math.round(tr.duration * tr.fps);
233
+ const waitFrames = Math.round(tr.wait * tr.fps);
234
+ const legs = LEGS[tr.kind];
235
+
236
+ const states = [];
237
+ for (const [start, end] of legs) {
238
+ for (let i = 0; i < legFrames; i++) {
239
+ const p = legFrames ? i / legFrames : 0.0;
240
+ const eased = ease(tr.easing, p);
241
+ const t = start + (end - start) * eased;
242
+ states.push(frameState(scene, compact, expanded, t));
243
+ }
244
+ const hold = frameState(scene, compact, expanded, end);
245
+ for (let i = 0; i < waitFrames; i++) states.push(hold);
246
+ }
247
+ return states;
248
+ }
package/src/global.js ADDED
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/global.js
3
+ //
4
+ // Classic-script global entry (SPEC.md §6.3). Importing this auto-registers the
5
+ // <vexy-stax> element (via element.js) and exposes window.VexyStax.
6
+
7
+ import { VexyStax, loadScene } from "./index.js";
8
+ import "./element.js";
9
+
10
+ const api = { VexyStax, loadScene };
11
+
12
+ if (typeof window !== "undefined") {
13
+ window.VexyStax = api;
14
+ }
15
+
16
+ export { VexyStax, loadScene };
package/src/index.js ADDED
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // this_file: src/index.js
3
+ //
4
+ // Public ESM API (SPEC.md §6.1): a static view, image export, a playable
5
+ // transition, video export, and a scroll-driven transition. The view math comes
6
+ // from geometry.js (mirrors the Python exactly); the morph driver, scrollspy
7
+ // mapping, and exporters are in transition.js / scrollspy.js / export.js.
8
+
9
+ import { Stage } from "./stage.js";
10
+ import { loadScene, parseScene, resolvedOpacity } from "./scene.js";
11
+ import { frameStateAt, ease } from "./geometry.js";
12
+ import { playTransition, transitionEndpoints, buildTimeline, morphAtProgress } from "./transition.js";
13
+ import { attachScrollspy } from "./scrollspy.js";
14
+ import { canvasToPngBlob, recordVideo } from "./export.js";
15
+
16
+ export {
17
+ loadScene,
18
+ parseScene,
19
+ resolvedOpacity,
20
+ };
21
+
22
+ export {
23
+ MIN_GAP,
24
+ FILL,
25
+ plateGaps,
26
+ stackDepth,
27
+ compactCamera,
28
+ expandedCamera,
29
+ ease,
30
+ interpolateOpacity,
31
+ framePlan,
32
+ frameStateAt,
33
+ } from "./geometry.js";
34
+
35
+ export { buildTimeline, morphAtProgress, transitionEndpoints } from "./transition.js";
36
+ export { computeScrollProgress, prefersReducedMotion } from "./scrollspy.js";
37
+ export { canvasToPngBlob } from "./export.js";
38
+
39
+ export class VexyStax {
40
+ /**
41
+ * @param {HTMLElement} container mount point for the three.js canvas
42
+ * @param {object} scene normalized scene object (from loadScene)
43
+ */
44
+ constructor(container, scene) {
45
+ if (!container) throw new Error("VexyStax: container is required");
46
+ if (!scene || !Array.isArray(scene.slides)) {
47
+ throw new Error("VexyStax: scene must be a parsed scene object (use loadScene first)");
48
+ }
49
+ this.container = container;
50
+ this.scene = scene;
51
+ this.stage = new Stage(container, scene);
52
+ this._ready = this.stage.init().then(() => {
53
+ this.stage.render();
54
+ return this;
55
+ });
56
+ }
57
+
58
+ /** Resolves once textures are loaded and the initial view is rendered. */
59
+ get ready() {
60
+ return this._ready;
61
+ }
62
+
63
+ /** Position the deck + camera for a view and render a frame. */
64
+ async setView(view) {
65
+ await this._ready;
66
+ this.stage.setView(view);
67
+ this.stage.render();
68
+ return this;
69
+ }
70
+
71
+ /** Resize the renderer/camera to the container (or explicit size). */
72
+ resize(width, height) {
73
+ const w = width ?? this.container.clientWidth;
74
+ const h = height ?? this.container.clientHeight;
75
+ this.stage.resize(w, h);
76
+ this.stage.render();
77
+ }
78
+
79
+ /**
80
+ * Render the current view to a PNG Blob. `scale` re-renders at a higher
81
+ * pixel size. (Animated transition export is a later story.)
82
+ */
83
+ async toImage({ scale = 1 } = {}) {
84
+ await this._ready;
85
+ const renderer = this.stage.renderer;
86
+ const canvas = renderer.domElement;
87
+ let restore = null;
88
+ if (scale !== 1) {
89
+ // getSize fills a Vector2; we read CSS size then re-render larger.
90
+ const w = canvas.width;
91
+ const h = canvas.height;
92
+ const cssW = this.container.clientWidth || this.scene.size.width;
93
+ const cssH = this.container.clientHeight || this.scene.size.height;
94
+ const prevPR = renderer.getPixelRatio();
95
+ renderer.setPixelRatio(prevPR * scale);
96
+ renderer.setSize(cssW, cssH, false);
97
+ restore = () => {
98
+ renderer.setPixelRatio(prevPR);
99
+ renderer.setSize(cssW, cssH, false);
100
+ this.stage.render();
101
+ };
102
+ }
103
+ this.stage.render();
104
+ try {
105
+ return await canvasToPngBlob(canvas);
106
+ } finally {
107
+ if (restore) restore();
108
+ }
109
+ }
110
+
111
+ // --- Render operations (SPEC.md §6.1) ------------------------------------
112
+
113
+ /**
114
+ * Play a transition (camera + spacing + opacity + caption fade morph) as an
115
+ * animation, rendering each frame. Resolves when the animation finishes.
116
+ *
117
+ * @param {string} [kind] override scene.transition.kind
118
+ * @param {object} [opts]
119
+ * @param {(p:number)=>void} [opts.onProgress] global progress [0,1] per frame
120
+ * @returns {Promise<this>}
121
+ */
122
+ async transition(kind, opts = {}) {
123
+ await this._ready;
124
+ const resolvedKind = kind ?? this.scene.transition?.kind;
125
+ if (!resolvedKind) {
126
+ throw new Error("VexyStax.transition: no kind given and scene.transition is null");
127
+ }
128
+ // Snap to the starting endpoint so the first frame is correct.
129
+ const { startMorph } = transitionEndpoints(resolvedKind);
130
+ this.stage.applyFrameState(frameStateAt(this.scene, startMorph), startMorph);
131
+ this.stage.render();
132
+
133
+ this._cancelTransition?.();
134
+ const controller = playTransition(this.scene, (state) => {
135
+ // Derive the (unclamped) morph factor for caption fade from the state's
136
+ // camera lerp between compact/expanded targets is unnecessary; the driver
137
+ // already passes opacities/gaps. We re-derive t from gaps[1] for captions.
138
+ const t = this._morphFromGaps(state.gaps);
139
+ this.stage.applyFrameState(state, t);
140
+ this.stage.render();
141
+ }, { kind: resolvedKind, onProgress: opts.onProgress });
142
+
143
+ this._cancelTransition = controller.cancel;
144
+ this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
145
+ try {
146
+ await controller.promise;
147
+ this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
148
+ } finally {
149
+ this._cancelTransition = null;
150
+ }
151
+ return this;
152
+ }
153
+
154
+ /** Re-derive the morph factor t from a frame's gap[1] (for caption fade). */
155
+ _morphFromGaps(gaps) {
156
+ if (gaps.length < 2) return 0;
157
+ const fullGap = this.stage.scene.camera.gap;
158
+ const span = fullGap - 3.0; // MIN_GAP = 3
159
+ if (span <= 0) return gaps[1] >= fullGap ? 1 : 0;
160
+ return Math.max(0, Math.min(1, (gaps[1] - 3.0) / span));
161
+ }
162
+
163
+ /**
164
+ * Record the transition to a video Blob (WebCodecs preferred, MediaRecorder
165
+ * fallback). Plays the full transition while capturing the canvas.
166
+ * @param {object} [opts]
167
+ * @param {string} [opts.kind] override scene.transition.kind
168
+ * @returns {Promise<Blob>}
169
+ */
170
+ async toVideo(opts = {}) {
171
+ await this._ready;
172
+ const kind = opts.kind ?? this.scene.transition?.kind;
173
+ if (!kind) throw new Error("VexyStax.toVideo: no kind given and scene.transition is null");
174
+ const fps = this.scene.transition?.fps ?? 30;
175
+ const canvas = this.stage.renderer.domElement;
176
+
177
+ return recordVideo({
178
+ canvas,
179
+ fps,
180
+ run: async (onFrame) => {
181
+ const controller = playTransition(this.scene, (state) => {
182
+ const t = this._morphFromGaps(state.gaps);
183
+ this.stage.applyFrameState(state, t);
184
+ this.stage.render();
185
+ onFrame(state);
186
+ }, { kind });
187
+ await controller.promise;
188
+ },
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Drive the transition from scroll position over a trigger region (SPEC.md
194
+ * §6.4). Maps scroll progress [0,1] to the morph; respects
195
+ * prefers-reduced-motion (snaps to endpoints).
196
+ *
197
+ * @param {object} opts
198
+ * @param {Element|string} opts.trigger element or selector for the scroll region
199
+ * @param {string} [opts.kind] override scene.transition.kind
200
+ * @param {boolean} [opts.reducedMotion] override prefers-reduced-motion
201
+ * @returns {{disconnect:()=>void}}
202
+ */
203
+ scrollspy(opts = {}) {
204
+ const kind = opts.kind ?? this.scene.transition?.kind ?? "expand";
205
+ const trigger =
206
+ typeof opts.trigger === "string" ? document.querySelector(opts.trigger) : opts.trigger;
207
+ if (!trigger) throw new Error("VexyStax.scrollspy: trigger element not found");
208
+
209
+ // Build a normalized timeline so scroll progress maps through the same legs
210
+ // (including holds) as the playable animation.
211
+ const easing = this.scene.transition?.easing ?? "easeInOutCubic";
212
+ const duration = this.scene.transition?.duration ?? 3.0;
213
+ const wait = this.scene.transition?.wait ?? 0.0;
214
+
215
+ const apply = (p) => {
216
+ // Map global scroll progress through the transition timeline → morph t.
217
+ const t = this._scrollMorph(kind, p, easing, duration, wait);
218
+ this.stage.applyFrameState(frameStateAt(this.scene, t), t);
219
+ this.stage.render();
220
+ };
221
+
222
+ // Prime to the starting endpoint.
223
+ apply(0);
224
+
225
+ this._scrollspy?.disconnect?.();
226
+ this._scrollspy = attachScrollspy({
227
+ trigger,
228
+ reducedMotion: opts.reducedMotion,
229
+ onProgress: apply,
230
+ });
231
+ return this._scrollspy;
232
+ }
233
+
234
+ /** Resolve scroll progress p to a morph factor via the transition timeline. */
235
+ _scrollMorph(kind, p, easing, duration, wait) {
236
+ const timeline = buildTimeline(kind, { duration, wait });
237
+ return morphAtProgress(timeline, p, (x) => ease(easing, x));
238
+ }
239
+
240
+ /** Tear down the three.js stage, stop animations/scrollspy, remove the canvas. */
241
+ destroy() {
242
+ this._cancelTransition?.();
243
+ this._scrollspy?.disconnect?.();
244
+ this.stage?.dispose();
245
+ }
246
+ }