vexy-stax-js 3.0.1 → 3.0.10

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/CHANGELOG.md CHANGED
@@ -4,6 +4,97 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.0.10] — issues 331
8
+
9
+ ### Fixed
10
+
11
+ - **Seekable mp4 video export** (331): `src/export.js` `recordVideo()` now uses a
12
+ deterministic WebCodecs + `mp4-muxer` primary path instead of the broken
13
+ `captureStream`/MediaRecorder approach. The new `recordViaMuxer()` function:
14
+ - Checks H.264 (`avc1.640028`) support via `VideoEncoder.isConfigSupported()`; falls
15
+ back to VP9 (`vp09.00.10.08`) if H.264 is unavailable.
16
+ - Feeds each `VideoFrame(canvas, {timestamp, duration})` into a `VideoEncoder` whose
17
+ output chunks are piped directly to a `mp4-muxer` `Muxer` with
18
+ `fastStart: "in-memory"` and an `ArrayBufferTarget`.
19
+ - Calls `muxer.finalize()` after `encoder.flush()`, producing a `Blob([target.buffer],
20
+ {type:"video/mp4"})` with correct per-stream `duration` and `nb_frames` metadata —
21
+ verified seekable by ffprobe.
22
+ - `MediaRecorder` (live `captureStream`) is retained as a last-resort fallback for
23
+ environments entirely without `VideoEncoder`.
24
+ - **`verify/example.mjs` extension logic** (331): the `ext` variable already derived the
25
+ extension from `blob.type` (`mp4` vs `webm`), so the primary path now writes
26
+ `airbl-transition.mp4` automatically.
27
+
28
+ ### Added
29
+
30
+ - **Deployable `docs/` site for GitHub Pages** (331 part 2):
31
+ - `scripts/build-docs.mjs` copies the built `dist/` bundles (element + global + source
32
+ maps), the `airbl-lores` scene JSON + slide PNGs, writes a self-contained
33
+ `docs/index.html` landing page with a playable `<vexy-stax>` demo and usage snippets
34
+ for all three entry points (Web Component, ESM import, global script), and a short
35
+ `docs/README.md`.
36
+ - `package.json` gains a `build:docs` script (`node scripts/build-docs.mjs`).
37
+ - `build.sh` calls `npm run build:docs` after `npm run build`, so the docs site is
38
+ regenerated on every full build.
39
+ - Base path is `/vexy-stax-js/` (GitHub Pages subdirectory), set via a `<base>` tag
40
+ in `index.html`.
41
+
42
+ ## [3.0.9] — issue 332
43
+
44
+ ### Added
45
+
46
+ - **Global `captions` on/off toggle** (332): a new top-level boolean scene field (default `true`,
47
+ preserving prior behavior) parsed + validated in `src/scene.js` and
48
+ `schema/vexy-stax-scene.schema.json` (strict — a non-bool throws). When `false`, no caption plates
49
+ are built (`stage.js`) and `captionOpacities` returns all-zero, and the slide plates drop directly
50
+ onto the floor.
51
+
52
+ ### Changed
53
+
54
+ - **New stacked caption layout** (332): when captions are ON, each caption plate sits RIGHT ON the
55
+ floor (bottom edge on the floor line) and its slide plate sits directly ON TOP of it, LEFT-aligned
56
+ with the slide (caption left edge == slide left edge at `X = -width/2`). This replaces the previous
57
+ "caption to the LEFT of the plate" layout. Mirrors `vexy-stax-py` exactly.
58
+ - `geometry.js`: added `slideLift(scene)` (one caption-plate height when captions on, else 0); every
59
+ slide plate is lifted by it in `stage.js`. `captionAnchorX` now means the caption plate's LEFT edge
60
+ (numerically unchanged since `CAPTION_GAP_EM == 0`). `captionOpacities` returns all-zero when
61
+ `captions` is off.
62
+ - **Crop-free camera framing for the full composite**: `compactCamera` fits the frontmost COMPOSITE
63
+ (width `W`, height `H + lift`) and aims at its center (`Y = lift/2`); `expandedCamera` includes the
64
+ lifted slide corners AND the on-floor caption-plate bottom row in its bounding fit, so the
65
+ caption+slide stack is framed with no crop.
66
+ - `stage.js`: lifts plates/borders/reflections by `slideLift`, anchors each caption plate by its LEFT
67
+ edge on the floor, and skips caption plates entirely when the toggle is off.
68
+
69
+ ## [3.0.8] — issues 328, 329, 330
70
+
71
+ ### Changed
72
+
73
+ - **Static Zalando Sans `<link>` in the generated demos** (328): `verify/example.mjs` now emits the
74
+ Google Fonts preconnect + `Zalando+Sans:wdth,wght@125,500` stylesheet directly in the `<head>` of
75
+ both `playable.html` and `scrollable.html`, so the caption face is available before first paint.
76
+ (The element's runtime `_ensureCaptionFonts` injection from 3.0.7 still awaits `document.fonts`,
77
+ so this just removes the first-frame fallback flash.)
78
+
79
+ ### Verified (no code change needed)
80
+
81
+ - **`scrollable.html` reflects current src** (329): the demo HTML is regenerated from the live
82
+ `src/` on every `example.sh` / `example.mjs` run (and now also when the Python `example.py`
83
+ rebuilds the JS demos — issue 330), so there is no stale checked-in copy to "port" changes into.
84
+ - **`outputs/` cleanup** (330): `example.sh` already `rm -rf outputs` before regenerating, so stale
85
+ artifacts are removed on every rebuild.
86
+
87
+ ## [3.0.7] — issue 328
88
+
89
+ ### Changed
90
+
91
+ - **Default caption font → "Zalando Sans"** (328): the default caption font is now "Zalando Sans"
92
+ pulled from Google Fonts at wdth 125 / wght 500 (matching the bundled Python `vexy-stax.ttf` =
93
+ Zalando Sans Expanded). `makeCaptionSprite` renders the default at `500 expanded` with 0.02em
94
+ tracking (an explicit family is still used plainly, with a `system-ui, sans-serif` fallback).
95
+ `_ensureCaptionFonts` injects the Google Fonts preconnect links + the Zalando Sans stylesheet
96
+ and preloads the face by default whenever a caption uses the default font.
97
+
7
98
  ## [3.0.6] — issue 327
