@bendyline/squisq-video-react 1.2.2 → 2.0.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.
@@ -17,6 +17,7 @@ import type {
17
17
  FrameMessage,
18
18
  } from './workerTypes.js';
19
19
  import { bitrateForQuality, ffmpegVideoQualityArgs } from '@bendyline/squisq-video';
20
+ import type { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
20
21
 
21
22
  import { createMp4Muxer, type Mp4MuxerHandle } from '../mp4Mux.js';
22
23
 
@@ -51,6 +52,15 @@ function postError(message: string) {
51
52
  post({ type: 'error', message });
52
53
  }
53
54
 
55
+ function disposeFfmpeg(): void {
56
+ const instance = ffmpegInstance as { terminate?: () => void } | null;
57
+ try {
58
+ instance?.terminate?.();
59
+ } finally {
60
+ ffmpegInstance = null;
61
+ }
62
+ }
63
+
54
64
  // ── Feature Detection ──────────────────────────────────────────────
55
65
 
56
66
  function hasWebCodecs(): boolean {
@@ -153,17 +163,28 @@ async function finalizeWebCodecs() {
153
163
  async function initFfmpegWasm(config: InitMessage) {
154
164
  ffmpegConfig = config;
155
165
  ffmpegFrames = [];
166
+ ffmpegSegments.length = 0;
156
167
  totalFramesReceived = 0;
157
168
 
158
169
  // Lazy-load ffmpeg.wasm
170
+ let ffmpeg: {
171
+ load: (config?: FfmpegWasmLoadConfig) => Promise<unknown>;
172
+ terminate: () => void;
173
+ } | null = null;
159
174
  try {
160
175
  const { FFmpeg } = await import('@ffmpeg/ffmpeg');
161
- const ffmpeg = new FFmpeg();
162
- await ffmpeg.load();
176
+ ffmpeg = new FFmpeg();
177
+ await ffmpeg.load({
178
+ ...config.ffmpegWasm,
179
+ classWorkerURL:
180
+ config.ffmpegWasm?.classWorkerURL ??
181
+ new URL('./ffmpeg.class-worker.js', import.meta.url).href,
182
+ });
163
183
  ffmpegInstance = ffmpeg;
164
184
  } catch (err: unknown) {
185
+ ffmpeg?.terminate();
165
186
  const message = err instanceof Error ? err.message : String(err);
166
- postError(
187
+ throw new Error(
167
188
  `Failed to load ffmpeg.wasm: ${message}. ` +
168
189
  'Ensure @ffmpeg/ffmpeg and @ffmpeg/util are installed and ' +
169
190
  'Cross-Origin-Isolation headers (COOP/COEP) are set.',
@@ -208,6 +229,14 @@ async function encodeFrameFfmpeg(msg: FrameMessage) {
208
229
  /** Segments already encoded as MP4 data. */
209
230
  const ffmpegSegments: Uint8Array[] = [];
210
231
 
232
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
+ async function execOrThrow(ffmpeg: any, args: string[], operation: string): Promise<void> {
234
+ const exitCode = await ffmpeg.exec(args);
235
+ if (exitCode !== 0) {
236
+ throw new Error(`${operation} failed with ffmpeg exit code ${exitCode}`);
237
+ }
238
+ }
239
+
211
240
  async function encodeFfmpegBatch() {
212
241
  if (!ffmpegInstance || ffmpegFrames.length === 0 || cancelled) return;
213
242
 
@@ -228,22 +257,26 @@ async function encodeFfmpegBatch() {
228
257
  const segmentName = `segment_${batchIndex}.mp4`;
229
258
 
230
259
  // Encode batch to MP4 segment
231
- await ffmpeg.exec([
232
- '-framerate',
233
- String(config.fps),
234
- '-start_number',
235
- String(firstIndex),
236
- '-i',
237
- `frame_%06d.png`,
238
- '-frames:v',
239
- String(ffmpegFrames.length),
240
- '-c:v',
241
- 'libx264',
242
- '-pix_fmt',
243
- 'yuv420p',
244
- ...ffmpegVideoQualityArgs(config.quality),
245
- segmentName,
246
- ]);
260
+ await execOrThrow(
261
+ ffmpeg,
262
+ [
263
+ '-framerate',
264
+ String(config.fps),
265
+ '-start_number',
266
+ String(firstIndex),
267
+ '-i',
268
+ `frame_%06d.png`,
269
+ '-frames:v',
270
+ String(ffmpegFrames.length),
271
+ '-c:v',
272
+ 'libx264',
273
+ '-pix_fmt',
274
+ 'yuv420p',
275
+ ...ffmpegVideoQualityArgs(config.quality),
276
+ segmentName,
277
+ ],
278
+ `Encoding video segment ${batchIndex + 1}`,
279
+ );
247
280
 
248
281
  // Read segment and clean up frames
249
282
  const segmentData = await ffmpeg.readFile(segmentName);
@@ -286,17 +319,11 @@ async function finalizeFfmpeg() {
286
319
  }
287
320
  await ffmpeg.writeFile('concat.txt', new TextEncoder().encode(concatList.join('\n')));
288
321
 
289
- await ffmpeg.exec([
290
- '-f',
291
- 'concat',
292
- '-safe',
293
- '0',
294
- '-i',
295
- 'concat.txt',
296
- '-c',
297
- 'copy',
298
- 'output.mp4',
299
- ]);
322
+ await execOrThrow(
323
+ ffmpeg,
324
+ ['-f', 'concat', '-safe', '0', '-i', 'concat.txt', '-c', 'copy', 'output.mp4'],
325
+ 'Concatenating video segments',
326
+ );
300
327
 
301
328
  finalData = await ffmpeg.readFile('output.mp4');
302
329
 
@@ -318,74 +345,95 @@ async function finalizeFfmpeg() {
318
345
  post({ type: 'complete', data: sliced, size: sliced.byteLength }, [sliced]);
319
346
 
320
347
  // Clean up
321
- ffmpegInstance = null;
348
+ disposeFfmpeg();
322
349
  ffmpegSegments.length = 0;
323
350
  }
324
351
 
325
352
  // ── Message Handler ────────────────────────────────────────────────
326
353
 
327
- self.onmessage = async (event: MessageEvent<MainToWorkerMessage>) => {
328
- const msg = event.data;
329
-
330
- try {
331
- switch (msg.type) {
332
- case 'init': {
333
- cancelled = false;
334
- totalFramesReceived = 0;
335
-
336
- if (await supportsWebCodecsH264(msg)) {
337
- backend = 'webcodecs';
338
- initWebCodecs(msg);
339
- } else if (hasSharedArrayBuffer()) {
340
- backend = 'ffmpeg-wasm';
341
- await initFfmpegWasm(msg);
342
- } else {
343
- postError(
344
- 'No video encoding support available. ' +
345
- 'WebCodecs H.264 is unavailable (typical on Linux Chromium without proprietary codecs) ' +
346
- 'and ffmpeg.wasm requires SharedArrayBuffer (Cross-Origin-Isolation headers).',
347
- );
348
- return;
349
- }
350
-
351
- post({ type: 'capabilities', backend });
352
- postProgress(0, 'Encoder ready');
353
- break;
354
+ async function handleMessage(msg: MainToWorkerMessage): Promise<void> {
355
+ switch (msg.type) {
356
+ case 'init': {
357
+ cancelled = false;
358
+ totalFramesReceived = 0;
359
+
360
+ if (await supportsWebCodecsH264(msg)) {
361
+ backend = 'webcodecs';
362
+ initWebCodecs(msg);
363
+ } else if (hasSharedArrayBuffer()) {
364
+ backend = 'ffmpeg-wasm';
365
+ await initFfmpegWasm(msg);
366
+ } else {
367
+ throw new Error(
368
+ 'No video encoding support available. ' +
369
+ 'WebCodecs H.264 is unavailable (typical on Linux Chromium without proprietary codecs) ' +
370
+ 'and ffmpeg.wasm requires SharedArrayBuffer (Cross-Origin-Isolation headers).',
371
+ );
354
372
  }
355
373
 
356
- case 'frame': {
357
- if (backend === 'webcodecs') {
358
- await encodeFrameWebCodecs(msg);
359
- } else if (backend === 'ffmpeg-wasm') {
360
- await encodeFrameFfmpeg(msg);
361
- }
362
- break;
374
+ post({ type: 'capabilities', backend });
375
+ postProgress(0, 'Encoder ready');
376
+ break;
377
+ }
378
+
379
+ case 'frame': {
380
+ if (cancelled) {
381
+ msg.bitmap.close();
382
+ return;
363
383
  }
384
+ if (backend === 'webcodecs') {
385
+ await encodeFrameWebCodecs(msg);
386
+ } else if (backend === 'ffmpeg-wasm') {
387
+ await encodeFrameFfmpeg(msg);
388
+ } else {
389
+ msg.bitmap.close();
390
+ throw new Error('Encoder received a frame before it was initialized');
391
+ }
392
+ post({ type: 'frame-complete', frameIndex: msg.frameIndex });
393
+ break;
394
+ }
364
395
 
365
- case 'finalize': {
366
- if (backend === 'webcodecs') {
367
- await finalizeWebCodecs();
368
- } else if (backend === 'ffmpeg-wasm') {
369
- await finalizeFfmpeg();
370
- }
371
- break;
396
+ case 'finalize': {
397
+ if (backend === 'webcodecs') {
398
+ await finalizeWebCodecs();
399
+ } else if (backend === 'ffmpeg-wasm') {
400
+ await finalizeFfmpeg();
401
+ } else {
402
+ throw new Error('Encoder cannot finalize before it is initialized');
372
403
  }
404
+ break;
405
+ }
373
406
 
374
- case 'cancel': {
375
- cancelled = true;
376
- if (videoEncoder && videoEncoder.state !== 'closed') {
377
- videoEncoder.close();
378
- videoEncoder = null;
379
- }
380
- muxer = null;
381
- ffmpegInstance = null;
382
- ffmpegFrames = [];
383
- ffmpegSegments.length = 0;
384
- break;
407
+ case 'cancel': {
408
+ if (videoEncoder && videoEncoder.state !== 'closed') {
409
+ videoEncoder.close();
410
+ videoEncoder = null;
385
411
  }
412
+ muxer = null;
413
+ disposeFfmpeg();
414
+ ffmpegFrames = [];
415
+ ffmpegSegments.length = 0;
416
+ backend = null;
417
+ break;
386
418
  }
387
- } catch (err: unknown) {
388
- const message = err instanceof Error ? err.message : String(err);
389
- postError(message);
390
419
  }
420
+ }
421
+
422
+ // MessageEvent callbacks are not implicitly serialized: an async frame handler
423
+ // can still be running when finalize arrives. Keep one queue so frame ownership,
424
+ // ffmpeg's virtual filesystem, and finalization have a single ordering boundary.
425
+ let messageQueue: Promise<void> = Promise.resolve();
426
+
427
+ self.onmessage = (event: MessageEvent<MainToWorkerMessage>) => {
428
+ const msg = event.data;
429
+ // Cancellation must become visible to the currently-running operation without
430
+ // waiting behind all queued frames. Cleanup itself remains ordered.
431
+ if (msg.type === 'cancel') cancelled = true;
432
+
433
+ messageQueue = messageQueue
434
+ .then(() => handleMessage(msg))
435
+ .catch((err: unknown) => {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ postError(message);
438
+ });
391
439
  };
@@ -0,0 +1,11 @@
1
+ /// <reference lib="webworker" />
2
+ /// <reference lib="dom" />
3
+
4
+ // @ffmpeg/ffmpeg normally locates this worker relative to its own module. That
5
+ // assumption does not survive every consumer bundler (and is especially brittle
6
+ // when FFmpeg is created inside our encoding worker), so publish an explicit,
7
+ // stable default worker asset alongside the Squisq encoder worker. Advanced
8
+ // hosts can still override it through FfmpegWasmLoadConfig.
9
+ import '@ffmpeg/ffmpeg/worker';
10
+
11
+ export {};
@@ -5,7 +5,7 @@
5
5
  * the video encoding Web Worker.
6
6
  */
7
7
 
8
- import type { VideoQuality } from '@bendyline/squisq-video';
8
+ import type { VideoQuality, FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
9
9
 
10
10
  // ── Main → Worker Messages ─────────────────────────────────────────
11
11
 
@@ -16,6 +16,7 @@ export interface InitMessage {
16
16
  height: number;
17
17
  fps: number;
18
18
  quality: VideoQuality;
19
+ ffmpegWasm?: FfmpegWasmLoadConfig;
19
20
  }
20
21
 
21
22
  /** Send a single video frame to the encoder. */
@@ -68,6 +69,12 @@ export interface CompleteMessage {
68
69
  size: number;
69
70
  }
70
71
 
72
+ /** A frame has been fully consumed by the selected backend. */
73
+ export interface FrameCompleteMessage {
74
+ type: 'frame-complete';
75
+ frameIndex: number;
76
+ }
77
+
71
78
  /** An error occurred during encoding. */
72
79
  export interface ErrorMessage {
73
80
  type: 'error';
@@ -76,6 +83,7 @@ export interface ErrorMessage {
76
83
 
77
84
  export type WorkerToMainMessage =
78
85
  | CapabilitiesMessage
86
+ | FrameCompleteMessage
79
87
  | ProgressMessage
80
88
  | CompleteMessage
81
89
  | ErrorMessage;