vexy-stax-js 3.0.1 → 3.1.1

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,119 @@
4
4
 
5
5
  All notable changes to this project are documented here.
6
6
 
7
+ ## [3.0.11] — issues 335, 336, 337
8
+
9
+ ### Added
10
+
11
+ - **`video` scene section** (335 / 336): `scene.js` `parseVideo()` + the JSON schema now accept and
12
+ validate the `video` section (`width`/`height`/`fps`/`frames`/`first_hold`/`last_hold`), mirroring
13
+ `vexy_stax.scene.Video`. Previously the strict parser rejected the key with "Unknown key 'video' in
14
+ scene", which broke the Python `playwright` engine (it mounts the scene in `<vexy-stax>`) — issue
15
+ 336. The element now accepts a scene carrying `video`.
16
+ - **Held first/last still frames in `toVideo()`** (335 §2): the video export now bookends the clip
17
+ with held stills — it renders the start endpoint and captures it `video.first_hold` times (default
18
+ 10), plays the transition, then captures the end endpoint `video.last_hold` times (still →
19
+ transition → still). Mirrors `geometry.py`'s `frame_plan` holds. Verified: the exported
20
+ `airbl-transition.mp4` first/last frames are static.
21
+
22
+ ### Fixed
23
+
24
+ - **Compact view reserved empty caption space** (337): `compactCamera` now fits ONLY the frontmost
25
+ slide plate (height `H`, aimed at the slide center `Y = lift`) instead of the slide+caption
26
+ composite, so the compact view fills the frame with no caption padding. Mirrors `geometry.py`
27
+ (issue 337). Verified: the playwright-rendered compact still fills the frame; geometry test updated.
28
+
29
+ ## [3.0.10] — issues 331
30
+
31
+ ### Fixed
32
+
33
+ - **Seekable mp4 video export** (331): `src/export.js` `recordVideo()` now uses a
34
+ deterministic WebCodecs + `mp4-muxer` primary path instead of the broken
35
+ `captureStream`/MediaRecorder approach. The new `recordViaMuxer()` function:
36
+ - Checks H.264 (`avc1.640028`) support via `VideoEncoder.isConfigSupported()`; falls
37
+ back to VP9 (`vp09.00.10.08`) if H.264 is unavailable.
38
+ - Feeds each `VideoFrame(canvas, {timestamp, duration})` into a `VideoEncoder` whose
39
+ output chunks are piped directly to a `mp4-muxer` `Muxer` with
40
+ `fastStart: "in-memory"` and an `ArrayBufferTarget`.
41
+ - Calls `muxer.finalize()` after `encoder.flush()`, producing a `Blob([target.buffer],
42
+ {type:"video/mp4"})` with correct per-stream `duration` and `nb_frames` metadata —
43
+ verified seekable by ffprobe.
44
+ - `MediaRecorder` (live `captureStream`) is retained as a last-resort fallback for
45
+ environments entirely without `VideoEncoder`.
46
+ - **`verify/example.mjs` extension logic** (331): the `ext` variable already derived the
47
+ extension from `blob.type` (`mp4` vs `webm`), so the primary path now writes
48
+ `airbl-transition.mp4` automatically.
49
+
50
+ ### Added
51
+
52
+ - **Deployable `docs/` site for GitHub Pages** (331 part 2):
53
+ - `scripts/build-docs.mjs` copies the built `dist/` bundles (element + global + source
54
+ maps), the `airbl-lores` scene JSON + slide PNGs, writes a self-contained
55
+ `docs/index.html` landing page with a playable `<vexy-stax>` demo and usage snippets
56
+ for all three entry points (Web Component, ESM import, global script), and a short
57
+ `docs/README.md`.
58
+ - `package.json` gains a `build:docs` script (`node scripts/build-docs.mjs`).
59
+ - `build.sh` calls `npm run build:docs` after `npm run build`, so the docs site is
60
+ regenerated on every full build.
61
+ - Base path is `/vexy-stax-js/` (GitHub Pages subdirectory), set via a `<base>` tag
62
+ in `index.html`.
63
+
64
+ ## [3.0.9] — issue 332
65
+
66
+ ### Added
67
+
68
+ - **Global `captions` on/off toggle** (332): a new top-level boolean scene field (default `true`,
69
+ preserving prior behavior) parsed + validated in `src/scene.js` and
70
+ `schema/vexy-stax-scene.schema.json` (strict — a non-bool throws). When `false`, no caption plates
71
+ are built (`stage.js`) and `captionOpacities` returns all-zero, and the slide plates drop directly
72
+ onto the floor.
73
+
74
+ ### Changed
75
+
76
+ - **New stacked caption layout** (332): when captions are ON, each caption plate sits RIGHT ON the
77
+ floor (bottom edge on the floor line) and its slide plate sits directly ON TOP of it, LEFT-aligned
78
+ with the slide (caption left edge == slide left edge at `X = -width/2`). This replaces the previous
79
+ "caption to the LEFT of the plate" layout. Mirrors `vexy-stax-py` exactly.
80
+ - `geometry.js`: added `slideLift(scene)` (one caption-plate height when captions on, else 0); every
81
+ slide plate is lifted by it in `stage.js`. `captionAnchorX` now means the caption plate's LEFT edge
82
+ (numerically unchanged since `CAPTION_GAP_EM == 0`). `captionOpacities` returns all-zero when
83
+ `captions` is off.
84
+ - **Crop-free camera framing for the full composite**: `compactCamera` fits the frontmost COMPOSITE
85
+ (width `W`, height `H + lift`) and aims at its center (`Y = lift/2`); `expandedCamera` includes the
86
+ lifted slide corners AND the on-floor caption-plate bottom row in its bounding fit, so the
87
+ caption+slide stack is framed with no crop.
88
+ - `stage.js`: lifts plates/borders/reflections by `slideLift`, anchors each caption plate by its LEFT
89
+ edge on the floor, and skips caption plates entirely when the toggle is off.
90
+
91
+ ## [3.0.8] — issues 328, 329, 330
92
+
93
+ ### Changed
94
+
95
+ - **Static Zalando Sans `<link>` in the generated demos** (328): `verify/example.mjs` now emits the
96
+ Google Fonts preconnect + `Zalando+Sans:wdth,wght@125,500` stylesheet directly in the `<head>` of
97
+ both `playable.html` and `scrollable.html`, so the caption face is available before first paint.
98
+ (The element's runtime `_ensureCaptionFonts` injection from 3.0.7 still awaits `document.fonts`,
99
+ so this just removes the first-frame fallback flash.)
100
+
101
+ ### Verified (no code change needed)
102
+
103
+ - **`scrollable.html` reflects current src** (329): the demo HTML is regenerated from the live
104
+ `src/` on every `example.sh` / `example.mjs` run (and now also when the Python `example.py`
105
+ rebuilds the JS demos — issue 330), so there is no stale checked-in copy to "port" changes into.
106
+ - **`outputs/` cleanup** (330): `example.sh` already `rm -rf outputs` before regenerating, so stale
107
+ artifacts are removed on every rebuild.
108
+
109
+ ## [3.0.7] — issue 328
110
+
111
+ ### Changed
112
+
113
+ - **Default caption font → "Zalando Sans"** (328): the default caption font is now "Zalando Sans"
114
+ pulled from Google Fonts at wdth 125 / wght 500 (matching the bundled Python `vexy-stax.ttf` =
115
+ Zalando Sans Expanded). `makeCaptionSprite` renders the default at `500 expanded` with 0.02em
116
+ tracking (an explicit family is still used plainly, with a `system-ui, sans-serif` fallback).
117
+ `_ensureCaptionFonts` injects the Google Fonts preconnect links + the Zalando Sans stylesheet
118
+ and preloads the face by default whenever a caption uses the default font.
119
+
7
120
  ## [3.0.6] — issue 327
