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 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.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": "90%"
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": "#f2f2f2" },
57
- "opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 1.0 },
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": { "type": "number", "exclusiveMinimum": 0 },
117
- "color": { "type": "string" },
118
- "font": { "type": "string" }
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 — WebCodecs
6
- // (VideoEncoder + muxed via captureStream/MediaRecorder when available) with a
7
- // MediaRecorder fallback for browsers without WebCodecs. Both paths produce a
8
- // Blob the caller can download.
5
+ // to a PNG Blob. Video: capture the deck transition to an encoded, seekable clip.
6
+ //
7
+ // PRIMARY path (issue 331): WebCodecs (VideoEncoder) + mp4-muxer → H.264/mp4.
8
+ // Produces a fully seekable mp4 with correct duration + per-stream frame metadata.
9
+ // Falls back to webm-muxer+VP9 if H.264 is unsupported, then finally to
10
+ // MediaRecorder (live captureStream) as the last-resort path for environments
11
+ // that lack VideoEncoder entirely.
12
+
13
+ import { Muxer, ArrayBufferTarget } from "mp4-muxer";
9
14
 
10
15
  /**
11
16
  * Read a canvas to a PNG Blob.
@@ -43,36 +48,66 @@ function pickMimeType() {
43
48
  return candidates.find((t) => MediaRecorder.isTypeSupported(t));
44
49
  }
45
50
 
51
+ /**
52
+ * Check whether a given VideoEncoder codec string is supported.
53
+ * @param {string} codec
54
+ * @param {number} width
55
+ * @param {number} height
56
+ * @param {number} fps
57
+ * @returns {Promise<boolean>}
58
+ */
59
+ async function isCodecSupported(codec, width, height, fps) {
60
+ if (typeof VideoEncoder === "undefined") return false;
61
+ try {
62
+ const { supported } = await VideoEncoder.isConfigSupported({
63
+ codec,
64
+ width,
65
+ height,
66
+ framerate: fps,
67
+ });
68
+ return !!supported;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
46
74
  /**
47
75
  * Record a transition to a video Blob.
48
76
  *
49
- * Drives `playFrames(applyOneFrame)`: the caller renders each frame onto the
50
- * canvas synchronously inside the per-frame callback, and we capture the canvas
51
- * stream while it plays. WebCodecs is preferred when present (lower latency, mp4
52
- * where supported); otherwise MediaRecorder captures the live canvas stream.
77
+ * Drives `run(onFrame)`: the caller renders each frame onto the canvas
78
+ * synchronously inside the per-frame callback. The PRIMARY path uses WebCodecs
79
+ * (VideoEncoder) + mp4-muxer to produce a seekable mp4 with correct duration
80
+ * and per-stream frame count metadata. Falls back to MediaRecorder only when
81
+ * VideoEncoder is unavailable.
53
82
  *
54
83
  * @param {object} opts
55
- * @param {HTMLCanvasElement} opts.canvas the renderer canvas to capture
56
- * @param {(onFrame:(state:object)=>void)=>Promise<void>} opts.run plays the
84
+ * @param {HTMLCanvasElement} opts.canvas the renderer canvas to capture
85
+ * @param {(onFrame:(state:object)=>void)=>Promise<void>} opts.run plays the
57
86
  * transition, calling onFrame for each frame (which must render to the canvas)
58
- * @param {number} [opts.fps] frame rate for the captured stream
87
+ * @param {number} [opts.fps] frame rate for the encoded clip (default 30)
59
88
  * @returns {Promise<Blob>}
60
89
  */
61
90
  export async function recordVideo({ canvas, run, fps = 30 }) {
62
91
  if (!canvas) throw new Error("recordVideo: canvas is required");
63
92
  if (typeof run !== "function") throw new Error("recordVideo: run() callback is required");
64
93
 
65
- if (typeof VideoEncoder !== "undefined" && typeof canvas.captureStream === "function") {
66
- // WebCodecs path: still capture via MediaRecorder on the encoded stream when
67
- // available, since muxing raw VideoEncoder chunks into a container is heavy.
68
- // We treat presence of MediaRecorder as the muxer; if it's missing we fall
69
- // through to the explicit WebCodecs-only encoder below.
70
- if (hasMediaRecorder()) {
71
- return recordViaMediaRecorder({ canvas, run, fps });
94
+ // PRIMARY: WebCodecs + mp4-muxer (issue 331) — prefer H.264/mp4; fall back to
95
+ // VP9/webm inside the same muxed path if H.264 is unsupported.
96
+ if (typeof VideoEncoder !== "undefined") {
97
+ const w = canvas.width;
98
+ const h = canvas.height;
99
+ const avcCodec = "avc1.640028"; // H.264 High Profile Level 4.0
100
+ const vp9Codec = "vp09.00.10.08";
101
+
102
+ const useAvc = await isCodecSupported(avcCodec, w, h, fps);
103
+ const useVp9 = !useAvc && (await isCodecSupported(vp9Codec, w, h, fps));
104
+
105
+ if (useAvc || useVp9) {
106
+ return recordViaMuxer({ canvas, run, fps, useAvc });
72
107
  }
73
- return recordViaWebCodecs({ canvas, run, fps });
74
108
  }
75
109
 
110
+ // FALLBACK: MediaRecorder (captureStream) — no mux metadata, non-seekable.
76
111
  if (hasMediaRecorder()) {
77
112
  return recordViaMediaRecorder({ canvas, run, fps });
78
113
  }
@@ -82,6 +117,72 @@ export async function recordVideo({ canvas, run, fps = 30 }) {
82
117
  );
83
118
  }
84
119
 
120
+ /**
121
+ * PRIMARY recording path: VideoEncoder frames piped into mp4-muxer (H.264) or
122
+ * webm-muxer (VP9). Produces a properly seekable container with real duration +
123
+ * per-stream nb_frames metadata.
124
+ *
125
+ * @param {object} opts
126
+ * @param {HTMLCanvasElement} opts.canvas
127
+ * @param {(onFrame:()=>void)=>Promise<void>} opts.run
128
+ * @param {number} opts.fps
129
+ * @param {boolean} opts.useAvc true→H.264+mp4, false→VP9+webm via mp4-muxer
130
+ */
131
+ async function recordViaMuxer({ canvas, run, fps, useAvc }) {
132
+ const w = canvas.width;
133
+ const h = canvas.height;
134
+ const codec = useAvc ? "avc1.640028" : "vp09.00.10.08";
135
+
136
+ const target = new ArrayBufferTarget();
137
+ const muxer = new Muxer({
138
+ target,
139
+ video: {
140
+ codec: useAvc ? "avc" : "vp9",
141
+ width: w,
142
+ height: h,
143
+ },
144
+ // fastStart embeds the moov atom at the front for immediate seeking in players.
145
+ fastStart: "in-memory",
146
+ });
147
+
148
+ const chunks = [];
149
+ const encoder = new VideoEncoder({
150
+ output: (chunk, meta) => {
151
+ muxer.addVideoChunk(chunk, meta);
152
+ },
153
+ error: (e) => {
154
+ throw e;
155
+ },
156
+ });
157
+
158
+ encoder.configure({
159
+ codec,
160
+ width: w,
161
+ height: h,
162
+ framerate: fps,
163
+ // H.264: signal avc1 bitstream (Annex-B not needed for mp4-muxer).
164
+ ...(useAvc ? { avc: { format: "avc" } } : {}),
165
+ });
166
+
167
+ let frameIndex = 0;
168
+ const frameDuration = Math.round(1e6 / fps); // microseconds per frame
169
+
170
+ await run(() => {
171
+ const timestamp = frameIndex * frameDuration;
172
+ const frame = new VideoFrame(canvas, { timestamp, duration: frameDuration });
173
+ encoder.encode(frame, { keyFrame: frameIndex % fps === 0 });
174
+ frame.close();
175
+ frameIndex += 1;
176
+ });
177
+
178
+ await encoder.flush();
179
+ encoder.close();
180
+ muxer.finalize();
181
+
182
+ const mimeType = useAvc ? "video/mp4" : "video/webm";
183
+ return new Blob([target.buffer], { type: mimeType });
184
+ }
185
+
85
186
  async function recordViaMediaRecorder({ canvas, run, fps }) {
86
187
  const stream = canvas.captureStream(fps);
87
188
  const mimeType = pickMimeType();
@@ -108,36 +209,3 @@ async function recordViaMediaRecorder({ canvas, run, fps }) {
108
209
  stream.getTracks?.().forEach((t) => t.stop());
109
210
  return new Blob(chunks, { type: recorder.mimeType || mimeType || "video/webm" });
110
211
  }
111
-
112
- async function recordViaWebCodecs({ canvas, run, fps }) {
113
- // Minimal WebCodecs path: encode each frame; emit raw chunks wrapped as a Blob.
114
- // (Full container muxing is out of scope; MediaRecorder is the primary path and
115
- // this branch only runs where MediaRecorder is unavailable but VideoEncoder is.)
116
- const chunks = [];
117
- const encoder = new VideoEncoder({
118
- output: (chunk) => {
119
- const buf = new ArrayBuffer(chunk.byteLength);
120
- chunk.copyTo(buf);
121
- chunks.push(new Uint8Array(buf));
122
- },
123
- error: (e) => {
124
- throw e;
125
- },
126
- });
127
- encoder.configure({
128
- codec: "vp09.00.10.08",
129
- width: canvas.width,
130
- height: canvas.height,
131
- framerate: fps,
132
- });
133
- let frameIndex = 0;
134
- await run(() => {
135
- const frame = new VideoFrame(canvas, { timestamp: (frameIndex * 1e6) / fps });
136
- encoder.encode(frame, { keyFrame: frameIndex % fps === 0 });
137
- frame.close();
138
- frameIndex += 1;
139
- });
140
- await encoder.flush();
141
- encoder.close();
142
- return new Blob(chunks, { type: "video/webm" });
143
- }