vexy-stax-js 3.0.0 → 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 +200 -0
- package/package.json +4 -2
- package/schema/vexy-stax-scene.schema.json +44 -6
- package/src/element.js +3 -0
- package/src/export.js +120 -52
- package/src/geometry.js +306 -24
- package/src/index.js +45 -4
- package/src/scene.js +69 -6
- package/src/stage.js +385 -79
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,206 @@
|
|
|
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
|
+
|
|
98
|
+
## [3.0.6] — issue 327
|
|
99
|
+
|
|
100
|
+
### Fixed
|
|
101
|
+
|
|
102
|
+
- **Caption font fell back to serif (Times New Roman) in the playwright render**
|
|
103
|
+
(327.1): `makeCaptionSprite` now builds the canvas font as
|
|
104
|
+
`"<family>", system-ui, sans-serif`, so an unloaded/unknown family never falls
|
|
105
|
+
back to the canvas default serif. `_ensureCaptionFonts` now awaits the Google
|
|
106
|
+
Fonts `<link>` `onload` BEFORE calling `document.fonts.load`, so REM's
|
|
107
|
+
`@font-face` rules exist when the load is requested (previously a race resolved
|
|
108
|
+
the load against the system fallback, leaving captions in a serif font).
|
|
109
|
+
|
|
110
|
+
## [3.0.5] — issue 326
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- **Plate + caption borders OFF by default** (326): `parseEdge` width default
|
|
115
|
+
`0.004 → 0.0` (and schema default). A border (around both the slide plates and
|
|
116
|
+
the caption plates, which share `edge.width`) now draws only when `width > 0`.
|
|
117
|
+
Border color default is unchanged and applies only when a border is enabled.
|
|
118
|
+
|
|
119
|
+
## [3.0.4] — issues 324–325
|
|
120
|
+
|
|
121
|
+
### Changed
|
|
122
|
+
|
|
123
|
+
- **Caption + border colors default `#f2f2f2`, each overridable** (324):
|
|
124
|
+
`scene.edge.color` default `#cccccc → #f2f2f2` (the slide-plate border and, by
|
|
125
|
+
default, the caption-plate fill + border). New `caption_defaults.fill_color` and
|
|
126
|
+
`caption_defaults.border_color` (each defaulting to `scene.edge.color`) make the
|
|
127
|
+
caption plate fill and border independently overridable; `caption_defaults.color`
|
|
128
|
+
still sets the caption text color. New `geometry.captionFillColor()` /
|
|
129
|
+
`captionBorderColor()` helpers; `makeCaptionSprite` now takes separate
|
|
130
|
+
`fillColor` / `borderColor` (each falling back to `edgeColor`) and the stage
|
|
131
|
+
passes the resolved colors. Schema gained the `edge.color` default and
|
|
132
|
+
`captionStyle.fill_color` / `border_color`.
|
|
133
|
+
- **Caption font 1/3 larger by default** (324): `CAPTION_PLATE_HEIGHT_FRAC`
|
|
134
|
+
`0.10 → 0.10·4/3 ≈ 0.1333`, so `CAPTION_DEFAULT_SIZE_FRAC` `0.075 → 0.10` of the
|
|
135
|
+
scene height.
|
|
136
|
+
|
|
137
|
+
### Fixed
|
|
138
|
+
|
|
139
|
+
- **Blender compact view rendered black / transition translucent** (325):
|
|
140
|
+
fixed in the Python `blender` engine (Cycles `transparent_max_bounces` was
|
|
141
|
+
exhausted by the dense head-on plate stack). No changes to the browser package;
|
|
142
|
+
the JS/three.js engine was already correct (it draws single planes with
|
|
143
|
+
`depthWrite:false` + explicit render orders).
|
|
144
|
+
|
|
145
|
+
## [3.0.3] — issues 321–323
|
|
146
|
+
|
|
147
|
+
### Changed
|
|
148
|
+
|
|
149
|
+
- **Caption plate touches slide plate** (321/323): `CAPTION_GAP_EM` reduced from
|
|
150
|
+
`2.0` to `0.0` in `geometry.js` so the caption plate right edge aligns exactly
|
|
151
|
+
with the slide plate left edge — no visual gap.
|
|
152
|
+
|
|
153
|
+
## [3.0.2] — issue 320
|
|
154
|
+
|
|
155
|
+
### Changed
|
|
156
|
+
|
|
157
|
+
- **Caption plate fill = border color** (320.7): caption plate background is now
|
|
158
|
+
`edgeColor` (= `scene.edge.color`, default `#cccccc`) instead of white, so the
|
|
159
|
+
caption plate matches the slide-plate border.
|
|
160
|
+
- **Plate/reflection `depthWrite: false`** (320.9): THREE.js plate and reflection
|
|
161
|
+
materials set `depthWrite: false` to eliminate z-fighting flicker at the bottom
|
|
162
|
+
of each slide during Playwright-recorded transitions.
|
|
163
|
+
- **JS outputs use py testdata** (320.5–6): `verify/example.mjs`, harness HTML
|
|
164
|
+
files, and `verify/server.mjs` now read scene + slides from
|
|
165
|
+
`vexy-stax-py/testdata/` instead of the removed `vexy-stax-js/testdata/`.
|
|
166
|
+
- **Testdata removed** (320.6): `vexy-stax-js/testdata/` directory deleted; the
|
|
167
|
+
shared source of truth is `vexy-stax-py/testdata/`.
|
|
168
|
+
|
|
169
|
+
## [3.0.1] — issues 303–318
|
|
170
|
+
|
|
171
|
+
### Added
|
|
172
|
+
|
|
173
|
+
- **Smoked-glass floor + blurry reflections** (303 §1): floor defaults to ~4%
|
|
174
|
+
smoked glass; the mirror reflection texture is Gaussian-blurred (`ctx.filter`)
|
|
175
|
+
so it reads soft, not crisp.
|
|
176
|
+
- **Plate edge border** (305): `Edge` scene model (`width` fraction of plate
|
|
177
|
+
height, `color`), default-on; 4 thin perimeter quads per plate in `stage.js`.
|
|
178
|
+
- **Caption plates** (311, typography revised by 315): captions render as small
|
|
179
|
+
**white opaque bordered plates** (same edge as the slide plates), text centered;
|
|
180
|
+
plate height = 10% of plate height, text = 75% of that (→ 7.5%), width = text +
|
|
181
|
+
0.75em padding each side.
|
|
182
|
+
- **`seek(t)`** on `VexyStax` + `<vexy-stax>`: apply an arbitrary morph factor
|
|
183
|
+
(0 compact → 1 expanded). `scrollspy({map})` accepts a custom progress→morph map.
|
|
184
|
+
`compactCamera`/`expandedCamera` accept an optional viewport aspect (issue 314).
|
|
185
|
+
- **`outputs/scrollable.html`** (304.2 + 314): a **full-width 2:1 white** scene
|
|
186
|
+
(no rounding/box-shadow) between intro and outro copy. Compact = plate centered
|
|
187
|
+
with side padding; it morphs to expanded once **80% of the scene is visible**
|
|
188
|
+
scrolling in, then **latches** expanded (further scrolling does not collapse it).
|
|
189
|
+
|
|
190
|
+
### Fixed
|
|
191
|
+
|
|
192
|
+
- **playable.html scale at HiDPI** (304.1): `renderer.setSize(w, h)` (updateStyle
|
|
193
|
+
default) so the canvas displays at its CSS size, not the 2× backing size.
|
|
194
|
+
- **npm publish version conflict** (318): bumped package version to `3.0.1` since
|
|
195
|
+
`3.0.0` was already published on npm.
|
|
196
|
+
|
|
197
|
+
### Changed
|
|
198
|
+
|
|
199
|
+
- Default `camera.distance` is `"100%"` (compact fit-tight); default caption size
|
|
200
|
+
is 7.5% of plate height (75% of the caption-plate height — issues 311/315, supersede
|
|
201
|
+
308's 5%); `caption_fade.stagger_frames` for frame-based stagger.
|
|
202
|
+
|
|
203
|
+
### Removed
|
|
204
|
+
|
|
205
|
+
- **Floor shadows** (312): the short pale plate shadows on the floor were eliminated.
|
|
206
|
+
|
|
7
207
|
## [Unreleased]
|
|
8
208
|
|
|
9
209
|
### Added
|
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": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"distance": {
|
|
31
31
|
"description": "Camera distance: absolute points as a number/string, or viewport-fit percentage like \"90%\".",
|
|
32
32
|
"anyOf": [{ "type": "number" }, { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?%?$" }],
|
|
33
|
-
"default": "
|
|
33
|
+
"default": "100%"
|
|
34
34
|
},
|
|
35
35
|
"angle": { "type": "number", "default": 60, "description": "Azimuth degrees for expanded view." },
|
|
36
36
|
"elevation": { "type": "number", "default": 0, "description": "Degrees above horizon for expanded view." },
|
|
@@ -53,14 +53,34 @@
|
|
|
53
53
|
"type": "object",
|
|
54
54
|
"additionalProperties": false,
|
|
55
55
|
"properties": {
|
|
56
|
-
"color": { "type": "string", "default": "#
|
|
57
|
-
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default":
|
|
56
|
+
"color": { "type": "string", "default": "#1a1a1a" },
|
|
57
|
+
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.04, "description": "Smoked glass — ~4% (issue 303)." },
|
|
58
58
|
"reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
|
+
"edge": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"additionalProperties": false,
|
|
64
|
+
"description": "Visible plate border (issue 305), default-on.",
|
|
65
|
+
"properties": {
|
|
66
|
+
"width": { "type": "number", "minimum": 0, "default": 0.0, "description": "Border thickness as a fraction of plate height; 0 = off (issue 326: slide + caption borders off by default)." },
|
|
67
|
+
"color": { "type": "string", "default": "#f2f2f2", "description": "Slide-plate border color; also the default caption plate fill + border (issue 324)." }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
61
70
|
"background": { "type": "string", "default": "#ffffff" },
|
|
62
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." },
|
|
63
73
|
"caption_defaults": { "$ref": "#/$defs/captionStyle" },
|
|
74
|
+
"caption_fade": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"description": "Caption fade-in timing during a transition (issue 302 §B.4 / 309).",
|
|
78
|
+
"properties": {
|
|
79
|
+
"window": { "type": "number", "exclusiveMinimum": 0, "maximum": 1, "default": 0.9, "description": "Fraction of the morph (from the end) over which captions fade in." },
|
|
80
|
+
"stagger": { "type": "number", "minimum": 0, "exclusiveMaximum": 1, "default": 0.3, "description": "Back→front succession spread as a fraction of the fade window." },
|
|
81
|
+
"stagger_frames": { "type": "integer", "minimum": 0, "description": "Issue 309: back→front per-caption step in transition frames; overrides stagger when set." }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
64
84
|
"slides": {
|
|
65
85
|
"type": "array",
|
|
66
86
|
"minItems": 1,
|
|
@@ -113,9 +133,27 @@
|
|
|
113
133
|
"type": "object",
|
|
114
134
|
"additionalProperties": false,
|
|
115
135
|
"properties": {
|
|
116
|
-
"size": {
|
|
117
|
-
|
|
118
|
-
|
|
136
|
+
"size": {
|
|
137
|
+
"type": "number",
|
|
138
|
+
"exclusiveMinimum": 0,
|
|
139
|
+
"description": "Font size for the caption text (1em) in scene-point units."
|
|
140
|
+
},
|
|
141
|
+
"color": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "Color for the caption text (e.g., hex string like '#222222')."
|
|
144
|
+
},
|
|
145
|
+
"font": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Font family name (e.g., 'sans-serif', 'Arial') or a path to a TrueType/OpenType font file."
|
|
148
|
+
},
|
|
149
|
+
"fill_color": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Caption plate FILL color (issue 324); defaults to scene.edge.color when omitted."
|
|
152
|
+
},
|
|
153
|
+
"border_color": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "Caption plate BORDER color (issue 324); defaults to scene.edge.color when omitted."
|
|
156
|
+
}
|
|
119
157
|
}
|
|
120
158
|
}
|
|
121
159
|
}
|
package/src/element.js
CHANGED
|
@@ -124,6 +124,9 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
124
124
|
scrollspy(opts) {
|
|
125
125
|
return this._stax?.scrollspy(opts);
|
|
126
126
|
}
|
|
127
|
+
seek(t) {
|
|
128
|
+
return this._stax?.seek(t);
|
|
129
|
+
}
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
|
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
|
-
}
|