8
121
 
9
122
  ### 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.1.1",
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,20 @@
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." },
73
+ "video": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "description": "Video render params (issue 335 §3): dimensions/fps/frame counts/held stills. transition still owns the animation (kind/easing/wait/duration); video owns the output framing. Defaults preserve prior behavior.",
77
+ "properties": {
78
+ "width": { "type": "integer", "minimum": 1, "description": "Video width; omitted ⇒ size.width." },
79
+ "height": { "type": "integer", "minimum": 1, "description": "Video height; omitted ⇒ size.height." },
80
+ "fps": { "type": "integer", "minimum": 1, "description": "Video frames per second; omitted ⇒ transition.fps (else 30). Overrides transition.fps when set." },
81
+ "frames": { "type": "integer", "minimum": 1, "description": "Transition frames PER LEG; omitted ⇒ round(transition.duration × fps)." },
82
+ "first_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the FIRST frame (still intro)." },
83
+ "last_hold": { "type": "integer", "minimum": 0, "default": 10, "description": "Held copies of the LAST frame (still outro)." }
84
+ }
85
+ },
72
86
  "caption_defaults": { "$ref": "#/$defs/captionStyle" },
73
87
  "caption_fade": {
74
88
  "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,15 @@ 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 337: the compact view frames ONLY the frontmost SLIDE plate — not the composite with
334
+ // its caption row. Captions are invisible in compact (they fade in only as the deck expands),
335
+ // so reserving the caption-plate height just padded the frame. The slide is lifted by `lift`
336
+ // (issue 332: it sits on top of the on-floor caption plate), so its center is at Y = lift and
337
+ // it spans height H. Aim at the slide center and fit H so the slide fills the frame tight.
338
+ // Mirrors geometry.py.
339
+ const lift = slideLift(scene);
340
+ const target = [0.0, lift, -depth / 2.0];
341
+
311
342
  let isPercent = false;
312
343
  let pctVal = 90.0;
313
344
  if (typeof cam.distance === "string") {
@@ -318,12 +349,13 @@ export function compactCamera(scene, viewportAspect) {
318
349
  if (!isNaN(parsed)) pctVal = parsed;
319
350
  }
320
351
  }
321
-
352
+
322
353
  let distance;
323
354
  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.
355
+ // Dual-axis crop-free fit (SPEC.md §3, issue 302 §1, issue 337): fit the frontmost SLIDE
356
+ // plate (width W, height H — NOT the caption composite) so the limiting axis touches P% and
357
+ // the other axis only ever has extra padding (never a crop). distance = max(d_w, d_h).
358
+ // Mirrors geometry.py.
327
359
  const hfov = (cam.fov * Math.PI) / 180.0;
328
360
  const aspect = viewportAspect || scene.size.width / scene.size.height;
329
361
  const vfov = 2.0 * Math.atan(Math.tan(hfov / 2.0) / aspect);
@@ -335,7 +367,7 @@ export function compactCamera(scene, viewportAspect) {
335
367
  } else {
336
368
  distance = parseDistance(cam.distance, scene.size.width);
337
369
  }
338
-
370
+
339
371
  const near = Math.max(1.0, distance * 0.005);
340
372
  const position = [target[0], target[1], target[2] + distance];
341
373
  return { position, target, fov: cam.fov, near };
@@ -380,6 +412,8 @@ export function interpolateOpacity(slide, tExpanded) {
380
412
  */
381
413
  export function captionOpacities(scene, tExpanded) {
382
414
  const t = Math.max(0.0, Math.min(1.0, tExpanded));
415
+ // Issue 332: a global captions=false toggle suppresses ALL caption plates everywhere.
416
+ if (!scene.captions) return scene.slides.map(() => 0.0);
383
417
  const cf = scene.caption_fade;
384
418
  const window = cf ? cf.window : CAPTION_FADE_WINDOW;
385
419
  const stagger = cf ? cf.stagger : CAPTION_STAGGER;
package/src/index.js CHANGED
@@ -205,13 +205,28 @@ export class VexyStax {
205
205
  await this._ready;
206
206
  const kind = opts.kind ?? this.scene.transition?.kind;
207
207
  if (!kind) throw new Error("VexyStax.toVideo: no kind given and scene.transition is null");
208
- const fps = this.scene.transition?.fps ?? 30;
208
+ const fps = this.scene.video?.fps ?? this.scene.transition?.fps ?? 30;
209
209
  const canvas = this.stage.renderer.domElement;
210
+ // Issue 335 §2: bookend the clip with HELD STILLS — render the start frame and capture it
211
+ // `first_hold` times, then the transition, then capture the end frame `last_hold` times
212
+ // (still → transition → still). Defaults 10/10 from scene.video. Mirrors geometry.py's
213
+ // frame_plan holds (the Python engines get holds via frame_plan; here toVideo drives a
214
+ // real-time capture, so we hold by capturing the boundary frames repeatedly).
215
+ const firstHold = this.scene.video?.first_hold ?? 10;
216
+ const lastHold = this.scene.video?.last_hold ?? 10;
217
+ const { startMorph, endMorph } = transitionEndpoints(kind);
218
+ const aspect = this.stage.camera.aspect;
210
219
 
211
220
  return recordVideo({
212
221
  canvas,
213
222
  fps,
214
223
  run: async (onFrame) => {
224
+ // Held still intro: snap to the start endpoint and capture it `firstHold` times.
225
+ const startState = frameStateAt(this.scene, startMorph, aspect);
226
+ this.stage.applyFrameState(startState, startMorph);
227
+ this.stage.render();
228
+ for (let i = 0; i < firstHold; i++) onFrame(startState);
229
+
215
230
  const controller = playTransition(this.scene, (state) => {
216
231
  const t = this._morphFromGaps(state.gaps);
217
232
  this.stage.applyFrameState(state, t);
@@ -219,6 +234,12 @@ export class VexyStax {
219
234
  onFrame(state);
220
235
  }, { kind });
221
236
  await controller.promise;
237
+
238
+ // Held still outro: snap to the end endpoint and capture it `lastHold` times.
239
+ const endState = frameStateAt(this.scene, endMorph, aspect);
240
+ this.stage.applyFrameState(endState, endMorph);
241
+ this.stage.render();
242
+ for (let i = 0; i < lastHold; i++) onFrame(endState);
222
243
  },
223
244
  });
224
245
  }
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;