@aelionsdk/export 0.1.0-beta.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/audio-export.d.ts +23 -0
  4. package/dist/audio-export.d.ts.map +1 -0
  5. package/dist/audio-export.js +120 -0
  6. package/dist/checkpoint.d.ts +58 -0
  7. package/dist/checkpoint.d.ts.map +1 -0
  8. package/dist/checkpoint.js +119 -0
  9. package/dist/image-export.d.ts +40 -0
  10. package/dist/image-export.d.ts.map +1 -0
  11. package/dist/image-export.js +235 -0
  12. package/dist/index.d.ts +12 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +11 -0
  15. package/dist/memory-sink.d.ts +17 -0
  16. package/dist/memory-sink.d.ts.map +1 -0
  17. package/dist/memory-sink.js +60 -0
  18. package/dist/mux-export-worker.d.ts +2 -0
  19. package/dist/mux-export-worker.d.ts.map +1 -0
  20. package/dist/mux-export-worker.js +120 -0
  21. package/dist/opfs-sink.d.ts +20 -0
  22. package/dist/opfs-sink.d.ts.map +1 -0
  23. package/dist/opfs-sink.js +113 -0
  24. package/dist/profiles.d.ts +66 -0
  25. package/dist/profiles.d.ts.map +1 -0
  26. package/dist/profiles.js +284 -0
  27. package/dist/remote-export.d.ts +56 -0
  28. package/dist/remote-export.d.ts.map +1 -0
  29. package/dist/remote-export.js +83 -0
  30. package/dist/resumable-muxed-export.d.ts +84 -0
  31. package/dist/resumable-muxed-export.d.ts.map +1 -0
  32. package/dist/resumable-muxed-export.js +533 -0
  33. package/dist/session.d.ts +47 -0
  34. package/dist/session.d.ts.map +1 -0
  35. package/dist/session.js +483 -0
  36. package/dist/sink-completion.d.ts +8 -0
  37. package/dist/sink-completion.d.ts.map +1 -0
  38. package/dist/sink-completion.js +13 -0
  39. package/dist/webm-export.d.ts +97 -0
  40. package/dist/webm-export.d.ts.map +1 -0
  41. package/dist/webm-export.js +408 -0
  42. package/dist/worker-export.d.ts +25 -0
  43. package/dist/worker-export.d.ts.map +1 -0
  44. package/dist/worker-export.js +202 -0
  45. package/dist/worker-protocol.d.ts +66 -0
  46. package/dist/worker-protocol.d.ts.map +1 -0
  47. package/dist/worker-protocol.js +1 -0
  48. package/package.json +46 -0
