@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.
@@ -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 { VideoQuality, VideoOrientation, AudioTimelineClip } from '@bendyline/squisq-video';
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 fps = config.fps ?? 30;
276
+ const effectiveOutputFormat = config.outputFormat ?? 'mp4';
277
+ const fps = config.fps ?? (effectiveOutputFormat === 'gif' ? 10 : 30);
248
278
  const orientation = config.orientation ?? 'landscape';
249
- const { width, height } = resolveDimensions({ orientation });
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
- const timeline = computeAudioTimeline(doc, 0);
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({ width, height, fps, quality });
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: ffmpeg.wasm audio mux (tier 2) ───────────────
482
- // Video is finalized; add the audio in a single copy-video pass.
483
- if (useFfmpegAudio && renderedAudio) {
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(videoOnly, wav, audioBitrate);
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 blob = new Blob([finalBytes], { type: 'video/mp4' });
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(audioIncludedLocal ? null : audioReasonLocal);
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';
@@ -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
- return;
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 });
@@ -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(config: EncoderConfig): WorkerEncoder {
21
- if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
22
- throw new Error(
23
- `Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`,
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 = false;
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 (closed || fatalError) {
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 (closed) throw new Error('Encoder already closed');
144
+ if (state !== 'open') throw new Error('Encoder already closed or finalizing');
107
145
  if (fatalError) throw fatalError;
108
- closed = true;
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
- closed = true;
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
  },