@bendyline/squisq-video-react 1.2.2 → 2.0.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.
- package/README.md +63 -22
- package/dist/index.d.ts +33 -13
- package/dist/index.js +511 -212
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +110 -80
- package/dist/workers/encode.worker.js.map +1 -1
- package/dist/workers/ffmpeg.class-worker.d.ts +2 -0
- package/dist/workers/ffmpeg.class-worker.js +180 -0
- package/dist/workers/ffmpeg.class-worker.js.map +1 -0
- package/package.json +7 -5
- package/src/VideoExportButton.tsx +12 -5
- package/src/VideoExportModal.tsx +219 -62
- package/src/__tests__/gifTranscode.test.ts +118 -0
- package/src/__tests__/useVideoExportGif.test.ts +155 -0
- package/src/__tests__/videoExportProps.test.tsx +82 -7
- package/src/__tests__/workerEncoder.test.ts +143 -0
- package/src/audioTrack.ts +18 -9
- package/src/gifTranscode.ts +75 -0
- package/src/hooks/useFrameCapture.ts +26 -21
- package/src/hooks/useVideoExport.ts +104 -15
- package/src/index.ts +2 -0
- package/src/mainThreadEncoder.ts +5 -10
- package/src/workerEncoder.ts +66 -14
- package/src/workers/encode.worker.ts +134 -86
- package/src/workers/ffmpeg.class-worker.ts +11 -0
- package/src/workers/workerTypes.ts +9 -1
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
19
19
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
20
20
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
VideoQuality,
|
|
23
|
+
VideoOrientation,
|
|
24
|
+
AudioTimelineClip,
|
|
25
|
+
FfmpegWasmLoadConfig,
|
|
26
|
+
} from '@bendyline/squisq-video';
|
|
22
27
|
import { resolveDimensions, computeAudioTimeline, QUALITY_PRESETS } from '@bendyline/squisq-video';
|
|
23
28
|
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
24
29
|
import {
|
|
@@ -38,6 +43,7 @@ import {
|
|
|
38
43
|
EXPORT_AUDIO_SAMPLE_RATE,
|
|
39
44
|
EXPORT_AUDIO_CHANNELS,
|
|
40
45
|
} from '../audioTrack.js';
|
|
46
|
+
import { transcodeMp4ToGifWithFfmpegWasm } from '../gifTranscode.js';
|
|
41
47
|
import { useFrameCapture } from './useFrameCapture.js';
|
|
42
48
|
|
|
43
49
|
// ── Audio resolution ───────────────────────────────────────────────
|
|
@@ -84,13 +90,24 @@ export type VideoExportState =
|
|
|
84
90
|
| 'complete'
|
|
85
91
|
| 'error';
|
|
86
92
|
|
|
93
|
+
/** Browser export container format. */
|
|
94
|
+
export type VideoOutputFormat = 'mp4' | 'gif';
|
|
95
|
+
|
|
87
96
|
export interface VideoExportConfig {
|
|
97
|
+
/** Output container (default: 'mp4') */
|
|
98
|
+
outputFormat?: VideoOutputFormat;
|
|
99
|
+
/** Render authored animations and slide transitions (default: true for MP4, false for GIF). */
|
|
100
|
+
animationsEnabled?: boolean;
|
|
88
101
|
/** Encoding quality preset (default: 'normal') */
|
|
89
102
|
quality?: VideoQuality;
|
|
90
103
|
/** Frames per second (default: 30) */
|
|
91
104
|
fps?: number;
|
|
92
105
|
/** Viewport orientation (default: 'landscape') */
|
|
93
106
|
orientation?: VideoOrientation;
|
|
107
|
+
/** Explicit output width. GIF defaults to 960 landscape / 540 portrait. */
|
|
108
|
+
width?: number;
|
|
109
|
+
/** Explicit output height. GIF defaults to 540 landscape / 960 portrait. */
|
|
110
|
+
height?: number;
|
|
94
111
|
/**
|
|
95
112
|
* Map of relative image paths to binary data.
|
|
96
113
|
* Used to embed images into the render HTML.
|
|
@@ -107,6 +124,8 @@ export interface VideoExportConfig {
|
|
|
107
124
|
captionMode?: CaptionMode;
|
|
108
125
|
/** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */
|
|
109
126
|
playerScript?: string;
|
|
127
|
+
/** Optional self-hosted ffmpeg.wasm core URLs for fallback/offline/CSP use. */
|
|
128
|
+
ffmpegWasm?: FfmpegWasmLoadConfig;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
export interface VideoExportResult {
|
|
@@ -118,6 +137,8 @@ export interface VideoExportResult {
|
|
|
118
137
|
phase: string;
|
|
119
138
|
/** Video duration detected from the doc (seconds) */
|
|
120
139
|
duration: number;
|
|
140
|
+
/** Effective output format for the current or most recent export. */
|
|
141
|
+
outputFormat: VideoOutputFormat;
|
|
121
142
|
/** Encoder backend ('webcodecs' when WebCodecs H.264 active, 'ffmpeg-wasm' when worker fallback active, null when idle) */
|
|
122
143
|
backend: 'webcodecs' | 'ffmpeg-wasm' | null;
|
|
123
144
|
/** Blob download URL (populated when state === 'complete') */
|
|
@@ -158,6 +179,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
158
179
|
const [progress, setProgress] = useState(0);
|
|
159
180
|
const [phase, setPhase] = useState('');
|
|
160
181
|
const [duration, setDuration] = useState(0);
|
|
182
|
+
const [outputFormat, setOutputFormat] = useState<VideoOutputFormat>('mp4');
|
|
161
183
|
const [backend, setBackend] = useState<'webcodecs' | 'ffmpeg-wasm' | null>(null);
|
|
162
184
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
163
185
|
const [fileSize, setFileSize] = useState(0);
|
|
@@ -169,6 +191,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
169
191
|
const [estimatedRemaining, setEstimatedRemaining] = useState(0);
|
|
170
192
|
|
|
171
193
|
const encoderRef = useRef<MainThreadEncoder | null>(null);
|
|
194
|
+
const gifAbortRef = useRef<AbortController | null>(null);
|
|
172
195
|
const cancelledRef = useRef(false);
|
|
173
196
|
const downloadUrlRef = useRef<string | null>(null);
|
|
174
197
|
const startTimeRef = useRef<number>(0);
|
|
@@ -186,6 +209,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
186
209
|
if (encoderRef.current) {
|
|
187
210
|
encoderRef.current.close();
|
|
188
211
|
}
|
|
212
|
+
gifAbortRef.current?.abort();
|
|
189
213
|
frameCapture.destroy();
|
|
190
214
|
};
|
|
191
215
|
}, [frameCapture]);
|
|
@@ -199,11 +223,14 @@ export function useVideoExport(): VideoExportResult {
|
|
|
199
223
|
encoderRef.current.close();
|
|
200
224
|
encoderRef.current = null;
|
|
201
225
|
}
|
|
226
|
+
gifAbortRef.current?.abort();
|
|
227
|
+
gifAbortRef.current = null;
|
|
202
228
|
frameCapture.destroy();
|
|
203
229
|
setState('idle');
|
|
204
230
|
setProgress(0);
|
|
205
231
|
setPhase('');
|
|
206
232
|
setDuration(0);
|
|
233
|
+
setOutputFormat('mp4');
|
|
207
234
|
setBackend(null);
|
|
208
235
|
setDownloadUrl(null);
|
|
209
236
|
setFileSize(0);
|
|
@@ -223,6 +250,8 @@ export function useVideoExport(): VideoExportResult {
|
|
|
223
250
|
encoderRef.current.close();
|
|
224
251
|
encoderRef.current = null;
|
|
225
252
|
}
|
|
253
|
+
gifAbortRef.current?.abort();
|
|
254
|
+
gifAbortRef.current = null;
|
|
226
255
|
frameCapture.destroy();
|
|
227
256
|
setState('idle');
|
|
228
257
|
setProgress(0);
|
|
@@ -244,14 +273,39 @@ export function useVideoExport(): VideoExportResult {
|
|
|
244
273
|
setError(null);
|
|
245
274
|
|
|
246
275
|
const quality = config.quality ?? 'normal';
|
|
247
|
-
const
|
|
276
|
+
const effectiveOutputFormat = config.outputFormat ?? 'mp4';
|
|
277
|
+
const fps = config.fps ?? (effectiveOutputFormat === 'gif' ? 10 : 30);
|
|
248
278
|
const orientation = config.orientation ?? 'landscape';
|
|
249
|
-
const
|
|
279
|
+
const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === 'mp4';
|
|
280
|
+
setOutputFormat(effectiveOutputFormat);
|
|
250
281
|
|
|
251
282
|
try {
|
|
283
|
+
const gifDefaults =
|
|
284
|
+
orientation === 'portrait' ? { width: 540, height: 960 } : { width: 960, height: 540 };
|
|
285
|
+
const { width, height } = resolveDimensions({
|
|
286
|
+
orientation,
|
|
287
|
+
fps,
|
|
288
|
+
quality,
|
|
289
|
+
...(config.width !== undefined
|
|
290
|
+
? { width: config.width }
|
|
291
|
+
: effectiveOutputFormat === 'gif'
|
|
292
|
+
? { width: gifDefaults.width }
|
|
293
|
+
: {}),
|
|
294
|
+
...(config.height !== undefined
|
|
295
|
+
? { height: config.height }
|
|
296
|
+
: effectiveOutputFormat === 'gif'
|
|
297
|
+
? { height: gifDefaults.height }
|
|
298
|
+
: {}),
|
|
299
|
+
});
|
|
252
300
|
// ── Check browser support ─────────────────────────────────
|
|
253
301
|
const webCodecsAvailable = supportsWebCodecs();
|
|
254
302
|
const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== 'undefined';
|
|
303
|
+
if (effectiveOutputFormat === 'gif' && !sharedArrayBufferAvailable) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
'Animated GIF export requires ffmpeg.wasm and SharedArrayBuffer ' +
|
|
306
|
+
'(Cross-Origin-Isolation headers).',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
255
309
|
if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
|
|
256
310
|
throw new Error(
|
|
257
311
|
'No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, ' +
|
|
@@ -291,7 +345,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
291
345
|
|
|
292
346
|
const docDuration = await frameCapture.init(
|
|
293
347
|
doc,
|
|
294
|
-
{ images, audio: config.audio, width, height },
|
|
348
|
+
{ images, audio: config.audio, width, height, animationsEnabled },
|
|
295
349
|
config.captionMode,
|
|
296
350
|
);
|
|
297
351
|
|
|
@@ -319,7 +373,9 @@ export function useVideoExport(): VideoExportResult {
|
|
|
319
373
|
// wrapped so a failure degrades to a silent video with a reason —
|
|
320
374
|
// audio never aborts the export.
|
|
321
375
|
const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
|
|
322
|
-
|
|
376
|
+
// GIF has no audio track. An empty timeline skips preparation and
|
|
377
|
+
// muxing without reporting the format limitation as an export error.
|
|
378
|
+
const timeline = effectiveOutputFormat === 'mp4' ? computeAudioTimeline(doc, 0) : [];
|
|
323
379
|
const aacSupported =
|
|
324
380
|
timeline.length > 0
|
|
325
381
|
? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS)
|
|
@@ -388,7 +444,13 @@ export function useVideoExport(): VideoExportResult {
|
|
|
388
444
|
});
|
|
389
445
|
setBackend('webcodecs');
|
|
390
446
|
} else if (sharedArrayBufferAvailable) {
|
|
391
|
-
const workerEncoder = createWorkerEncoder({
|
|
447
|
+
const workerEncoder = createWorkerEncoder({
|
|
448
|
+
width,
|
|
449
|
+
height,
|
|
450
|
+
fps,
|
|
451
|
+
quality,
|
|
452
|
+
ffmpegWasm: config.ffmpegWasm,
|
|
453
|
+
});
|
|
392
454
|
encoder = workerEncoder;
|
|
393
455
|
const selectedBackend = await workerEncoder.ready;
|
|
394
456
|
setBackend(selectedBackend);
|
|
@@ -443,7 +505,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
443
505
|
}
|
|
444
506
|
|
|
445
507
|
// Encode immediately — WebCodecs is fast and async internally
|
|
446
|
-
encoder.encodeFrame(bitmap, i);
|
|
508
|
+
await encoder.encodeFrame(bitmap, i);
|
|
447
509
|
}
|
|
448
510
|
|
|
449
511
|
if (cancelledRef.current) return;
|
|
@@ -468,9 +530,9 @@ export function useVideoExport(): VideoExportResult {
|
|
|
468
530
|
}
|
|
469
531
|
}
|
|
470
532
|
|
|
471
|
-
// ── Step 4: Finalize MP4
|
|
533
|
+
// ── Step 4: Finalize MP4 (or GIF's MP4 intermediate) ─────
|
|
472
534
|
setState('encoding');
|
|
473
|
-
setPhase('Finalizing video…');
|
|
535
|
+
setPhase(effectiveOutputFormat === 'gif' ? 'Finalizing GIF frames…' : 'Finalizing video…');
|
|
474
536
|
setProgress(95);
|
|
475
537
|
|
|
476
538
|
let outputBytes: ArrayBuffer | Uint8Array = await encoder.finalize();
|
|
@@ -478,15 +540,36 @@ export function useVideoExport(): VideoExportResult {
|
|
|
478
540
|
|
|
479
541
|
if (cancelledRef.current) return;
|
|
480
542
|
|
|
481
|
-
// ── Step 4b:
|
|
482
|
-
|
|
483
|
-
|
|
543
|
+
// ── Step 4b: GIF palette transcode or audio mux ───────────
|
|
544
|
+
if (effectiveOutputFormat === 'gif') {
|
|
545
|
+
setPhase('Generating GIF palette…');
|
|
546
|
+
const videoOnly =
|
|
547
|
+
outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
548
|
+
const gifAbort = new AbortController();
|
|
549
|
+
gifAbortRef.current = gifAbort;
|
|
550
|
+
try {
|
|
551
|
+
outputBytes = await transcodeMp4ToGifWithFfmpegWasm(
|
|
552
|
+
videoOnly,
|
|
553
|
+
{ width, height, loop: 0 },
|
|
554
|
+
config.ffmpegWasm,
|
|
555
|
+
gifAbort.signal,
|
|
556
|
+
);
|
|
557
|
+
} finally {
|
|
558
|
+
if (gifAbortRef.current === gifAbort) gifAbortRef.current = null;
|
|
559
|
+
}
|
|
560
|
+
} else if (useFfmpegAudio && renderedAudio) {
|
|
561
|
+
// Video is finalized; add audio in a single copy-video pass.
|
|
484
562
|
setPhase('Muxing audio…');
|
|
485
563
|
try {
|
|
486
564
|
const wav = audioBufferToWav(renderedAudio);
|
|
487
565
|
const videoOnly =
|
|
488
566
|
outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
489
|
-
outputBytes = await muxAudioWithFfmpegWasm(
|
|
567
|
+
outputBytes = await muxAudioWithFfmpegWasm(
|
|
568
|
+
videoOnly,
|
|
569
|
+
wav,
|
|
570
|
+
audioBitrate,
|
|
571
|
+
config.ffmpegWasm,
|
|
572
|
+
);
|
|
490
573
|
audioIncludedLocal = true;
|
|
491
574
|
} catch (audioErr: unknown) {
|
|
492
575
|
audioIncludedLocal = false;
|
|
@@ -503,14 +586,17 @@ export function useVideoExport(): VideoExportResult {
|
|
|
503
586
|
// output may be typed over SharedArrayBuffer).
|
|
504
587
|
const finalBytes =
|
|
505
588
|
outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
|
|
506
|
-
const
|
|
589
|
+
const mimeType = effectiveOutputFormat === 'gif' ? 'image/gif' : 'video/mp4';
|
|
590
|
+
const blob = new Blob([finalBytes], { type: mimeType });
|
|
507
591
|
const url = URL.createObjectURL(blob);
|
|
508
592
|
downloadUrlRef.current = url;
|
|
509
593
|
|
|
510
594
|
setDownloadUrl(url);
|
|
511
595
|
setFileSize(finalBytes.byteLength);
|
|
512
596
|
setAudioIncluded(audioIncludedLocal);
|
|
513
|
-
setAudioSkippedReason(
|
|
597
|
+
setAudioSkippedReason(
|
|
598
|
+
effectiveOutputFormat === 'gif' || audioIncludedLocal ? null : audioReasonLocal,
|
|
599
|
+
);
|
|
514
600
|
setState('complete');
|
|
515
601
|
setProgress(100);
|
|
516
602
|
setPhase('Export complete');
|
|
@@ -532,6 +618,8 @@ export function useVideoExport(): VideoExportResult {
|
|
|
532
618
|
encoderRef.current.close();
|
|
533
619
|
encoderRef.current = null;
|
|
534
620
|
}
|
|
621
|
+
gifAbortRef.current?.abort();
|
|
622
|
+
gifAbortRef.current = null;
|
|
535
623
|
frameCapture.destroy();
|
|
536
624
|
}
|
|
537
625
|
},
|
|
@@ -543,6 +631,7 @@ export function useVideoExport(): VideoExportResult {
|
|
|
543
631
|
progress,
|
|
544
632
|
phase,
|
|
545
633
|
duration,
|
|
634
|
+
outputFormat,
|
|
546
635
|
backend,
|
|
547
636
|
downloadUrl,
|
|
548
637
|
fileSize,
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type {
|
|
|
25
25
|
VideoExportState,
|
|
26
26
|
VideoExportConfig,
|
|
27
27
|
VideoExportResult,
|
|
28
|
+
VideoOutputFormat,
|
|
28
29
|
} from './hooks/useVideoExport.js';
|
|
29
30
|
|
|
30
31
|
export { useFrameCapture } from './hooks/useFrameCapture.js';
|
|
@@ -33,6 +34,7 @@ export type { FrameCaptureHandle } from './hooks/useFrameCapture.js';
|
|
|
33
34
|
// ── Encoder Utilities (for advanced usage) ─────────────────────────
|
|
34
35
|
export { supportsWebCodecs, supportsWebCodecsH264, createEncoder } from './mainThreadEncoder.js';
|
|
35
36
|
export type { MainThreadEncoder, EncoderConfig } from './mainThreadEncoder.js';
|
|
37
|
+
export type { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
|
|
36
38
|
|
|
37
39
|
// ── Audio (capability probe) ───────────────────────────────────────
|
|
38
40
|
export { supportsWebCodecsAac } from './audioTrack.js';
|
package/src/mainThreadEncoder.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Requirements: Chrome 94+ / Edge 94+ (WebCodecs support).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { bitrateForQuality } from '@bendyline/squisq-video';
|
|
14
|
+
import { bitrateForQuality, validateVideoExportOptions } from '@bendyline/squisq-video';
|
|
15
15
|
|
|
16
16
|
import { createMp4Muxer, type Mp4MuxerHandle } from './mp4Mux.js';
|
|
17
17
|
|
|
@@ -33,7 +33,7 @@ export interface EncoderConfig {
|
|
|
33
33
|
|
|
34
34
|
export interface MainThreadEncoder {
|
|
35
35
|
/** Encode a single frame. The bitmap is closed after encoding. */
|
|
36
|
-
encodeFrame(bitmap: ImageBitmap, frameIndex: number): void
|
|
36
|
+
encodeFrame(bitmap: ImageBitmap, frameIndex: number): Promise<void>;
|
|
37
37
|
/**
|
|
38
38
|
* Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
|
|
39
39
|
* Only valid when the encoder was created with an `audio` config; otherwise a
|
|
@@ -81,6 +81,7 @@ export async function supportsWebCodecsH264(config: EncoderConfig): Promise<bool
|
|
|
81
81
|
* Throws if WebCodecs is not available.
|
|
82
82
|
*/
|
|
83
83
|
export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
84
|
+
validateVideoExportOptions(config);
|
|
84
85
|
if (!supportsWebCodecs()) {
|
|
85
86
|
throw new Error(
|
|
86
87
|
'WebCodecs is not available in this browser. ' +
|
|
@@ -88,12 +89,6 @@ export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
92
|
const muxer: Mp4MuxerHandle = createMp4Muxer({
|
|
98
93
|
width: config.width,
|
|
99
94
|
height: config.height,
|
|
@@ -126,10 +121,10 @@ export function createEncoder(config: EncoderConfig): MainThreadEncoder {
|
|
|
126
121
|
});
|
|
127
122
|
|
|
128
123
|
return {
|
|
129
|
-
encodeFrame(bitmap: ImageBitmap, frameIndex: number) {
|
|
124
|
+
async encodeFrame(bitmap: ImageBitmap, frameIndex: number): Promise<void> {
|
|
130
125
|
if (closed) {
|
|
131
126
|
bitmap.close();
|
|
132
|
-
|
|
127
|
+
throw new Error('Encoder already closed');
|
|
133
128
|
}
|
|
134
129
|
const timestamp = Math.round(frameIndex * frameDuration);
|
|
135
130
|
const frame = new VideoFrame(bitmap, { timestamp });
|
package/src/workerEncoder.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import type { MainThreadEncoder, EncoderConfig } from './mainThreadEncoder.js';
|
|
12
12
|
import type { MainToWorkerMessage, WorkerToMainMessage } from './workers/workerTypes.js';
|
|
13
|
+
import type { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
|
|
14
|
+
import { validateVideoExportOptions } from '@bendyline/squisq-video';
|
|
13
15
|
|
|
14
16
|
/** Resolves once the worker has reported which backend it picked. */
|
|
15
17
|
export interface WorkerEncoder extends MainThreadEncoder {
|
|
@@ -17,23 +19,26 @@ export interface WorkerEncoder extends MainThreadEncoder {
|
|
|
17
19
|
readonly ready: Promise<'webcodecs' | 'ffmpeg-wasm'>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
export function createWorkerEncoder(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
);
|
|
25
|
-
}
|
|
22
|
+
export function createWorkerEncoder(
|
|
23
|
+
config: EncoderConfig & { ffmpegWasm?: FfmpegWasmLoadConfig },
|
|
24
|
+
): WorkerEncoder {
|
|
25
|
+
validateVideoExportOptions(config);
|
|
26
26
|
|
|
27
27
|
const worker = new Worker(new URL('./workers/encode.worker.js', import.meta.url), {
|
|
28
28
|
type: 'module',
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
let closed =
|
|
31
|
+
let state: 'open' | 'finalizing' | 'closed' = 'open';
|
|
32
32
|
let fatalError: Error | null = null;
|
|
33
33
|
let finalizeResolve: ((buffer: ArrayBuffer) => void) | null = null;
|
|
34
34
|
let finalizeReject: ((err: Error) => void) | null = null;
|
|
35
35
|
let readyResolve: ((backend: 'webcodecs' | 'ffmpeg-wasm') => void) | null = null;
|
|
36
36
|
let readyReject: ((err: Error) => void) | null = null;
|
|
37
|
+
let readySettled = false;
|
|
38
|
+
const frameWaiters = new Map<
|
|
39
|
+
number,
|
|
40
|
+
{ promise: Promise<void>; resolve: () => void; reject: (err: Error) => void }
|
|
41
|
+
>();
|
|
37
42
|
|
|
38
43
|
const ready = new Promise<'webcodecs' | 'ffmpeg-wasm'>((resolve, reject) => {
|
|
39
44
|
readyResolve = resolve;
|
|
@@ -46,14 +51,26 @@ export function createWorkerEncoder(config: EncoderConfig): WorkerEncoder {
|
|
|
46
51
|
worker.postMessage(msg, transfer ?? []);
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
// Keep async lifecycle checks observable to TypeScript: event handlers can
|
|
55
|
+
// change state while finalize() is awaiting pending frame acknowledgements.
|
|
56
|
+
const currentState = () => state;
|
|
57
|
+
|
|
49
58
|
worker.onmessage = (event: MessageEvent<WorkerToMainMessage>) => {
|
|
50
59
|
const msg = event.data;
|
|
51
60
|
switch (msg.type) {
|
|
52
61
|
case 'capabilities':
|
|
62
|
+
readySettled = true;
|
|
53
63
|
readyResolve?.(msg.backend);
|
|
54
64
|
readyResolve = readyReject = null;
|
|
55
65
|
break;
|
|
66
|
+
case 'frame-complete': {
|
|
67
|
+
const waiter = frameWaiters.get(msg.frameIndex);
|
|
68
|
+
waiter?.resolve();
|
|
69
|
+
frameWaiters.delete(msg.frameIndex);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
56
72
|
case 'complete':
|
|
73
|
+
state = 'closed';
|
|
57
74
|
finalizeResolve?.(msg.data);
|
|
58
75
|
finalizeResolve = finalizeReject = null;
|
|
59
76
|
worker.terminate();
|
|
@@ -61,8 +78,12 @@ export function createWorkerEncoder(config: EncoderConfig): WorkerEncoder {
|
|
|
61
78
|
case 'error': {
|
|
62
79
|
const err = new Error(msg.message);
|
|
63
80
|
fatalError = err;
|
|
81
|
+
state = 'closed';
|
|
82
|
+
readySettled = true;
|
|
64
83
|
readyReject?.(err);
|
|
65
84
|
finalizeReject?.(err);
|
|
85
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
86
|
+
frameWaiters.clear();
|
|
66
87
|
readyResolve = readyReject = null;
|
|
67
88
|
finalizeResolve = finalizeReject = null;
|
|
68
89
|
worker.terminate();
|
|
@@ -75,8 +96,12 @@ export function createWorkerEncoder(config: EncoderConfig): WorkerEncoder {
|
|
|
75
96
|
worker.onerror = (event) => {
|
|
76
97
|
const err = new Error(event.message || 'Worker error');
|
|
77
98
|
fatalError = err;
|
|
99
|
+
state = 'closed';
|
|
100
|
+
readySettled = true;
|
|
78
101
|
readyReject?.(err);
|
|
79
102
|
finalizeReject?.(err);
|
|
103
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
104
|
+
frameWaiters.clear();
|
|
80
105
|
readyResolve = readyReject = null;
|
|
81
106
|
finalizeResolve = finalizeReject = null;
|
|
82
107
|
worker.terminate();
|
|
@@ -88,24 +113,41 @@ export function createWorkerEncoder(config: EncoderConfig): WorkerEncoder {
|
|
|
88
113
|
height: config.height,
|
|
89
114
|
fps: config.fps,
|
|
90
115
|
quality: config.quality,
|
|
116
|
+
...(config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}),
|
|
91
117
|
});
|
|
92
118
|
|
|
93
119
|
return {
|
|
94
120
|
ready,
|
|
95
121
|
|
|
96
|
-
encodeFrame(bitmap: ImageBitmap, frameIndex: number) {
|
|
97
|
-
if (
|
|
122
|
+
encodeFrame(bitmap: ImageBitmap, frameIndex: number): Promise<void> {
|
|
123
|
+
if (state !== 'open' || fatalError) {
|
|
124
|
+
bitmap.close();
|
|
125
|
+
return Promise.reject(fatalError ?? new Error('Encoder is not accepting frames'));
|
|
126
|
+
}
|
|
127
|
+
if (frameWaiters.has(frameIndex)) {
|
|
98
128
|
bitmap.close();
|
|
99
|
-
return;
|
|
129
|
+
return Promise.reject(new Error(`Frame ${frameIndex} was submitted more than once`));
|
|
100
130
|
}
|
|
131
|
+
let resolveFrame!: () => void;
|
|
132
|
+
let rejectFrame!: (err: Error) => void;
|
|
133
|
+
const promise = new Promise<void>((resolve, reject) => {
|
|
134
|
+
resolveFrame = resolve;
|
|
135
|
+
rejectFrame = reject;
|
|
136
|
+
});
|
|
137
|
+
frameWaiters.set(frameIndex, { promise, resolve: resolveFrame, reject: rejectFrame });
|
|
101
138
|
const timestamp = Math.round(frameIndex * frameDuration);
|
|
102
139
|
post({ type: 'frame', bitmap, frameIndex, timestamp }, [bitmap]);
|
|
140
|
+
return promise;
|
|
103
141
|
},
|
|
104
142
|
|
|
105
143
|
async finalize(): Promise<ArrayBuffer> {
|
|
106
|
-
if (
|
|
144
|
+
if (state !== 'open') throw new Error('Encoder already closed or finalizing');
|
|
107
145
|
if (fatalError) throw fatalError;
|
|
108
|
-
|
|
146
|
+
state = 'finalizing';
|
|
147
|
+
await Promise.all(Array.from(frameWaiters.values(), (waiter) => waiter.promise));
|
|
148
|
+
if (currentState() === 'closed') {
|
|
149
|
+
throw fatalError ?? new Error('Encoder closed during finalization');
|
|
150
|
+
}
|
|
109
151
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
110
152
|
finalizeResolve = resolve;
|
|
111
153
|
finalizeReject = reject;
|
|
@@ -114,8 +156,18 @@ export function createWorkerEncoder(config: EncoderConfig): WorkerEncoder {
|
|
|
114
156
|
},
|
|
115
157
|
|
|
116
158
|
close() {
|
|
117
|
-
if (closed) return;
|
|
118
|
-
|
|
159
|
+
if (state === 'closed') return;
|
|
160
|
+
state = 'closed';
|
|
161
|
+
const err = new Error('Encoder closed');
|
|
162
|
+
if (!readySettled) {
|
|
163
|
+
readySettled = true;
|
|
164
|
+
readyReject?.(err);
|
|
165
|
+
}
|
|
166
|
+
finalizeReject?.(err);
|
|
167
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
168
|
+
frameWaiters.clear();
|
|
169
|
+
readyResolve = readyReject = null;
|
|
170
|
+
finalizeResolve = finalizeReject = null;
|
|
119
171
|
post({ type: 'cancel' });
|
|
120
172
|
worker.terminate();
|
|
121
173
|
},
|