8
99
 
9
100
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexy-stax-js",
3
- "version": "3.0.1",
3
+ "version": "3.0.10",
4
4
  "description": "Browser renderer for the vexy-stax shared scene format: 3D glass plates in two views, with morphable opacity. Ships as ESM, Web Component, and a classic-script global.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,8 @@
19
19
  "build": "VEXY_BUILD=element vite build && VEXY_BUILD=global vite build",
20
20
  "preview": "vite preview",
21
21
  "test:unit": "node --test",
22
- "test": "npm run test:unit && playwright test"
22
+ "test": "npm run test:unit && playwright test",
23
+ "build:docs": "node scripts/build-docs.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "threejs",
@@ -48,6 +49,7 @@
48
49
  },
49
50
  "dependencies": {
50
51
  "gsap": "^3.13.0",
52
+ "mp4-muxer": "^5.2.2",
51
53
  "three": "^0.181.0"
52
54
  },
53
55
  "devDependencies": {
@@ -69,6 +69,7 @@
69
69
  },
70
70
  "background": { "type": "string", "default": "#ffffff" },
71
71
  "juicy": { "type": "boolean", "default": false, "description": "Python-only per-channel color match." },
72
+ "captions": { "type": "boolean", "default": true, "description": "Global captions toggle (issue 332). ON: each slide plate sits on top of its on-floor caption plate (stacked layout). OFF: no caption plates; slide plates sit directly on the floor." },
72
73
  "caption_defaults": { "$ref": "#/$defs/captionStyle" },
