@bendyline/squisq-video-react 2.0.2 → 2.1.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.
@@ -1,439 +0,0 @@
1
- /**
2
- * Video Encoding Web Worker
3
- *
4
- * Encodes video frames to MP4 using one of two backends:
5
- * - **WebCodecs** (preferred): Streaming H.264 encoding via VideoEncoder + mp4-muxer.
6
- * Each frame is encoded as it arrives — minimal memory footprint.
7
- * - **ffmpeg.wasm** (fallback): Batch encoding in ~10-second segments.
8
- * Used when WebCodecs is unavailable (older browsers).
9
- *
10
- * Frames arrive as ImageBitmap (zero-copy transfer from main thread).
11
- */
12
-
13
- import type {
14
- MainToWorkerMessage,
15
- WorkerToMainMessage,
16
- InitMessage,
17
- FrameMessage,
18
- } from './workerTypes.js';
19
- import { bitrateForQuality, ffmpegVideoQualityArgs } from '@bendyline/squisq-video';
20
- import type { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
21
-
22
- import { createMp4Muxer, type Mp4MuxerHandle } from '../mp4Mux.js';
23
-
24
- // ── State ──────────────────────────────────────────────────────────
25
-
26
- let backend: 'webcodecs' | 'ffmpeg-wasm' | null = null;
27
- let cancelled = false;
28
-
29
- // WebCodecs state
30
- let videoEncoder: VideoEncoder | null = null;
31
- let muxer: Mp4MuxerHandle | null = null;
32
-
33
- // ffmpeg.wasm state
34
- let ffmpegInstance: unknown = null;
35
- let ffmpegFrames: Array<{ data: Uint8Array; index: number }> = [];
36
- let ffmpegConfig: InitMessage | null = null;
37
-
38
- // Frame tracking
39
- let totalFramesReceived = 0;
40
-
41
- // ── Helpers ────────────────────────────────────────────────────────
42
-
43
- function post(msg: WorkerToMainMessage, transfer?: Transferable[]) {
44
- self.postMessage(msg, { transfer: transfer ?? [] });
45
- }
46
-
47
- function postProgress(percent: number, phase: string) {
48
- post({ type: 'progress', percent, phase });
49
- }
50
-
51
- function postError(message: string) {
52
- post({ type: 'error', message });
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
-
64
- // ── Feature Detection ──────────────────────────────────────────────
65
-
66
- function hasWebCodecs(): boolean {
67
- return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
68
- }
69
-
70
- function hasSharedArrayBuffer(): boolean {
71
- return typeof SharedArrayBuffer !== 'undefined';
72
- }
73
-
74
- /**
75
- * Check whether the browser's WebCodecs implementation actually supports
76
- * the H.264 Baseline profile we use. Linux Chromium ships without the
77
- * proprietary H.264 encoder, so `VideoEncoder` exists but `configure()`
78
- * for `avc1.*` fails asynchronously.
79
- */
80
- async function supportsWebCodecsH264(config: InitMessage): Promise<boolean> {
81
- if (!hasWebCodecs()) return false;
82
- try {
83
- const support = await VideoEncoder.isConfigSupported({
84
- codec: 'avc1.42001f',
85
- width: config.width,
86
- height: config.height,
87
- bitrate: bitrateForQuality(config.quality, config.width, config.height),
88
- framerate: config.fps,
89
- });
90
- return support.supported === true;
91
- } catch {
92
- return false;
93
- }
94
- }
95
-
96
- // ── WebCodecs Backend ──────────────────────────────────────────────
97
-
98
- function initWebCodecs(config: InitMessage) {
99
- muxer = createMp4Muxer({
100
- width: config.width,
101
- height: config.height,
102
- fps: config.fps,
103
- });
104
-
105
- videoEncoder = new VideoEncoder({
106
- output(chunk, meta) {
107
- if (cancelled) return;
108
- muxer!.addVideoChunk(chunk, meta ?? undefined);
109
- },
110
- error(err) {
111
- postError(`WebCodecs encoder error: ${err.message}`);
112
- },
113
- });
114
-
115
- // Deliberate profile split from mainThreadEncoder (avc1.640028, High@4.0):
116
- // this worker is the max-compatibility fallback path, so it uses H.264
117
- // Baseline (avc1.42001f) — widest decoder support at a small quality cost.
118
- videoEncoder.configure({
119
- codec: 'avc1.42001f',
120
- width: config.width,
121
- height: config.height,
122
- bitrate: bitrateForQuality(config.quality, config.width, config.height),
123
- framerate: config.fps,
124
- });
125
- }
126
-
127
- async function encodeFrameWebCodecs(msg: FrameMessage) {
128
- if (!videoEncoder || cancelled) {
129
- msg.bitmap.close();
130
- return;
131
- }
132
-
133
- const frame = new VideoFrame(msg.bitmap, { timestamp: msg.timestamp });
134
- const keyFrame = msg.frameIndex % 30 === 0; // Key frame every 30 frames
135
- videoEncoder.encode(frame, { keyFrame });
136
- frame.close();
137
- msg.bitmap.close();
138
- totalFramesReceived++;
139
-
140
- postProgress(
141
- Math.round((totalFramesReceived / (totalFramesReceived + 1)) * 50),
142
- `Encoding frame ${totalFramesReceived}`,
143
- );
144
- }
145
-
146
- async function finalizeWebCodecs() {
147
- if (!videoEncoder || !muxer || cancelled) return;
148
-
149
- postProgress(50, 'Flushing encoder…');
150
- await videoEncoder.flush();
151
- videoEncoder.close();
152
- videoEncoder = null;
153
-
154
- postProgress(90, 'Finalizing MP4…');
155
- const buffer = muxer.finalize();
156
- muxer = null;
157
-
158
- post({ type: 'complete', data: buffer, size: buffer.byteLength }, [buffer]);
159
- }
160
-
161
- // ── ffmpeg.wasm Backend ────────────────────────────────────────────
162
-
163
- async function initFfmpegWasm(config: InitMessage) {
164
- ffmpegConfig = config;
165
- ffmpegFrames = [];
166
- ffmpegSegments.length = 0;
167
- totalFramesReceived = 0;
168
-
169
- // Lazy-load ffmpeg.wasm
170
- let ffmpeg: {
171
- load: (config?: FfmpegWasmLoadConfig) => Promise<unknown>;
172
- terminate: () => void;
173
- } | null = null;
174
- try {
175
- const { FFmpeg } = await import('@ffmpeg/ffmpeg');
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
- });
183
- ffmpegInstance = ffmpeg;
184
- } catch (err: unknown) {
185
- ffmpeg?.terminate();
186
- const message = err instanceof Error ? err.message : String(err);
187
- throw new Error(
188
- `Failed to load ffmpeg.wasm: ${message}. ` +
189
- 'Ensure @ffmpeg/ffmpeg and @ffmpeg/util are installed and ' +
190
- 'Cross-Origin-Isolation headers (COOP/COEP) are set.',
191
- );
192
- }
193
- }
194
-
195
- async function encodeFrameFfmpeg(msg: FrameMessage) {
196
- if (cancelled) {
197
- msg.bitmap.close();
198
- return;
199
- }
200
-
201
- // Convert ImageBitmap to PNG bytes via OffscreenCanvas
202
- const canvas = new OffscreenCanvas(msg.bitmap.width, msg.bitmap.height);
203
- const ctx = canvas.getContext('2d');
204
- if (!ctx) {
205
- msg.bitmap.close();
206
- postError('Failed to create OffscreenCanvas 2D context');
207
- return;
208
- }
209
- ctx.drawImage(msg.bitmap, 0, 0);
210
- msg.bitmap.close();
211
-
212
- const blob = await canvas.convertToBlob({ type: 'image/png' });
213
- const arrayBuffer = await blob.arrayBuffer();
214
- ffmpegFrames.push({ data: new Uint8Array(arrayBuffer), index: msg.frameIndex });
215
- totalFramesReceived++;
216
-
217
- postProgress(
218
- Math.round((totalFramesReceived / (totalFramesReceived + 1)) * 40),
219
- `Collecting frame ${totalFramesReceived}`,
220
- );
221
-
222
- // Batch encode every ~10 seconds worth of frames
223
- const batchSize = (ffmpegConfig?.fps ?? 30) * 10;
224
- if (ffmpegFrames.length >= batchSize) {
225
- await encodeFfmpegBatch();
226
- }
227
- }
228
-
229
- /** Segments already encoded as MP4 data. */
230
- const ffmpegSegments: Uint8Array[] = [];
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
-
240
- async function encodeFfmpegBatch() {
241
- if (!ffmpegInstance || ffmpegFrames.length === 0 || cancelled) return;
242
-
243
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
- const ffmpeg = ffmpegInstance as any;
245
- const config = ffmpegConfig!;
246
- const batchIndex = ffmpegSegments.length;
247
-
248
- postProgress(40 + batchIndex * 5, `Encoding batch ${batchIndex + 1}…`);
249
-
250
- // Write frames to virtual filesystem
251
- for (const frame of ffmpegFrames) {
252
- const paddedIndex = String(frame.index).padStart(6, '0');
253
- await ffmpeg.writeFile(`frame_${paddedIndex}.png`, frame.data);
254
- }
255
-
256
- const firstIndex = ffmpegFrames[0].index;
257
- const segmentName = `segment_${batchIndex}.mp4`;
258
-
259
- // Encode batch to MP4 segment
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
- );
280
-
281
- // Read segment and clean up frames
282
- const segmentData = await ffmpeg.readFile(segmentName);
283
- ffmpegSegments.push(segmentData);
284
-
285
- // Clean up frame files
286
- for (const frame of ffmpegFrames) {
287
- const paddedIndex = String(frame.index).padStart(6, '0');
288
- await ffmpeg.deleteFile(`frame_${paddedIndex}.png`);
289
- }
290
- await ffmpeg.deleteFile(segmentName);
291
-
292
- // Free batch memory
293
- ffmpegFrames = [];
294
- }
295
-
296
- async function finalizeFfmpeg() {
297
- if (!ffmpegInstance || cancelled) return;
298
-
299
- // Encode any remaining frames
300
- await encodeFfmpegBatch();
301
-
302
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
303
- const ffmpeg = ffmpegInstance as any;
304
-
305
- postProgress(85, 'Concatenating segments…');
306
-
307
- let finalData: Uint8Array;
308
-
309
- if (ffmpegSegments.length === 1) {
310
- // Single segment — no concat needed
311
- finalData = ffmpegSegments[0];
312
- } else if (ffmpegSegments.length > 1) {
313
- // Write segments and concat list
314
- const concatList: string[] = [];
315
- for (let i = 0; i < ffmpegSegments.length; i++) {
316
- const name = `seg_${i}.mp4`;
317
- await ffmpeg.writeFile(name, ffmpegSegments[i]);
318
- concatList.push(`file '${name}'`);
319
- }
320
- await ffmpeg.writeFile('concat.txt', new TextEncoder().encode(concatList.join('\n')));
321
-
322
- await execOrThrow(
323
- ffmpeg,
324
- ['-f', 'concat', '-safe', '0', '-i', 'concat.txt', '-c', 'copy', 'output.mp4'],
325
- 'Concatenating video segments',
326
- );
327
-
328
- finalData = await ffmpeg.readFile('output.mp4');
329
-
330
- // Clean up
331
- for (let i = 0; i < ffmpegSegments.length; i++) {
332
- await ffmpeg.deleteFile(`seg_${i}.mp4`);
333
- }
334
- await ffmpeg.deleteFile('concat.txt');
335
- await ffmpeg.deleteFile('output.mp4');
336
- } else {
337
- postError('No frames were encoded');
338
- return;
339
- }
340
-
341
- postProgress(95, 'Preparing download…');
342
-
343
- // Convert Uint8Array to a transferable ArrayBuffer
344
- const sliced = new Uint8Array(finalData).buffer as ArrayBuffer;
345
- post({ type: 'complete', data: sliced, size: sliced.byteLength }, [sliced]);
346
-
347
- // Clean up
348
- disposeFfmpeg();
349
- ffmpegSegments.length = 0;
350
- }
351
-
352
- // ── Message Handler ────────────────────────────────────────────────
353
-
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
- );
372
- }
373
-
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;
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
- }
395
-
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');
403
- }
404
- break;
405
- }
406
-
407
- case 'cancel': {
408
- if (videoEncoder && videoEncoder.state !== 'closed') {
409
- videoEncoder.close();
410
- videoEncoder = null;
411
- }
412
- muxer = null;
413
- disposeFfmpeg();
414
- ffmpegFrames = [];
415
- ffmpegSegments.length = 0;
416
- backend = null;
417
- break;
418
- }
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
- });
439
- };
@@ -1,11 +0,0 @@
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 {};
@@ -1,89 +0,0 @@
1
- /**
2
- * Worker Message Protocol
3
- *
4
- * Defines the message types exchanged between the main thread and
5
- * the video encoding Web Worker.
6
- */
7
-
8
- import type { VideoQuality, FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
9
-
10
- // ── Main → Worker Messages ─────────────────────────────────────────
11
-
12
- /** Initialize the encoder with video parameters. */
13
- export interface InitMessage {
14
- type: 'init';
15
- width: number;
16
- height: number;
17
- fps: number;
18
- quality: VideoQuality;
19
- ffmpegWasm?: FfmpegWasmLoadConfig;
20
- }
21
-
22
- /** Send a single video frame to the encoder. */
23
- export interface FrameMessage {
24
- type: 'frame';
25
- /** Frame bitmap — transferred (zero-copy) from main thread */
26
- bitmap: ImageBitmap;
27
- /** Frame index (0-based) */
28
- frameIndex: number;
29
- /** Timestamp in microseconds */
30
- timestamp: number;
31
- }
32
-
33
- /** Signal that all frames have been sent; finalize the video. */
34
- export interface FinalizeMessage {
35
- type: 'finalize';
36
- }
37
-
38
- /** Cancel the export and clean up resources. */
39
- export interface CancelMessage {
40
- type: 'cancel';
41
- }
42
-
43
- export type MainToWorkerMessage = InitMessage | FrameMessage | FinalizeMessage | CancelMessage;
44
-
45
- // ── Worker → Main Messages ─────────────────────────────────────────
46
-
47
- /** Encoder backend detection result, sent after init. */
48
- export interface CapabilitiesMessage {
49
- type: 'capabilities';
50
- /** Which encoder backend the worker selected */
51
- backend: 'webcodecs' | 'ffmpeg-wasm';
52
- }
53
-
54
- /** Progress update during encoding. */
55
- export interface ProgressMessage {
56
- type: 'progress';
57
- /** 0–100 completion percentage */
58
- percent: number;
59
- /** Human-readable phase description */
60
- phase: string;
61
- }
62
-
63
- /** Encoding complete — MP4 data returned. */
64
- export interface CompleteMessage {
65
- type: 'complete';
66
- /** MP4 file data — transferred (zero-copy) back to main thread */
67
- data: ArrayBuffer;
68
- /** File size in bytes */
69
- size: number;
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
-
78
- /** An error occurred during encoding. */
79
- export interface ErrorMessage {
80
- type: 'error';
81
- message: string;
82
- }
83
-
84
- export type WorkerToMainMessage =
85
- | CapabilitiesMessage
86
- | FrameCompleteMessage
87
- | ProgressMessage
88
- | CompleteMessage
89
- | ErrorMessage;