@glissade/export-web 0.2.0 → 0.3.0

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/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @glissade/export-web
2
+
3
+ In-browser export: WebCodecs encoding + Mediabunny muxing, frame-accurate and faster than realtime, with sample-accurate audio via OfflineAudioContext. Ships the **worker protocol** (`serveExportRequest` / `requestWorkerExport`) so encoding runs off the main thread — audio premixes main-side and transfers as raw PCM. Codec support is feature-detected; PNG frames are the unconditional fallback.
4
+
5
+ ```sh
6
+ npm i @glissade/export-web
7
+ ```
8
+
9
+ ```ts
10
+ import { exportVideo } from '@glissade/export-web';
11
+
12
+ const { blob, format } = await exportVideo(scene, doc, { fps: 60, format: 'auto' });
13
+ ```
14
+
15
+ ## Part of glissade
16
+
17
+ *(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
18
+
19
+ - [Repository & full README](https://github.com/tyevco/glissade)
20
+ - [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
21
+
22
+ Apache-2.0.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AudioCodec, VideoCodec } from "mediabunny";
2
- import { Timeline } from "@glissade/core";
2
+ import { AudioClip, Timeline } from "@glissade/core";
3
3
  import { Scene, VideoFrameSource } from "@glissade/scene";
4
4
 
5
5
  //#region src/videoSource.d.ts
@@ -18,6 +18,46 @@ declare class MediabunnyVideoFrameSource implements VideoFrameSource {
18
18
  close(): void;
19
19
  }
20
20
  //#endregion
21
+ //#region src/workerProtocol.d.ts
22
+ interface ExportWorkerRequest {
23
+ /** Registry key the worker's loadScene resolves (e.g. a module glob key). */
24
+ sceneKey: string;
25
+ /** The document to render — e.g. the studio's sidecar-merged timeline. */
26
+ timeline: Timeline;
27
+ options?: Omit<WebExportOptions, 'onProgress' | 'premixedAudio'>;
28
+ /** Premixed on the main thread; channels arrive transferred. */
29
+ premixedAudio?: PremixedAudio;
30
+ }
31
+ type ExportWorkerResponse = {
32
+ kind: 'progress';
33
+ frame: number;
34
+ total: number;
35
+ } | {
36
+ kind: 'done';
37
+ buffer: ArrayBuffer;
38
+ format: 'mp4' | 'webm';
39
+ videoCodec: string;
40
+ audioCodec: string | null;
41
+ frames: number;
42
+ } | {
43
+ kind: 'error';
44
+ message: string;
45
+ };
46
+ /** The entire worker body: resolve the scene, export, stream progress, transfer the result. */
47
+ declare function serveExportRequest(req: ExportWorkerRequest, loadScene: (key: string) => Promise<Scene>, post: (msg: ExportWorkerResponse, transfer?: Transferable[]) => void): Promise<void>;
48
+ interface WorkerExportHandle {
49
+ result: Promise<WebExportResult>;
50
+ /** Terminates the worker; the result promise rejects. */
51
+ cancel(): void;
52
+ }
53
+ /** Main-thread side: premix audio if the document carries any, post, collect. */
54
+ declare function requestWorkerExport(worker: Worker, args: {
55
+ sceneKey: string;
56
+ timeline: Timeline;
57
+ options?: ExportWorkerRequest['options'];
58
+ onProgress?: (frame: number, total: number) => void;
59
+ }): WorkerExportHandle;
60
+ //#endregion
21
61
  //#region src/index.d.ts
22
62
  interface WebExportOptions {
23
63
  fps?: number;
@@ -25,8 +65,19 @@ interface WebExportOptions {
25
65
  format?: 'mp4' | 'webm' | 'auto';
26
66
  videoBitrate?: number;
27
67
  audioBitrate?: number;
68
+ /**
69
+ * Pre-mixed PCM from the main thread (raw planar f32 channels). Workers
70
+ * have no OfflineAudioContext, so the worker path premixes there and
71
+ * transfers the channels (§5.1 worker posture).
72
+ */
73
+ premixedAudio?: PremixedAudio;
28
74
  onProgress?: (frame: number, total: number) => void;
29
75
  }
76
+ /** Raw mixed audio: one Float32Array per channel, transferable to a Worker. */
77
+ interface PremixedAudio {
78
+ sampleRate: number;
79
+ channelData: Float32Array[];
80
+ }
30
81
  interface WebExportResult {
31
82
  blob: Blob;
32
83
  format: 'mp4' | 'webm';
@@ -37,6 +88,10 @@ interface WebExportResult {
37
88
  declare class ExportUnsupportedError extends Error {
38
89
  constructor(detail: string);
39
90
  }
91
+ /** Mix timeline audio clips sample-accurately (§5.3 browser path). Window-only (OfflineAudioContext). */
92
+ declare function mixAudio(clips: AudioClip[], duration: number, sampleRate?: number): Promise<AudioBuffer>;
93
+ /** Main-thread premix for the worker path: mixAudio flattened to transferable channels. */
94
+ declare function premixTimelineAudio(clips: AudioClip[], duration: number): Promise<PremixedAudio>;
40
95
  /** Export a scene + timeline to a video Blob, entirely in the browser. */
41
96
  declare function exportVideo(scene: Scene, doc: Timeline, opts?: WebExportOptions): Promise<WebExportResult>;
42
97
  /** Unconditional fallback (§5.2): per-frame PNG blobs via a callback — works wherever canvas does. */
@@ -46,4 +101,4 @@ declare function exportPngFrames(scene: Scene, doc: Timeline, onFrame: (frame: n
46
101
  frames: number;
47
102
  }>;
48
103
  //#endregion
49
- export { ExportUnsupportedError, MediabunnyVideoFrameSource, WebExportOptions, WebExportResult, exportPngFrames, exportVideo };
104
+ export { ExportUnsupportedError, type ExportWorkerRequest, type ExportWorkerResponse, MediabunnyVideoFrameSource, PremixedAudio, WebExportOptions, WebExportResult, type WorkerExportHandle, exportPngFrames, exportVideo, mixAudio, premixTimelineAudio, requestWorkerExport, serveExportRequest };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ALL_FORMATS, AudioBufferSource, BlobSource, BufferTarget, CanvasSink, CanvasSource, Input, Mp4OutputFormat, Output, UrlSource, WebMOutputFormat, getFirstEncodableAudioCodec, getFirstEncodableVideoCodec } from "mediabunny";
1
+ import { ALL_FORMATS, AudioBufferSource, AudioSample, AudioSampleSource, BlobSource, BufferTarget, CanvasSink, CanvasSource, Input, Mp4OutputFormat, Output, UrlSource, WebMOutputFormat, getFirstEncodableAudioCodec, getFirstEncodableVideoCodec } from "mediabunny";
2
2
  import { compileTimeline, sampleTrack } from "@glissade/core";
3
3
  import { ColdAssetError, evaluate } from "@glissade/scene";
4
4
  import { Canvas2DBackend } from "@glissade/backend-canvas2d";
@@ -71,6 +71,98 @@ var MediabunnyVideoFrameSource = class MediabunnyVideoFrameSource {
71
71
  }
72
72
  };
73
73
  //#endregion
74
+ //#region src/workerProtocol.ts
75
+ /**
76
+ * Worker export protocol (DESIGN.md §3.4/§5.1: export always runs in a Worker
77
+ * so the main thread stays interactive). The worker re-creates the scene from
78
+ * a host-provided registry key and evaluates + encodes there; the main side
79
+ * premixes audio (OfflineAudioContext is Window-only) and transfers raw PCM.
80
+ * Host apps own the Worker file (bundlers handle `new Worker(new URL(...))`):
81
+ * the worker body is one call to serveExportRequest, the main side one call
82
+ * to requestWorkerExport.
83
+ */
84
+ /** The entire worker body: resolve the scene, export, stream progress, transfer the result. */
85
+ async function serveExportRequest(req, loadScene, post) {
86
+ try {
87
+ const scene = await loadScene(req.sceneKey);
88
+ if ([...scene.nodes.values()].some((n) => n.constructor.isLayoutNode === true)) {
89
+ const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
90
+ await loadYogaLayoutEngine();
91
+ }
92
+ const result = await exportVideo(scene, req.timeline, {
93
+ ...req.options,
94
+ ...req.premixedAudio ? { premixedAudio: req.premixedAudio } : {},
95
+ onProgress: (frame, total) => post({
96
+ kind: "progress",
97
+ frame,
98
+ total
99
+ })
100
+ });
101
+ const buffer = await result.blob.arrayBuffer();
102
+ post({
103
+ kind: "done",
104
+ buffer,
105
+ format: result.format,
106
+ videoCodec: result.videoCodec,
107
+ audioCodec: result.audioCodec,
108
+ frames: result.frames
109
+ }, [buffer]);
110
+ } catch (err) {
111
+ post({
112
+ kind: "error",
113
+ message: err instanceof Error ? err.message : String(err)
114
+ });
115
+ }
116
+ }
117
+ /** Main-thread side: premix audio if the document carries any, post, collect. */
118
+ function requestWorkerExport(worker, args) {
119
+ let cancelled = false;
120
+ let rejectResult = null;
121
+ return {
122
+ result: (async () => {
123
+ const compiled = compileTimeline(args.timeline);
124
+ const transfer = [];
125
+ let premixedAudio;
126
+ if (compiled.audio.length > 0) {
127
+ premixedAudio = await premixTimelineAudio(compiled.audio, compiled.duration);
128
+ for (const ch of premixedAudio.channelData) transfer.push(ch.buffer);
129
+ }
130
+ return await new Promise((resolve, reject) => {
131
+ rejectResult = reject;
132
+ if (cancelled) {
133
+ reject(/* @__PURE__ */ new Error("export cancelled"));
134
+ return;
135
+ }
136
+ worker.onmessage = (e) => {
137
+ const msg = e.data;
138
+ if (msg.kind === "progress") args.onProgress?.(msg.frame, msg.total);
139
+ else if (msg.kind === "done") resolve({
140
+ blob: new Blob([msg.buffer], { type: msg.format === "mp4" ? "video/mp4" : "video/webm" }),
141
+ format: msg.format,
142
+ videoCodec: msg.videoCodec,
143
+ audioCodec: msg.audioCodec,
144
+ frames: msg.frames
145
+ });
146
+ else reject(new Error(msg.message));
147
+ };
148
+ worker.onerror = (e) => reject(new Error(e.message || "export worker crashed"));
149
+ const req = {
150
+ sceneKey: args.sceneKey,
151
+ timeline: args.timeline,
152
+ ...args.options ? { options: args.options } : {},
153
+ ...premixedAudio ? { premixedAudio } : {}
154
+ };
155
+ worker.postMessage(req, transfer);
156
+ });
157
+ })(),
158
+ cancel: () => {
159
+ cancelled = true;
160
+ worker.terminate();
161
+ rejectResult?.(/* @__PURE__ */ new Error("export cancelled"));
162
+ }
163
+ };
164
+ }
165
+ //#endregion
74
166
  //#region src/index.ts
75
167
  /**
76
168
  * @glissade/export-web — in-browser export (DESIGN.md §5.1b/§5.2/§5.3):
@@ -119,7 +211,7 @@ async function pickCodecs(format, width, height, bitrate, needAudio) {
119
211
  }
120
212
  throw new ExportUnsupportedError(`format '${format}'${needAudio ? " with audio" : ""}`);
121
213
  }
122
- /** Mix timeline audio clips sample-accurately (§5.3 browser path). */
214
+ /** Mix timeline audio clips sample-accurately (§5.3 browser path). Window-only (OfflineAudioContext). */
123
215
  async function mixAudio(clips, duration, sampleRate = 48e3) {
124
216
  const ctx = new OfflineAudioContext(2, Math.ceil(duration * sampleRate), sampleRate);
125
217
  for (const clip of clips) {
@@ -147,6 +239,16 @@ async function mixAudio(clips, duration, sampleRate = 48e3) {
147
239
  }
148
240
  return ctx.startRendering();
149
241
  }
242
+ /** Main-thread premix for the worker path: mixAudio flattened to transferable channels. */
243
+ async function premixTimelineAudio(clips, duration) {
244
+ const buf = await mixAudio(clips, duration);
245
+ const channelData = [];
246
+ for (let i = 0; i < buf.numberOfChannels; i++) channelData.push(buf.getChannelData(i).slice());
247
+ return {
248
+ sampleRate: buf.sampleRate,
249
+ channelData
250
+ };
251
+ }
150
252
  /** Export a scene + timeline to a video Blob, entirely in the browser. */
151
253
  async function exportVideo(scene, doc, opts = {}) {
152
254
  const compiled = compileTimeline(doc);
@@ -199,19 +301,43 @@ async function exportVideo(scene, doc, opts = {}) {
199
301
  bitrate: videoBitrate
200
302
  });
201
303
  output.addVideoTrack(videoSource, { frameRate: fps });
202
- let audioSource = null;
304
+ let feedAudio = null;
203
305
  if (picked.audio) {
204
- audioSource = new AudioBufferSource({
205
- codec: picked.audio,
206
- bitrate: opts.audioBitrate ?? 192e3
207
- });
208
- output.addAudioTrack(audioSource);
306
+ const bitrate = opts.audioBitrate ?? 192e3;
307
+ const premixed = opts.premixedAudio;
308
+ if (premixed) {
309
+ const source = new AudioSampleSource({
310
+ codec: picked.audio,
311
+ bitrate
312
+ });
313
+ output.addAudioTrack(source);
314
+ feedAudio = async () => {
315
+ const frames = premixed.channelData[0]?.length ?? 0;
316
+ const data = new Float32Array(frames * premixed.channelData.length);
317
+ premixed.channelData.forEach((ch, i) => data.set(ch, i * frames));
318
+ await source.add(new AudioSample({
319
+ data,
320
+ format: "f32-planar",
321
+ numberOfChannels: premixed.channelData.length,
322
+ sampleRate: premixed.sampleRate,
323
+ timestamp: 0
324
+ }));
325
+ source.close();
326
+ };
327
+ } else {
328
+ const source = new AudioBufferSource({
329
+ codec: picked.audio,
330
+ bitrate
331
+ });
332
+ output.addAudioTrack(source);
333
+ feedAudio = async () => {
334
+ await source.add(await mixAudio(compiled.audio, duration));
335
+ source.close();
336
+ };
337
+ }
209
338
  }
210
339
  await output.start();
211
- if (audioSource) {
212
- await audioSource.add(await mixAudio(compiled.audio, duration));
213
- audioSource.close();
214
- }
340
+ if (feedAudio) await feedAudio();
215
341
  for (let f = 0; f < total; f++) {
216
342
  await renderFrame(f / fps);
217
343
  await videoSource.add(f / fps, 1 / fps);
@@ -244,4 +370,4 @@ async function exportPngFrames(scene, doc, onFrame, opts = {}) {
244
370
  return { frames: total };
245
371
  }
246
372
  //#endregion
247
- export { ExportUnsupportedError, MediabunnyVideoFrameSource, exportPngFrames, exportVideo };
373
+ export { ExportUnsupportedError, MediabunnyVideoFrameSource, exportPngFrames, exportVideo, mixAudio, premixTimelineAudio, requestWorkerExport, serveExportRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/export-web",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "glissade in-browser export: WebCodecs VideoEncoder + Mediabunny muxing, OfflineAudioContext audio mix. Frame-accurate, faster than realtime, no server.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,9 +16,9 @@
16
16
  ],
17
17
  "dependencies": {
18
18
  "mediabunny": "^1.0.0",
19
- "@glissade/backend-canvas2d": "0.2.0",
20
- "@glissade/core": "0.2.0",
21
- "@glissade/scene": "0.2.0"
19
+ "@glissade/backend-canvas2d": "0.3.0",
20
+ "@glissade/core": "0.3.0",
21
+ "@glissade/scene": "0.3.0"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",