@glissade/export-web 0.2.0 → 0.4.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 +22 -0
- package/dist/index.d.ts +57 -2
- package/dist/index.js +139 -13
- package/package.json +4 -4
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
|
|
304
|
+
let feedAudio = null;
|
|
203
305
|
if (picked.audio) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "0.4.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.
|
|
20
|
-
"@glissade/core": "0.
|
|
21
|
-
"@glissade/scene": "0.
|
|
19
|
+
"@glissade/backend-canvas2d": "0.4.0",
|
|
20
|
+
"@glissade/core": "0.4.0",
|
|
21
|
+
"@glissade/scene": "0.4.0"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|