@@ -0,0 +1,408 @@
1
+ import { AelionError, frameDurationUs, frameStartUs, throwIfAborted, } from '@aelionsdk/core';
2
+ import { AudioSample, AudioSampleSource, Output, Mp4OutputFormat, StreamTarget, VideoSample, VideoSampleSource, WebMOutputFormat, } from 'mediabunny';
3
+ import { av1CodecString, hevcCodecString, negotiateAvcCodecString, preferredAvcCodecString, } from './profiles.js';
4
+ import { createSinkCompletionBarrier } from './sink-completion.js';
5
+ const MAIN_THREAD_YIELD_INTERVAL_MS = 16;
6
+ const audioEncoderRuntimeSupport = new Map();
7
+ function nextMainThreadTask() {
8
+ return new Promise(resolve => globalThis.setTimeout(resolve, 0));
9
+ }
10
+ function verifyAudioEncoderRuntime(config) {
11
+ const key = JSON.stringify(config);
12
+ const existing = audioEncoderRuntimeSupport.get(key);
13
+ if (existing !== undefined)
14
+ return existing;
15
+ const verification = new Promise(resolve => {
16
+ let settled = false;
17
+ let encoder;
18
+ const finish = (supported) => {
19
+ if (settled)
20
+ return;
21
+ settled = true;
22
+ try {
23
+ encoder?.close();
24
+ }
25
+ catch {
26
+ // A codec error can close the encoder before the error callback.
27
+ }
28
+ resolve(supported);
29
+ };
30
+ try {
31
+ encoder = new AudioEncoder({
32
+ output: () => undefined,
33
+ error: () => finish(false),
34
+ });
35
+ encoder.configure(config);
36
+ for (let block = 0; block < 4; block += 1) {
37
+ const frameCount = 1_024;
38
+ const sample = new AudioData({
39
+ format: 'f32',
40
+ sampleRate: config.sampleRate,
41
+ numberOfFrames: frameCount,
42
+ numberOfChannels: config.numberOfChannels,
43
+ timestamp: Math.round((block * frameCount * 1_000_000) / config.sampleRate),
44
+ data: new Float32Array(frameCount * config.numberOfChannels),
45
+ });
46
+ try {
47
+ encoder.encode(sample);
48
+ }
49
+ finally {
50
+ sample.close();
51
+ }
52
+ }
53
+ void encoder.flush().then(() => finish(true), () => finish(false));
54
+ }
55
+ catch {
56
+ finish(false);
57
+ }
58
+ });
59
+ audioEncoderRuntimeSupport.set(key, verification);
60
+ return verification;
61
+ }
62
+ function exportFailure(stage, cause) {
63
+ const causeName = cause instanceof DOMException ? cause.name : '';
64
+ const causeMessage = cause instanceof Error ? cause.message : '';
65
+ if (causeName === 'QuotaExceededError' ||
66
+ /quota|storage|disk|write failed/iu.test(causeMessage)) {
67
+ return new AelionError([
68
+ {
69
+ code: 'EXPORT_STORAGE_WRITE_FAILED',
70
+ severity: 'error',
71
+ message: `Export sink write failed: ${causeMessage || 'unknown storage failure'}`,
72
+ recoverable: true,
73
+ cause,
74
+ },
75
+ ]);
76
+ }
77
+ const mapping = {
78
+ initialize: ['EXPORT_ENCODER_INIT_FAILED', 'Failed to initialize export encoders or muxer'],
79
+ 'render-video': ['EXPORT_VIDEO_RENDER_FAILED', 'Failed to render an export video frame'],
80
+ 'encode-video': ['EXPORT_VIDEO_ENCODER_FAILED', 'Video encoder rejected an export frame'],
81
+ 'render-audio': ['EXPORT_AUDIO_RENDER_FAILED', 'Failed to render an export PCM block'],
82
+ 'encode-audio': ['EXPORT_AUDIO_ENCODER_FAILED', 'Audio encoder rejected an export block'],
83
+ finalize: ['EXPORT_MUX_OR_SINK_FAILED', 'Failed to finalize muxed output or write the sink'],
84
+ };
85
+ const [code, prefix] = mapping[stage];
86
+ return new AelionError([
87
+ {
88
+ code,
89
+ severity: 'error',
90
+ message: `${prefix}: ${cause instanceof Error ? cause.message : 'unknown failure'}`,
91
+ recoverable: stage === 'finalize',
92
+ cause,
93
+ },
94
+ ]);
95
+ }
96
+ function assertPositiveInteger(value, name) {
97
+ if (!Number.isSafeInteger(value) || value <= 0) {
98
+ throw new RangeError(`${name} must be a positive safe integer`);
99
+ }
100
+ }
101
+ export async function exportMuxed(options, profile, range) {
102
+ assertPositiveInteger(options.durationUs, 'durationUs');
103
+ assertPositiveInteger(options.width, 'width');
104
+ assertPositiveInteger(options.height, 'height');
105
+ assertPositiveInteger(options.sampleRate, 'sampleRate');
106
+ assertPositiveInteger(options.channelCount, 'channelCount');
107
+ const fullVideoFrameCount = Math.ceil((options.durationUs * options.frameRate.numerator) /
108
+ (1_000_000 * options.frameRate.denominator));
109
+ const fullAudioFrameCount = Math.floor((options.durationUs * options.sampleRate) / 1_000_000);
110
+ const videoStartFrame = range?.videoStartFrame ?? 0;
111
+ const videoEndFrameExclusive = range?.videoEndFrameExclusive ?? fullVideoFrameCount;
112
+ const audioStartFrame = range?.audioStartFrame ?? 0;
113
+ const audioEndFrameExclusive = range?.audioEndFrameExclusive ?? fullAudioFrameCount;
114
+ const rangeTimestampBaseUs = range?.timestampBase === 'range' ? frameStartUs(videoStartFrame, options.frameRate) : 0;
115
+ const rangeAudioBaseFrame = range?.timestampBase === 'range' ? audioStartFrame : 0;
116
+ for (const [value, name] of [
117
+ [videoStartFrame, 'videoStartFrame'],
118
+ [videoEndFrameExclusive, 'videoEndFrameExclusive'],
119
+ [audioStartFrame, 'audioStartFrame'],
120
+ [audioEndFrameExclusive, 'audioEndFrameExclusive'],
121
+ ]) {
122
+ if (!Number.isSafeInteger(value) || value < 0) {
123
+ throw new RangeError(`${name} must be a non-negative safe integer`);
124
+ }
125
+ }
126
+ if (videoStartFrame >= videoEndFrameExclusive ||
127
+ videoEndFrameExclusive > fullVideoFrameCount ||
128
+ audioStartFrame >= audioEndFrameExclusive ||
129
+ audioEndFrameExclusive > fullAudioFrameCount) {
130
+ throw new RangeError('Muxed export range must be a non-empty subset of the timeline');
131
+ }
132
+ throwIfAborted(options.signal, profile.operationName);
133
+ if (profile.audioCodec === 'aac') {
134
+ const runtimeConfig = {
135
+ codec: options.audioCodecString ?? 'mp4a.40.2',
136
+ sampleRate: options.sampleRate,
137
+ numberOfChannels: options.channelCount,
138
+ bitrate: options.audioBitrate,
139
+ bitrateMode: 'variable',
140
+ ...{ aac: { format: 'aac' } },
141
+ };
142
+ const supported = await verifyAudioEncoderRuntime(runtimeConfig);
143
+ if (!supported) {
144
+ throw new AelionError([
145
+ {
146
+ code: 'EXPORT_AUDIO_CONFIG_UNSUPPORTED',
147
+ severity: 'error',
148
+ message: 'AAC encoder configuration failed the runtime encode canary',
149
+ recoverable: false,
150
+ },
151
+ ]);
152
+ }
153
+ }
154
+ // StreamTarget closes its writer during Output.finalize(). Some Firefox
155
+ // builds have resolved that close before the consumer sink's close callback
156
+ // became observable. Pipe through a barrier and await the pipe separately so
157
+ // a completed export always means the caller's sink is fully closed.
158
+ const sinkBarrier = createSinkCompletionBarrier(options.sink);
159
+ let output;
160
+ let videoFrames = 0;
161
+ let audioFrames = 0;
162
+ let stage = 'initialize';
163
+ let pendingRenderedFrame;
164
+ let lastMainThreadYieldMs = performance.now();
165
+ const yieldMainThreadWhenDue = async () => {
166
+ throwIfAborted(options.signal, 'WebM export');
167
+ if (performance.now() - lastMainThreadYieldMs < MAIN_THREAD_YIELD_INTERVAL_MS)
168
+ return;
169
+ await nextMainThreadTask();
170
+ lastMainThreadYieldMs = performance.now();
171
+ throwIfAborted(options.signal, 'WebM export');
172
+ };
173
+ try {
174
+ const target = new StreamTarget(sinkBarrier.writable, {
175
+ chunked: true,
176
+ chunkSize: 64 * 1_024,
177
+ });
178
+ output = new Output({
179
+ format: profile.format,
180
+ target,
181
+ });
182
+ const videoSource = new VideoSampleSource({
183
+ codec: profile.videoCodec,
184
+ fullCodecString: options.videoCodecString ?? profile.fullVideoCodecString,
185
+ bitrate: options.videoBitrate,
186
+ bitrateMode: 'variable',
187
+ keyFrameInterval: 1,
188
+ latencyMode: 'quality',
189
+ alpha: 'discard',
190
+ });
191
+ const audioSource = new AudioSampleSource({
192
+ codec: profile.audioCodec,
193
+ ...(options.audioCodecString === undefined
194
+ ? {}
195
+ : { fullCodecString: options.audioCodecString }),
196
+ bitrate: options.audioBitrate,
197
+ bitrateMode: 'variable',
198
+ });
199
+ output.addVideoTrack(videoSource, {
200
+ frameRate: options.frameRate.numerator / options.frameRate.denominator,
201
+ });
202
+ output.addAudioTrack(audioSource);
203
+ await output.start();
204
+ await yieldMainThreadWhenDue();
205
+ const rangedVideoFrames = videoEndFrameExclusive - videoStartFrame;
206
+ const beginRender = (frameIndex) => {
207
+ const timestampUs = frameStartUs(frameIndex, options.frameRate);
208
+ const durationUs = Math.min(frameDurationUs(frameIndex, options.frameRate), options.durationUs - timestampUs);
209
+ return Promise.resolve(options.renderFrame({ frameIndex, timestampUs, durationUs, width: options.width, height: options.height }, options.signal)).then(frame => ({ ok: true, frame }), (error) => ({ ok: false, error }));
210
+ };
211
+ pendingRenderedFrame = beginRender(videoStartFrame);
212
+ for (let frameIndex = videoStartFrame; frameIndex < videoEndFrameExclusive; frameIndex += 1) {
213
+ throwIfAborted(options.signal, 'WebM video export');
214
+ const timestampUs = frameStartUs(frameIndex, options.frameRate);
215
+ const durationUs = Math.min(frameDurationUs(frameIndex, options.frameRate), options.durationUs - timestampUs);
216
+ if (durationUs <= 0)
217
+ break;
218
+ stage = 'render-video';
219
+ const renderPromise = pendingRenderedFrame;
220
+ if (renderPromise === undefined)
221
+ throw new Error('Video render pipeline was not primed');
222
+ const rendered = await renderPromise;
223
+ pendingRenderedFrame =
224
+ frameIndex + 1 < videoEndFrameExclusive ? beginRender(frameIndex + 1) : undefined;
225
+ if (!rendered.ok)
226
+ throw rendered.error;
227
+ const frame = rendered.frame;
228
+ try {
229
+ stage = 'encode-video';
230
+ const sample = new VideoSample(frame, {
231
+ timestamp: (timestampUs - rangeTimestampBaseUs) / 1_000_000,
232
+ duration: durationUs / 1_000_000,
233
+ });
234
+ try {
235
+ await videoSource.add(sample);
236
+ }
237
+ finally {
238
+ sample.close();
239
+ }
240
+ }
241
+ finally {
242
+ frame.close();
243
+ }
244
+ videoFrames += 1;
245
+ options.onProgress?.(videoFrames / rangedVideoFrames / 2);
246
+ await yieldMainThreadWhenDue();
247
+ }
248
+ videoSource.close();
249
+ const totalAudioFrames = audioEndFrameExclusive - audioStartFrame;
250
+ const blockFrames = 1_024;
251
+ while (audioFrames < totalAudioFrames) {
252
+ throwIfAborted(options.signal, 'WebM audio export');
253
+ const frameCount = Math.min(blockFrames, totalAudioFrames - audioFrames);
254
+ stage = 'render-audio';
255
+ const pcm = await options.renderAudio({
256
+ startFrame: audioStartFrame + audioFrames,
257
+ frameCount,
258
+ sampleRate: options.sampleRate,
259
+ channelCount: options.channelCount,
260
+ }, options.signal);
261
+ if (pcm.length !== frameCount * options.channelCount) {
262
+ throw new RangeError('renderAudio returned an unexpected interleaved PCM length');
263
+ }
264
+ const sample = new AudioSample({
265
+ data: pcm,
266
+ format: 'f32',
267
+ numberOfChannels: options.channelCount,
268
+ sampleRate: options.sampleRate,
269
+ timestamp: (audioStartFrame + audioFrames - rangeAudioBaseFrame) / options.sampleRate,
270
+ });
271
+ try {
272
+ stage = 'encode-audio';
273
+ await audioSource.add(sample);
274
+ }
275
+ finally {
276
+ sample.close();
277
+ }
278
+ audioFrames += frameCount;
279
+ options.onProgress?.(Math.min(1 - Number.EPSILON, 0.5 + audioFrames / totalAudioFrames / 2));
280
+ await yieldMainThreadWhenDue();
281
+ }
282
+ audioSource.close();
283
+ stage = 'finalize';
284
+ await output.finalize();
285
+ await sinkBarrier.completion;
286
+ options.onProgress?.(1);
287
+ return {
288
+ mimeType: await output.getMimeType(),
289
+ videoFrames,
290
+ audioFrames,
291
+ durationUs: options.durationUs,
292
+ encoderConfiguration: {
293
+ profile: profile.id,
294
+ video: {
295
+ codec: profile.videoCodec,
296
+ codecString: options.videoCodecString ?? profile.fullVideoCodecString,
297
+ width: options.width,
298
+ height: options.height,
299
+ frameRate: options.frameRate.numerator / options.frameRate.denominator,
300
+ bitrateMode: 'variable',
301
+ targetBitrate: options.videoBitrate,
302
+ },
303
+ audio: {
304
+ codec: profile.audioCodec,
305
+ sampleRate: options.sampleRate,
306
+ channelCount: options.channelCount,
307
+ bitrateMode: 'variable',
308
+ targetBitrate: options.audioBitrate,
309
+ },
310
+ },
311
+ };
312
+ }
313
+ catch (error) {
314
+ if (pendingRenderedFrame !== undefined) {
315
+ const pending = await pendingRenderedFrame;
316
+ if (pending.ok)
317
+ pending.frame.close();
318
+ pendingRenderedFrame = undefined;
319
+ }
320
+ if (output !== undefined && output.state !== 'finalized' && output.state !== 'canceled') {
321
+ try {
322
+ await output.cancel();
323
+ }
324
+ catch {
325
+ // Preserve the first failure. Stream cancellation is best-effort cleanup.
326
+ }
327
+ }
328
+ sinkBarrier.abort(error);
329
+ await sinkBarrier.completion.catch(() => undefined);
330
+ try {
331
+ await options.cleanupSink?.(error);
332
+ }
333
+ catch {
334
+ // Cleanup errors are reported by the concrete sink; preserve the primary failure.
335
+ }
336
+ if (error instanceof AelionError)
337
+ throw error;
338
+ throw exportFailure(stage, error);
339
+ }
340
+ }
341
+ export function exportWebM(options) {
342
+ return exportMuxed(options, {
343
+ id: 'webm-vp9-opus',
344
+ operationName: 'WebM export',
345
+ format: new WebMOutputFormat(),
346
+ videoCodec: 'vp9',
347
+ fullVideoCodecString: 'vp09.00.10.08',
348
+ audioCodec: 'opus',
349
+ });
350
+ }
351
+ export async function exportMp4(options) {
352
+ const framerate = options.frameRate.numerator / options.frameRate.denominator;
353
+ const negotiated = options.videoCodecString === undefined
354
+ ? await negotiateAvcCodecString({
355
+ width: options.width,
356
+ height: options.height,
357
+ framerate,
358
+ bitrate: options.videoBitrate,
359
+ })
360
+ : undefined;
361
+ const videoCodecString = options.videoCodecString ??
362
+ negotiated?.selected ??
363
+ preferredAvcCodecString(options.width, options.height, framerate);
364
+ return exportMuxed({
365
+ ...options,
366
+ videoCodecString,
367
+ audioCodecString: options.audioCodecString ?? 'mp4a.40.2',
368
+ }, {
369
+ id: 'mp4-h264-aac',
370
+ operationName: 'MP4 export',
371
+ format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
372
+ videoCodec: 'avc',
373
+ fullVideoCodecString: videoCodecString,
374
+ audioCodec: 'aac',
375
+ });
376
+ }
377
+ export function exportAv1Mp4(options) {
378
+ const framerate = options.frameRate.numerator / options.frameRate.denominator;
379
+ const videoCodecString = options.videoCodecString ?? av1CodecString(options.width, options.height, framerate);
380
+ return exportMuxed({
381
+ ...options,
382
+ videoCodecString,
383
+ audioCodecString: options.audioCodecString ?? 'mp4a.40.2',
384
+ }, {
385
+ id: 'mp4-av1-aac',
386
+ operationName: 'AV1 MP4 export',
387
+ format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
388
+ videoCodec: 'av1',
389
+ fullVideoCodecString: videoCodecString,
390
+ audioCodec: 'aac',
391
+ });
392
+ }
393
+ export function exportHevcMp4(options) {
394
+ const framerate = options.frameRate.numerator / options.frameRate.denominator;
395
+ const videoCodecString = options.videoCodecString ?? hevcCodecString(options.width, options.height, framerate);
396
+ return exportMuxed({
397
+ ...options,
398
+ videoCodecString,
399
+ audioCodecString: options.audioCodecString ?? 'mp4a.40.2',
400
+ }, {
401
+ id: 'mp4-hevc-aac',
402
+ operationName: 'HEVC MP4 export',
403
+ format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
404
+ videoCodec: 'hevc',
405
+ fullVideoCodecString: videoCodecString,
406
+ audioCodec: 'aac',
407
+ });
408
+ }
@@ -0,0 +1,25 @@
1
+ import { type Disposable } from '@aelionsdk/core';
2
+ import type { WebMExportOptions, WebMExportResult } from './webm-export.js';
3
+ export interface WorkerMuxedExportOptions extends WebMExportOptions {
4
+ readonly profile: 'webm' | 'mp4' | 'mp4-av1' | 'mp4-hevc';
5
+ readonly workerUrl?: string | URL;
6
+ }
7
+ export interface WorkerMuxedExporterSnapshot {
8
+ readonly disposed: boolean;
9
+ readonly running: boolean;
10
+ readonly pendingHostRequests: number;
11
+ }
12
+ export interface WorkerMuxedExporterOptions {
13
+ readonly workerUrl?: string | URL;
14
+ readonly workerFactory?: () => Worker;
15
+ }
16
+ export declare class WorkerMuxedExporter implements Disposable {
17
+ #private;
18
+ constructor(options?: WorkerMuxedExporterOptions);
19
+ get disposed(): boolean;
20
+ snapshot(): WorkerMuxedExporterSnapshot;
21
+ run(options: WorkerMuxedExportOptions): Promise<WebMExportResult>;
22
+ dispose(): void;
23
+ }
24
+ export declare function exportMuxedInWorker(options: WorkerMuxedExportOptions): Promise<WebMExportResult>;
25
+ //# sourceMappingURL=worker-export.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-export.d.ts","sourceRoot":"","sources":["../src/worker-export.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG/D,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAQ5E,MAAM,WAAW,wBAAyB,SAAQ,iBAAiB;IACjE,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS,GAAG,UAAU,CAAC;IAC1D,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;CACnC;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;CACtC;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;IAClC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,MAAM,CAAC;CACvC;AAQD,qBAAa,mBAAoB,YAAW,UAAU;;gBAMjC,OAAO,GAAE,0BAA+B;IAS3D,IAAW,QAAQ,IAAI,OAAO,CAE7B;IAEM,QAAQ,IAAI,2BAA2B;IAQvC,GAAG,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAiKjE,OAAO,IAAI,IAAI;CAWvB;AAED,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,gBAAgB,CAAC,CAS3B"}
@@ -0,0 +1,202 @@
1
+ import { AelionError } from '@aelionsdk/core';
2
+ import { createSinkCompletionBarrier } from './sink-completion.js';
3
+ function copiedPcm(pcm) {
4
+ const copy = new Float32Array(pcm.length);
5
+ copy.set(pcm);
6
+ return copy;
7
+ }
8
+ export class WorkerMuxedExporter {
9
+ #worker;
10
+ #pendingHostRequests = new Set();
11
+ #disposed = false;
12
+ #running = false;
13
+ constructor(options = {}) {
14
+ this.#worker =
15
+ options.workerFactory?.() ??
16
+ new Worker(options.workerUrl ?? new URL('./mux-export-worker.js', import.meta.url), {
17
+ type: 'module',
18
+ name: 'aelion-export-mux',
19
+ });
20
+ }
21
+ get disposed() {
22
+ return this.#disposed;
23
+ }
24
+ snapshot() {
25
+ return {
26
+ disposed: this.#disposed,
27
+ running: this.#running,
28
+ pendingHostRequests: this.#pendingHostRequests.size,
29
+ };
30
+ }
31
+ run(options) {
32
+ if (this.#disposed)
33
+ return Promise.reject(new ReferenceError('Export Worker is disposed'));
34
+ if (this.#running)
35
+ return Promise.reject(new TypeError('EXPORT_WORKER_BUSY'));
36
+ if (options.signal?.aborted === true) {
37
+ return Promise.reject(new DOMException('Export cancelled', 'AbortError'));
38
+ }
39
+ const sinkBarrier = createSinkCompletionBarrier(options.sink);
40
+ this.#running = true;
41
+ return new Promise((resolve, reject) => {
42
+ let settled = false;
43
+ const settle = (operation) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ this.#running = false;
48
+ options.signal?.removeEventListener('abort', onAbort);
49
+ this.#worker.removeEventListener('message', onMessage);
50
+ this.#worker.removeEventListener('error', onError);
51
+ operation();
52
+ };
53
+ const respondFrame = (request) => {
54
+ this.#pendingHostRequests.add(request.id);
55
+ void options.renderFrame(request.request, options.signal).then(frame => {
56
+ this.#pendingHostRequests.delete(request.id);
57
+ if (!this.#running) {
58
+ frame.close();
59
+ return;
60
+ }
61
+ this.#post({ type: 'frame-response', id: request.id, frame }, [frame]);
62
+ }, (error) => {
63
+ this.#pendingHostRequests.delete(request.id);
64
+ this.#post({
65
+ type: 'frame-response',
66
+ id: request.id,
67
+ error: error instanceof Error ? error.message : 'Frame rendering failed',
68
+ });
69
+ });
70
+ };
71
+ const respondAudio = (request) => {
72
+ this.#pendingHostRequests.add(request.id);
73
+ void options.renderAudio(request.request, options.signal).then(value => {
74
+ this.#pendingHostRequests.delete(request.id);
75
+ if (!this.#running)
76
+ return;
77
+ const pcm = copiedPcm(value);
78
+ this.#post({ type: 'audio-response', id: request.id, pcm }, [pcm.buffer]);
79
+ }, (error) => {
80
+ this.#pendingHostRequests.delete(request.id);
81
+ this.#post({
82
+ type: 'audio-response',
83
+ id: request.id,
84
+ error: error instanceof Error ? error.message : 'Audio rendering failed',
85
+ });
86
+ });
87
+ };
88
+ const onMessage = (event) => {
89
+ const response = event.data;
90
+ if (response.type === 'render-frame')
91
+ respondFrame(response);
92
+ else if (response.type === 'render-audio')
93
+ respondAudio(response);
94
+ else if (response.type === 'progress')
95
+ options.onProgress?.(response.value);
96
+ else if (response.type === 'completed') {
97
+ void sinkBarrier.completion.then(() => settle(() => resolve(response.result)), (cause) => {
98
+ const error = cause instanceof AelionError
99
+ ? cause
100
+ : new AelionError([
101
+ {
102
+ code: 'EXPORT_STORAGE_WRITE_FAILED',
103
+ severity: 'error',
104
+ message: `Export sink did not finalize: ${cause instanceof Error ? cause.message : 'unknown storage failure'}`,
105
+ recoverable: true,
106
+ cause,
107
+ },
108
+ ]);
109
+ settle(() => {
110
+ void Promise.resolve(options.cleanupSink?.(error)).then(() => reject(error), () => reject(error));
111
+ });
112
+ });
113
+ }
114
+ else {
115
+ const error = response.aborted
116
+ ? new DOMException(response.message, 'AbortError')
117
+ : new AelionError([
118
+ {
119
+ code: response.code,
120
+ severity: 'error',
121
+ message: response.message,
122
+ recoverable: true,
123
+ },
124
+ ]);
125
+ settle(() => {
126
+ sinkBarrier.abort(error);
127
+ void sinkBarrier.completion
128
+ .catch(() => undefined)
129
+ .then(() => options.cleanupSink?.(error))
130
+ .then(() => reject(error), () => reject(error));
131
+ });
132
+ }
133
+ };
134
+ const onError = (event) => {
135
+ const error = new Error(event.message || 'Export Worker crashed');
136
+ settle(() => {
137
+ sinkBarrier.abort(error);
138
+ void sinkBarrier.completion
139
+ .catch(() => undefined)
140
+ .then(() => options.cleanupSink?.(error))
141
+ .then(() => reject(error), () => reject(error));
142
+ });
143
+ };
144
+ const onAbort = () => {
145
+ this.#post({
146
+ type: 'cancel',
147
+ reason: options.signal?.reason instanceof Error
148
+ ? options.signal.reason.message
149
+ : 'Export cancelled',
150
+ });
151
+ };
152
+ this.#worker.addEventListener('message', onMessage);
153
+ this.#worker.addEventListener('error', onError);
154
+ options.signal?.addEventListener('abort', onAbort, { once: true });
155
+ const start = {
156
+ type: 'start',
157
+ profile: options.profile,
158
+ config: {
159
+ durationUs: options.durationUs,
160
+ width: options.width,
161
+ height: options.height,
162
+ frameRate: options.frameRate,
163
+ sampleRate: options.sampleRate,
164
+ channelCount: options.channelCount,
165
+ videoBitrate: options.videoBitrate,
166
+ audioBitrate: options.audioBitrate,
167
+ ...(options.videoCodecString === undefined
168
+ ? {}
169
+ : { videoCodecString: options.videoCodecString }),
170
+ ...(options.audioCodecString === undefined
171
+ ? {}
172
+ : { audioCodecString: options.audioCodecString }),
173
+ },
174
+ sink: sinkBarrier.writable,
175
+ };
176
+ this.#post(start, [sinkBarrier.writable]);
177
+ });
178
+ }
179
+ dispose() {
180
+ if (this.#disposed)
181
+ return;
182
+ this.#disposed = true;
183
+ this.#running = false;
184
+ this.#pendingHostRequests.clear();
185
+ this.#worker.terminate();
186
+ }
187
+ #post(request, transfer = []) {
188
+ if (!this.#disposed)
189
+ this.#worker.postMessage(request, transfer);
190
+ }
191
+ }
192
+ export async function exportMuxedInWorker(options) {
193
+ const exporter = new WorkerMuxedExporter({
194
+ ...(options.workerUrl === undefined ? {} : { workerUrl: options.workerUrl }),
195
+ });
196
+ try {
197
+ return await exporter.run(options);
198
+ }
199
+ finally {
200
+ exporter.dispose();
201
+ }
202
+ }