73
74
  "caption_fade": {
74
75
  "type": "object",
package/src/export.js CHANGED
@@ -2,10 +2,15 @@
2
2
  // this_file: src/export.js
3
3
  //
4
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.
5
+ // to a PNG Blob. Video: capture the deck transition to an encoded, seekable clip.
6
+ //
7
+ // PRIMARY path (issue 331): WebCodecs (VideoEncoder) + mp4-muxer → H.264/mp4.
8
+ // Produces a fully seekable mp4 with correct duration + per-stream frame metadata.
9
+ // Falls back to webm-muxer+VP9 if H.264 is unsupported, then finally to
10
+ // MediaRecorder (live captureStream) as the last-resort path for environments
11
+ // that lack VideoEncoder entirely.
12
+
13
+ import { Muxer, ArrayBufferTarget } from "mp4-muxer";
9
14
 
10
15
  /**
11
16
  * Read a canvas to a PNG Blob.
@@ -43,36 +48,66 @@ function pickMimeType() {
43
48
  return candidates.find((t) => MediaRecorder.isTypeSupported(t));
44
49
  }
45
50
 
51
+ /**
52
+ * Check whether a given VideoEncoder codec string is supported.
53
+ * @param {string} codec
54
+ * @param {number} width
55
+ * @param {number} height
56
+ * @param {number} fps
57
+ * @returns {Promise<boolean>}
58
+ */
59
+ async function isCodecSupported(codec, width, height, fps) {
60
+ if (typeof VideoEncoder === "undefined") return false;
61
+ try {
62
+ const { supported } = await VideoEncoder.isConfigSupported({
63
+ codec,
64
+ width,
65
+ height,
66
+ framerate: fps,
67
+ });
68
+ return !!supported;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
46
74
  /**
47
75
  * Record a transition to a video Blob.
48
76
  *
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.
77
+ * Drives `run(onFrame)`: the caller renders each frame onto the canvas
78
+ * synchronously inside the per-frame callback. The PRIMARY path uses WebCodecs
79
+ * (VideoEncoder) + mp4-muxer to produce a seekable mp4 with correct duration
80
+ * and per-stream frame count metadata. Falls back to MediaRecorder only when
81
+ * VideoEncoder is unavailable.
53
82
  *
54
83
  * @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
84
+ * @param {HTMLCanvasElement} opts.canvas the renderer canvas to capture
85
+ * @param {(onFrame:(state:object)=>void)=>Promise<void>} opts.run plays the
57
86
  * transition, calling onFrame for each frame (which must render to the canvas)
58
- * @param {number} [opts.fps] frame rate for the captured stream
87
+ * @param {number} [opts.fps] frame rate for the encoded clip (default 30)
59
88
  * @returns {Promise<Blob>}
60
89
  */
61
90
  export async function recordVideo({ canvas, run, fps = 30 }) {
62
91
  if (!canvas) throw new Error("recordVideo: canvas is required");
63
92
  if (typeof run !== "function") throw new Error("recordVideo: run() callback is required");
64
93
 
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 });
94
+ // PRIMARY: WebCodecs + mp4-muxer (issue 331) — prefer H.264/mp4; fall back to
95
+ // VP9/webm inside the same muxed path if H.264 is unsupported.
96
+ if (typeof VideoEncoder !== "undefined") {
97
+ const w = canvas.width;
98
+ const h = canvas.height;
99
+ const avcCodec = "avc1.640028"; // H.264 High Profile Level 4.0
100
+ const vp9Codec = "vp09.00.10.08";
101
+
102
+ const useAvc = await isCodecSupported(avcCodec, w, h, fps);
103
+ const useVp9 = !useAvc && (await isCodecSupported(vp9Codec, w, h, fps));
104
+
105
+ if (useAvc || useVp9) {
106
+ return recordViaMuxer({ canvas, run, fps, useAvc });
72
107
  }
73
- return recordViaWebCodecs({ canvas, run, fps });
74
108
  }
75
109
 
110
+ // FALLBACK: MediaRecorder (captureStream) — no mux metadata, non-seekable.
76
111
  if (hasMediaRecorder()) {
77
112
  return recordViaMediaRecorder({ canvas, run, fps });
78
113
  }
@@ -82,6 +117,72 @@ export async function recordVideo({ canvas, run, fps = 30 }) {
82
117
  );
83
118
  }
84
119
 
120
+ /**
121
+ * PRIMARY recording path: VideoEncoder frames piped into mp4-muxer (H.264) or
122
+ * webm-muxer (VP9). Produces a properly seekable container with real duration +
123
+ * per-stream nb_frames metadata.
124
+ *
125
+ * @param {object} opts
126
+ * @param {HTMLCanvasElement} opts.canvas
127
+ * @param {(onFrame:()=>void)=>Promise<void>} opts.run
128
+ * @param {number} opts.fps
129
+ * @param {boolean} opts.useAvc true→H.264+mp4, false→VP9+webm via mp4-muxer
130
+ */
131
+ async function recordViaMuxer({ canvas, run, fps, useAvc }) {
132
+ const w = canvas.width;
133
+ const h = canvas.height;
134
+ const codec = useAvc ? "avc1.640028" : "vp09.00.10.08";
135
+
136
+ const target = new ArrayBufferTarget();
137
+ const muxer = new Muxer({
138
+ target,
139
+ video: {
140
+ codec: useAvc ? "avc" : "vp9",
141
+ width: w,
142
+ height: h,
143
+ },
144
+ // fastStart embeds the moov atom at the front for immediate seeking in players.
145
+ fastStart: "in-memory",
146
+ });
147
+
148
+ const chunks = [];
149
+ const encoder = new VideoEncoder({
150
+ output: (chunk, meta) => {
151
+ muxer.addVideoChunk(chunk, meta);
152
+ },
153
+ error: (e) => {
154
+ throw e;
155
+ },
156
+ });
157
+
158
+ encoder.configure({
159
+ codec,
160
+ width: w,
161
+ height: h,
162
+ framerate: fps,
163
+ // H.264: signal avc1 bitstream (Annex-B not needed for mp4-muxer).
164
+ ...(useAvc ? { avc: { format: "avc" } } : {}),
165
+ });
166
+
167
+ let frameIndex = 0;
168
+ const frameDuration = Math.round(1e6 / fps); // microseconds per frame
169
+
170
+ await run(() => {
171
+ const timestamp = frameIndex * frameDuration;
172
+ const frame = new VideoFrame(canvas, { timestamp, duration: frameDuration });
173
+ encoder.encode(frame, { keyFrame: frameIndex % fps === 0 });
174
+ frame.close();
175
+ frameIndex += 1;
176
+ });
177
+
178
+ await encoder.flush();
179
+ encoder.close();
180
+ muxer.finalize();
181
+
182
+ const mimeType = useAvc ? "video/mp4" : "video/webm";
183
+ return new Blob([target.buffer], { type: mimeType });
184
+ }
185
+
85
186
  async function recordViaMediaRecorder({ canvas, run, fps }) {
86
187
  const stream = canvas.captureStream(fps);
87
188
  const mimeType = pickMimeType();
@@ -108,36 +209,3 @@ async function recordViaMediaRecorder({ canvas, run, fps }) {
108
209
  stream.getTracks?.().forEach((t) => t.stop());
109
210
  return new Blob(chunks, { type: recorder.mimeType || mimeType || "video/webm" });
110
211
  }
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
- }
package/src/geometry.js CHANGED
@@ -84,17 +84,30 @@ export function captionPlateHeight(scene) {
84
84
  }
85
85
 
86
86
  /**
87
- * World Y of a caption plate's vertical center (issue 311): the plate sits on the virtual
88
- * ground (Y = -height/2), so its center is half its height above it. Mirrors geometry.py.
87
+ * World Y of a caption plate's vertical center (issue 311; relayout issue 332): the plate
88
+ * sits RIGHT ON the floor (its bottom edge on the floor line Y = -height/2), so its center
89
+ * is half its plate height above it. The slide plate then sits on TOP (see slideLift).
90
+ * Mirrors caption_plate_center_y in geometry.py.
89
91
  */
90
92
  export function captionPlateCenterY(scene) {
91
93
  return -(scene.size.height / 2.0) + captionPlateHeight(scene) / 2.0;
92
94
  }
93
95
 
94
96
  /**
95
- * World X where every caption's RIGHT edge aligns CAPTION_GAP_EM em left of the plate
96
- * left edge (-width/2). All plates share scene.size width centered at X=0. Mirrors
97
- * caption_anchor_x in geometry.py.
97
+ * World Y offset added to EVERY slide plate's vertical center (issue 332). Captions ON: each
98
+ * slide sits on TOP of its on-floor caption plate, so it is lifted by exactly one
99
+ * caption-plate height relative to the centered (Y=0) convention. Captions OFF: no caption
100
+ * plates, slides sit directly on the floor → lift 0. Mirrors slide_lift in geometry.py.
101
+ */
102
+ export function slideLift(scene) {
103
+ return scene.captions ? captionPlateHeight(scene) : 0.0;
104
+ }
105
+
106
+ /**
107
+ * World X where every caption plate's LEFT edge aligns (issue 332 relayout): the caption
108
+ * plate is LEFT-aligned with its slide plate, so its left edge sits at the slide left edge
109
+ * (-width/2). CAPTION_GAP_EM is 0, so the numeric value is unchanged from the prior
110
+ * right-edge anchor; only the meaning (now a LEFT edge) changed. Mirrors caption_anchor_x.
98
111
  */
99
112
  export function captionAnchorX(scene) {
100
113
  return -(scene.size.width / 2.0 + CAPTION_GAP_EM * captionSize(scene));
@@ -199,16 +212,27 @@ export function expandedCamera(scene, viewportAspect) {
199
212
  const halfHPlate = scene.size.height / 2.0;
200
213
  const zPositions = stackPositions(plateGaps(scene));
201
214
 
215
+ // Issue 332: slides are LIFTED by one caption-plate height (captions on) so they sit on
216
+ // top of their on-floor caption plates. The full composite the camera frames (NO crop)
217
+ // spans vertically from the floor line (caption-plate bottom == -H/2) up to the lifted
218
+ // slide top (lift + H/2). Include the lifted slide corners AND the caption-plate bottom
219
+ // corners so the bounding fit never crops the caption row. Mirrors geometry.py.
220
+ const lift = slideLift(scene);
221
+ const floorY = -halfHPlate; // caption plate bottom (and floor line)
222
+ const slideYLo = lift - halfHPlate;
223
+ const slideYHi = lift + halfHPlate;
224
+ const capYs = scene.captions ? [floorY] : []; // extra bottom row (caption plate bottom)
225
+
202
226
  // Precompute each corner's (right, up, look) offsets relative to baseTarget so
203
227
  // projecting at a candidate (distance D, horizontal pan) is cheap and exact.
204
228
  // ndc_x = (cr - pan)/((cl + D)*th). Mirrors geometry.py expanded_camera.
205
229
  const corners = []; // [cr, cu, cl]
206
230
  const centers = []; // [cr, cl] of each plate center
207
231
  for (const z of zPositions) {
208
- const relC = sub([0.0, 0.0, z], baseTarget);
232
+ const relC = sub([0.0, lift, z], baseTarget);
209
233
  centers.push([dot(relC, right), dot(relC, look)]);
210
234
  for (const sx of [-halfWPlate, halfWPlate]) {
211
- for (const sy of [-halfHPlate, halfHPlate]) {
235
+ for (const sy of [slideYLo, slideYHi, ...capYs]) {
212
236
  const rel = sub([sx, sy, z], baseTarget);
213
237
  corners.push([dot(rel, right), dot(rel, up), dot(rel, look)]);
214
238
  }
@@ -306,8 +330,14 @@ export function expandedCamera(scene, viewportAspect) {
306
330
  export function compactCamera(scene, viewportAspect) {
307
331
  const cam = scene.camera;
308
332
  const depth = stackDepth(scene, "compact");
309
- const target = [0.0, 0.0, -depth / 2.0];
310
-
333
+ // Issue 332: the frontmost COMPOSITE the head-on camera frames is the slide plate plus
334
+ // (captions on) its on-floor caption plate stacked below it: full width W, height H + lift
335
+ // (lift == one caption-plate height), vertically centered at Y = lift/2. Aim at that
336
+ // composite center so neither the slide nor the caption row crops. Mirrors geometry.py.
337
+ const lift = slideLift(scene);
338
+ const compositeH = scene.size.height + lift;
339
+ const target = [0.0, lift / 2.0, -depth / 2.0];
340
+
311
341
  let isPercent = false;
312
342
  let pctVal = 90.0;
313
343
  if (typeof cam.distance === "string") {
@@ -318,24 +348,24 @@ export function compactCamera(scene, viewportAspect) {
318
348
  if (!isNaN(parsed)) pctVal = parsed;
319
349
  }
320
350
  }
321
-
351
+
322
352
  let distance;
323
353
  if (isPercent) {
324
- // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost plate
325
- // (scene.size) so the limiting axis touches P% and the other axis only ever has
326
- // extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
354
+ // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1): fit the frontmost COMPOSITE
355
+ // (width W, height H + lift) so the limiting axis touches P% and the other axis only
356
+ // ever has extra padding (never a crop). distance = max(d_w, d_h). Mirrors geometry.py.
327
357
  const hfov = (cam.fov * Math.PI) / 180.0;
328
358
  const aspect = viewportAspect || scene.size.width / scene.size.height;
329
359
  const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
330
360
  const frac = pctVal / 100.0;
331
361
  const dW = scene.size.width / (2.0 * Math.tan(hfov / 2.0) * frac);
332
- const dH = scene.size.height / (2.0 * Math.tan(vfov / 2.0) * frac);
362
+ const dH = compositeH / (2.0 * Math.tan(vfov / 2.0) * frac);
333
363
  const distToZ0 = Math.max(dW, dH);
334
364
  distance = distToZ0 + depth / 2.0;
335
365
  } else {
336
366
  distance = parseDistance(cam.distance, scene.size.width);
337
367
  }
338
-
368
+
339
369
  const near = Math.max(1.0, distance * 0.005);
340
370
  const position = [target[0], target[1], target[2] + distance];
341
371
  return { position, target, fov: cam.fov, near };
@@ -380,6 +410,8 @@ export function interpolateOpacity(slide, tExpanded) {
380
410
  */
381
411
  export function captionOpacities(scene, tExpanded) {
382
412
  const t = Math.max(0.0, Math.min(1.0, tExpanded));
413
+ // Issue 332: a global captions=false toggle suppresses ALL caption plates everywhere.
414
+ if (!scene.captions) return scene.slides.map(() => 0.0);
383
415
  const cf = scene.caption_fade;
384
416
  const window = cf ? cf.window : CAPTION_FADE_WINDOW;
385
417
  const stagger = cf ? cf.stagger : CAPTION_STAGGER;
package/src/scene.js CHANGED
@@ -113,6 +113,28 @@ function parseTransition(raw) {
113
113
  };
114
114
  }
115
115
 
116
+ // Issue 335 §3 / 336: the `video` section centralizes the VIDEO render params. Mirrors
117
+ // vexy_stax.scene.Video exactly so PY and JS agree. Always present (default below): width/
118
+ // height fall back to scene.size when null; fps falls back to transition.fps (else 30);
119
+ // frames (transition frames PER LEG) falls back to round(transition.duration * fps);
120
+ // first_hold/last_hold (default 10) prepend/append held still frames in the video.
121
+ function parseVideo(raw) {
122
+ if (raw === undefined) {
123
+ return { width: null, height: null, fps: null, frames: null, first_hold: 10, last_hold: 10 };
124
+ }
125
+ const o = asObject(raw, "video");
126
+ rejectExtraKeys(o, new Set(["width", "height", "fps", "frames", "first_hold", "last_hold"]), "video");
127
+ const orNull = (v, where, opts) => (v === undefined || v === null ? null : int(v, where, opts));
128
+ return {
129
+ width: orNull(o.width, "video.width", { min: 1 }),
130
+ height: orNull(o.height, "video.height", { min: 1 }),
131
+ fps: orNull(o.fps, "video.fps", { min: 1 }),
132
+ frames: orNull(o.frames, "video.frames", { min: 1 }),
133
+ first_hold: o.first_hold === undefined ? 10 : int(o.first_hold, "video.first_hold", { min: 0 }),
134
+ last_hold: o.last_hold === undefined ? 10 : int(o.last_hold, "video.last_hold", { min: 0 }),
135
+ };
136
+ }
137
+
116
138
  function parseFloor(raw) {
117
139
  // Smoked glass: ~4% opacity, dark tint, reflective (issue 303 §1).
118
140
  if (raw === undefined) return { color: "#1a1a1a", opacity: 0.04, reflectivity: 0.5 };
@@ -232,6 +254,8 @@ export function parseScene(raw) {
232
254
  "edge",
233
255
  "background",
234
256
  "juicy",
257
+ "captions",
258
+ "video",
235
259
  "caption_defaults",
236
260
  "caption_fade",
237
261
  "slides",
@@ -256,6 +280,10 @@ export function parseScene(raw) {
256
280
  edge: parseEdge(o.edge),
257
281
  background: o.background === undefined ? "#ffffff" : str(o.background, "background"),
258
282
  juicy: o.juicy === undefined ? false : bool(o.juicy, "juicy"),
283
+ // Issue 332: global captions toggle (default true → preserves prior stacked-with-captions
284
+ // behavior). false skips all caption plates and drops slides onto the floor.
285
+ captions: o.captions === undefined ? true : bool(o.captions, "captions"),
286
+ video: parseVideo(o.video),
259
287
  caption_defaults: parseCaptionStyle(o.caption_defaults, "caption_defaults"),
260
288
  caption_fade: parseCaptionFade(o.caption_fade),
261
289
  slides: o.slides.map((s, i) => parseSlide(s, i)),
package/src/stage.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  captionFillColor,
24
24
  captionBorderColor,
25
25
  captionOpacities,
26
+ slideLift,
26
27
  plateEdgeWidth,
27
28
  CAPTION_PLATE_PAD_EM,
28
29
  REFLECTION_BLUR_FRAC,
@@ -56,19 +57,28 @@ export function makeCaptionSprite(text, style) {
56
57
  const fillColor = style?.fillColor ?? edgeColor;
57
58
  const borderColor = style?.borderColor ?? edgeColor;
58
59
 
59
- // Caption font stack (issue 327): the requested family (e.g. REM from Google Fonts) with a
60
- // `system-ui, sans-serif` fallback so an unloaded/unknown family never falls back to the
61
- // canvas default serif (which renders as Times New Roman). Quote the family for safety.
62
- const fontFamily = style?.font ? `"${style.font}", system-ui, sans-serif` : "system-ui, sans-serif";
63
-
60
+ // Caption font (issue 328): the default is "Zalando Sans" the bundled vexy-stax face — an
61
+ // EXPANDED, medium-weight grotesque pulled from Google Fonts (wdth 125 / wght 500). We match
62
+ // that with `500 expanded` + 0.02em tracking. An explicit family is used as given, with a
63
+ // `system-ui, sans-serif` fallback so an unloaded family never resolves to the canvas default
64
+ // serif (Times New Roman — issue 327). Quote the family for safety.
64
65
  const dpr = 2; // render the canvas at 2x for crisper text
65
- const pxFont = `${size * dpr}px ${fontFamily}`;
66
+ const usesDefaultFont = !style?.font;
67
+ const fontFamily = usesDefaultFont
68
+ ? `"Zalando Sans", system-ui, sans-serif`
69
+ : `"${style.font}", system-ui, sans-serif`;
70
+ const weightStretch = usesDefaultFont ? "500 expanded " : "";
71
+ const trackingPx = usesDefaultFont ? 0.02 * size * dpr : 0; // 0.02em → device px
72
+
73
+ const pxFont = `${weightStretch}${size * dpr}px ${fontFamily}`;
66
74
 
67
75
  // Measure the typeset text width (px at 1× = scene points), then the plate world width is
68
76
  // text width + 1.5em pad on EACH side (issue 311). Heights are exact: plate world height.
77
+ // letterSpacing must be set BEFORE measuring so the plate width accounts for the tracking.
69
78
  const measureCanvas = document.createElement("canvas");
70
79
  const mctx = measureCanvas.getContext("2d");
71
80
  mctx.font = pxFont;
81
+ if (trackingPx && "letterSpacing" in mctx) mctx.letterSpacing = `${trackingPx}px`;
72
82
  const textWWorld = mctx.measureText(text).width / dpr; // scene-point text width
73
83
  const worldWidth = textWWorld + 2.0 * CAPTION_PLATE_PAD_EM * size;
74
84
  const worldHeight = plateHeight;
@@ -96,8 +106,9 @@ export function makeCaptionSprite(text, style) {
96
106
  ctx.strokeRect(lwPx / 2, lwPx / 2, canvasW - lwPx, canvasH - lwPx);
97
107
  }
98
108
 
99
- // Caption text, centered both ways, in the caption color.
109
+ // Caption text, centered both ways, in the caption color (same tracking as the measurement).
100
110
  ctx.font = pxFont;
111
+ if (trackingPx && "letterSpacing" in ctx) ctx.letterSpacing = `${trackingPx}px`;
101
112
  ctx.textAlign = "center";
102
113
  ctx.textBaseline = "middle";
103
114
  ctx.fillStyle = color;
@@ -351,30 +362,39 @@ export class Stage {
351
362
  }
352
363
 
353
364
  /**
354
- * Ensure the caption fonts are loaded before the caption canvases are drawn (issue 320).
355
- * Caption text is rasterized to a canvas at build time, so the font must be available
356
- * first or it falls back to a system font. The default font "REM" is pulled from Google
357
- * Fonts (injected once); any other family is left to the host page. Best-effort: in
358
- * non-browser/offline contexts it resolves without blocking (captions fall back).
365
+ * Ensure the default caption font is loaded before the caption canvases are drawn (issue 328).
366
+ * Caption text is rasterized to a canvas at build time, so the font must be available first or
367
+ * it falls back to a system font. The default font is "Zalando Sans" (the bundled vexy-stax
368
+ * face) pulled from Google Fonts at wdth 125 / wght 500; explicit families are left to the host
369
+ * page. Best-effort: in non-browser/offline contexts it resolves without blocking.
359
370
  */
360
371
  async _ensureCaptionFonts() {
361
372
  if (typeof document === "undefined" || !document.fonts) return;
362
- const fonts = new Set();
363
- if (this.scene.caption_defaults?.font) fonts.add(this.scene.caption_defaults.font);
364
- for (const s of this.scene.slides) {
365
- if (s.caption?.style?.font) fonts.add(s.caption.style.font);
366
- }
367
- if (fonts.size === 0) return;
368
- // Load REM (the documented default caption font) from Google Fonts when used. We must wait
369
- // for the stylesheet to actually PARSE before calling document.fonts.load — otherwise the
370
- // REM @font-face rules don't exist yet and load() resolves against the system fallback,
371
- // leaving captions in a serif default (issue 327). So await the <link> onload first.
372
- const usesRem = [...fonts].some((f) => String(f).trim().toLowerCase() === "rem");
373
- if (usesRem && typeof document.getElementById === "function" && !document.getElementById("vexy-rem-font")) {
373
+ // Does any caption fall back to the DEFAULT font (no explicit family anywhere)?
374
+ const defFont = this.scene.caption_defaults?.font;
375
+ const needsDefault = !defFont && this.scene.slides.some((s) => s.caption && !s.caption.style?.font);
376
+ if (!needsDefault) return;
377
+ // Pull "Zalando Sans" (wdth 125 / wght 500) from Google Fonts. We must await the stylesheet
378
+ // PARSE before document.fonts.load otherwise the @font-face rules don't exist yet and
379
+ // load() resolves against the system fallback, leaving captions in a serif default (327).
380
+ if (typeof document.getElementById === "function" && !document.getElementById("vexy-zalando-font")) {
381
+ for (const [id, href, cors] of [
382
+ ["vexy-gf-preconnect", "https://fonts.googleapis.com", false],
383
+ ["vexy-gf-preconnect-static", "https://fonts.gstatic.com", true],
384
+ ]) {
385
+ if (!document.getElementById(id)) {
386
+ const pre = document.createElement("link");
387
+ pre.id = id;
388
+ pre.rel = "preconnect";
389
+ pre.href = href;
390
+ if (cors) pre.crossOrigin = "anonymous";
391
+ document.head?.appendChild(pre);
392
+ }
393
+ }
374
394
  const link = document.createElement("link");
375
- link.id = "vexy-rem-font";
395
+ link.id = "vexy-zalando-font";
376
396
  link.rel = "stylesheet";
377
- link.href = "https://fonts.googleapis.com/css2?family=REM:wght@400;500;700&display=swap";
397
+ link.href = "https://fonts.googleapis.com/css2?family=Zalando+Sans:wdth,wght@125,500&display=swap";
378
398
  const linkLoaded = new Promise((resolve) => {
379
399
  link.onload = resolve;
380
400
  link.onerror = resolve; // offline → fall through to system fallback
@@ -383,7 +403,8 @@ export class Stage {
383
403
  await linkLoaded;
384
404
  }
385
405
  try {
386
- await Promise.all([...fonts].map((f) => document.fonts.load(`32px "${f}"`)));
406
+ // Match the canvas font descriptor (500 / expanded == wdth 125) so the right face preloads.
407
+ await document.fonts.load('500 expanded 32px "Zalando Sans"');
387
408
  if (document.fonts.ready) await document.fonts.ready;
388
409
  } catch {
389
410
  /* offline / unsupported → captions fall back to a system font */
@@ -393,6 +414,7 @@ export class Stage {
393
414
  /** Build a caption sprite under each plate that declares one (best-effort). */
394
415
  _buildCaptions() {
395
416
  if (typeof document === "undefined") return; // no canvas → skip captions
417
+ if (!this.scene.captions) return; // issue 332: global captions toggle off
396
418
  const defaults = this.scene.caption_defaults ?? null;
397
419
  // Caption-plate layout (issue 311): the caption is a small white opaque bordered PLATE.
398
420
  // 1em == captionSize, plate height == captionPlateHeight, border == the slide-plate edge
@@ -501,6 +523,9 @@ export class Stage {
501
523
  const bottomY = -tallest / 2;
502
524
  const floorY = bottomY;
503
525
  const reflectivity = this.scene.floor.reflectivity;
526
+ // Issue 332: lift every slide plate (+ border/reflection) by one caption-plate height so
527
+ // it sits ON TOP of its on-floor caption plate (0 when captions are off → on the floor).
528
+ const lift = slideLift(this.scene);
504
529
 
505
530
  // Cumulative Z: index 0 farthest (most negative), last at 0.
506
531
  // stack_depth = sum(gaps[1:]); place slide i at z = -(stack_depth - cumGapTo(i)).
@@ -511,7 +536,7 @@ export class Stage {
511
536
  this.plates.forEach((plate, i) => {
512
537
  if (i > 0) cum += gaps[i];
513
538
  const z = -(totalDepth - cum);
514
- const y = bottomY + plate.height / 2;
539
+ const y = bottomY + lift + plate.height / 2;
515
540
  const op = opacities[i];
516
541
  plate.mesh.position.set(0, y, z);
517
542
  plate.mesh.material.opacity = op;
@@ -535,12 +560,13 @@ export class Stage {
535
560
  }
536
561
 
537
562
  /**
538
- * Position each caption PLATE (issue 311) so its RIGHT edge is at captionAnchorX (2em left
539
- * of the plates) and its VERTICAL CENTER is at captionPlateCenterY (the plate sits on the
540
- * virtual ground), at the slide plate's current Z (captions recede with their plate). The
541
- * caption mesh is centered geometry of width `worldWidth`, so the mesh CENTER X is
542
- * anchorX worldWidth/2. The whole plate (fill + border + text) fades with the per-frame
543
- * opacity. opacities[plateIndex] == 0 → fully invisible.
563
+ * Position each caption PLATE (issue 311; relayout issue 332) so its LEFT edge is at
564
+ * captionAnchorX (the slide LEFT edge) and its VERTICAL CENTER is at captionPlateCenterY
565
+ * (the plate sits on the floor, the slide stacked on top of it), at the slide plate's
566
+ * current Z (captions recede with their plate). The caption mesh is centered geometry of
567
+ * width `worldWidth`, so the mesh CENTER X is anchorX + worldWidth/2. The whole plate
568
+ * (fill + border + text) fades with the per-frame opacity. opacities[plateIndex] == 0 →
569
+ * fully invisible.
544
570
  * @param {number[]} opacities per-slide opacity list (1:1 with this.plates)
545
571
  */
546
572
  _placeCaptions(opacities) {
@@ -549,10 +575,10 @@ export class Stage {
549
575
  const centerY = captionPlateCenterY(this.scene);
550
576
  this.captions.forEach(({ sprite, material, plateIndex, worldWidth }) => {
551
577
  const plate = this.plates[plateIndex];
552
- // Right edge at anchorX → mesh center at anchorX width/2; vertical center at centerY;
553
- // plate Z (captions recede with their plate in expanded view).
578
+ // Issue 332: LEFT edge at anchorX (the slide left edge) → mesh center at anchorX + w/2;
579
+ // vertical center at centerY (on the floor); plate Z (captions recede with their plate).
554
580
  const w = worldWidth ?? sprite.scale?.x ?? 0;
555
- sprite.position.set(anchorX - w / 2, centerY, plate.mesh.position.z);
581
+ sprite.position.set(anchorX + w / 2, centerY, plate.mesh.position.z);
556
582
  const op = opacities[plateIndex] ?? 0;
557
583
  material.opacity = op;
558
584
  sprite.visible = op > 0.001;