@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.
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
'concat',
|
|
292
|
-
'
|
|
293
|
-
|
|
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
|
-
|
|
348
|
+
disposeFfmpeg();
|
|
322
349
|
ffmpegSegments.length = 0;
|
|
323
350
|
}
|
|
324
351
|
|
|
325
352
|
// ── Message Handler ────────────────────────────────────────────────
|
|
326
353
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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;
|