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 +113 -0
- package/package.json +4 -2
- package/schema/vexy-stax-scene.schema.json +14 -0
- package/src/export.js +120 -52
- package/src/geometry.js +48 -14
- package/src/index.js +22 -1
- package/src/scene.js +28 -0
- package/src/stage.js +63 -37
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.
|
|
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
|
|
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,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
|
-
|
|
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
|
|
325
|
-
// (
|
|
326
|
-
// extra padding (never a crop). distance = max(d_w, d_h).
|
|
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
|
|
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;
|