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 +91 -0
- package/package.json +4 -2
- package/schema/vexy-stax-scene.schema.json +1 -0
- package/src/export.js +120 -52
- package/src/geometry.js +47 -15
- package/src/scene.js +28 -0
- package/src/stage.js +63 -37
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.
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 `
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
56
|
-
* @param {(onFrame:(state:object)=>void)=>Promise<void>} opts.run
|
|
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]
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
88
|
-
*
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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,
|
|
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 [
|
|
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
|
-
|
|
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
|
|
325
|
-
// (
|
|
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 =
|
|
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
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
|
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
|
|
355
|
-
* Caption text is rasterized to a canvas at build time, so the font must be available
|
|
356
|
-
*
|
|
357
|
-
* Fonts
|
|
358
|
-
* non-browser/offline contexts it resolves without blocking
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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-
|
|
395
|
+
link.id = "vexy-zalando-font";
|
|
376
396
|
link.rel = "stylesheet";
|
|
377
|
-
link.href = "https://fonts.googleapis.com/css2?family=
|
|
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
|
-
|
|
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
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
* opacity. opacities[plateIndex] == 0 →
|
|
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
|
-
//
|
|
553
|
-
// plate Z (captions recede with their plate
|
|
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
|
|
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;
|