@bendyline/squisq-video-react 2.0.2 → 2.2.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.
Files changed (44) hide show
  1. package/COPYING.GPL-2.0.txt +339 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +39 -0
  4. package/README.md +34 -7
  5. package/dist/chunk-KQG6DZ7G.js +549 -0
  6. package/dist/chunk-VI5AIVOQ.js +1951 -0
  7. package/dist/chunk-VILZP3GL.js +856 -0
  8. package/dist/chunk-YZN7D4IC.js +300 -0
  9. package/dist/components/index.d.ts +90 -0
  10. package/dist/components/index.js +11 -0
  11. package/dist/encoder/index.d.ts +95 -0
  12. package/dist/encoder/index.js +13 -0
  13. package/dist/hooks/index.d.ts +32 -0
  14. package/dist/hooks/index.js +10 -0
  15. package/dist/index.d.ts +8 -277
  16. package/dist/index.js +12 -1503
  17. package/dist/useVideoExport-NtqZVgaz.d.ts +115 -0
  18. package/dist/workers/encode.worker.js +37 -20
  19. package/dist/workers/ffmpeg.class-worker.js +0 -1
  20. package/package.json +26 -8
  21. package/dist/chunk-6DKLHXX3.js +0 -47
  22. package/dist/chunk-6DKLHXX3.js.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/workers/encode.worker.js.map +0 -1
  25. package/dist/workers/ffmpeg.class-worker.js.map +0 -1
  26. package/src/VideoExportButton.tsx +0 -87
  27. package/src/VideoExportModal.tsx +0 -553
  28. package/src/__tests__/audioTrack.test.ts +0 -108
  29. package/src/__tests__/gifTranscode.test.ts +0 -118
  30. package/src/__tests__/mp4MuxAudio.test.ts +0 -72
  31. package/src/__tests__/useVideoExportGif.test.ts +0 -155
  32. package/src/__tests__/videoExportProps.test.tsx +0 -148
  33. package/src/__tests__/workerEncoder.test.ts +0 -143
  34. package/src/audioTrack.ts +0 -332
  35. package/src/gifTranscode.ts +0 -75
  36. package/src/hooks/useFrameCapture.ts +0 -268
  37. package/src/hooks/useVideoExport.ts +0 -647
  38. package/src/index.ts +0 -40
  39. package/src/mainThreadEncoder.ts +0 -158
  40. package/src/mp4Mux.ts +0 -123
  41. package/src/workerEncoder.ts +0 -175
  42. package/src/workers/encode.worker.ts +0 -439
  43. package/src/workers/ffmpeg.class-worker.ts +0 -11
  44. package/src/workers/workerTypes.ts +0 -89
@@ -1,647 +0,0 @@
1
- /**
2
- * useVideoExport — Main orchestration hook for browser video export.
3
- *
4
- * Coordinates frame capture (hidden iframe + html2canvas) with
5
- * main-thread WebCodecs encoding via mp4-muxer. Manages the full
6
- * lifecycle: prepare → capture + encode → download.
7
- *
8
- * Encoding runs on the main thread because frame capture via html2canvas
9
- * (~100-200ms per frame) is the bottleneck, not encoding (~1ms per frame
10
- * with hardware-accelerated WebCodecs). Worker offloading would add
11
- * complexity with minimal benefit.
12
- *
13
- * Usage:
14
- * const { state, progress, phase, startExport, cancel, downloadUrl } = useVideoExport();
15
- * <button onClick={() => startExport(doc, options)}>Export</button>
16
- */
17
-
18
- import { useState, useRef, useCallback, useEffect } from 'react';
19
- import type { Doc } from '@bendyline/squisq/schemas';
20
- import type { MediaProvider } from '@bendyline/squisq/schemas';
21
- import type {
22
- VideoQuality,
23
- VideoOrientation,
24
- AudioTimelineClip,
25
- FfmpegWasmLoadConfig,
26
- } from '@bendyline/squisq-video';
27
- import { resolveDimensions, computeAudioTimeline, QUALITY_PRESETS } from '@bendyline/squisq-video';
28
- import type { CaptionMode } from '@bendyline/squisq-react';
29
- import {
30
- createEncoder,
31
- supportsWebCodecs,
32
- supportsWebCodecsH264,
33
- type MainThreadEncoder,
34
- } from '../mainThreadEncoder.js';
35
- import { createWorkerEncoder } from '../workerEncoder.js';
36
- import {
37
- supportsWebCodecsAac,
38
- selectAudioTier,
39
- renderAudioTimeline,
40
- encodeAacTrack,
41
- audioBufferToWav,
42
- muxAudioWithFfmpegWasm,
43
- EXPORT_AUDIO_SAMPLE_RATE,
44
- EXPORT_AUDIO_CHANNELS,
45
- } from '../audioTrack.js';
46
- import { transcodeMp4ToGifWithFfmpegWasm } from '../gifTranscode.js';
47
- import { useFrameCapture } from './useFrameCapture.js';
48
-
49
- // ── Audio resolution ───────────────────────────────────────────────
50
-
51
- /**
52
- * Resolve the raw bytes for every unique source in the audio timeline from
53
- * (in order) the pre-collected audio map, the images map, then the
54
- * MediaProvider. Sources that can't be resolved are simply omitted — the
55
- * caller degrades gracefully rather than failing.
56
- */
57
- async function resolveAudioBuffers(
58
- clips: AudioTimelineClip[],
59
- sources: {
60
- audio?: Map<string, ArrayBuffer>;
61
- images?: Map<string, ArrayBuffer>;
62
- mediaProvider?: MediaProvider;
63
- },
64
- ): Promise<Map<string, ArrayBuffer>> {
65
- const srcs = new Set(clips.map((c) => c.src));
66
- const out = new Map<string, ArrayBuffer>();
67
- for (const src of srcs) {
68
- let data = sources.audio?.get(src) ?? sources.images?.get(src);
69
- if (!data && sources.mediaProvider) {
70
- try {
71
- const url = await sources.mediaProvider.resolveUrl(src);
72
- const res = await fetch(url);
73
- if (res.ok) data = await res.arrayBuffer();
74
- } catch {
75
- // Unresolvable source; skip it.
76
- }
77
- }
78
- if (data) out.set(src, data);
79
- }
80
- return out;
81
- }
82
-
83
- // ── Types ──────────────────────────────────────────────────────────
84
-
85
- export type VideoExportState =
86
- | 'idle'
87
- | 'preparing'
88
- | 'capturing'
89
- | 'encoding'
90
- | 'complete'
91
- | 'error';
92
-
93
- /** Browser export container format. */
94
- export type VideoOutputFormat = 'mp4' | 'gif';
95
-
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;
101
- /** Encoding quality preset (default: 'normal') */
102
- quality?: VideoQuality;
103
- /** Frames per second (default: 30) */
104
- fps?: number;
105
- /** Viewport orientation (default: 'landscape') */
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;
111
- /**
112
- * Map of relative image paths to binary data.
113
- * Used to embed images into the render HTML.
114
- */
115
- images?: Map<string, ArrayBuffer>;
116
- /**
117
- * Map of audio segment names to binary data.
118
- * Used to embed audio into the render HTML.
119
- */
120
- audio?: Map<string, ArrayBuffer>;
121
- /** MediaProvider to resolve media URLs (alternative to passing images directly) */
122
- mediaProvider?: MediaProvider;
123
- /** Caption mode for the exported video (default: 'off') */
124
- captionMode?: CaptionMode;
125
- /** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */
126
- playerScript?: string;
127
- /** Optional self-hosted ffmpeg.wasm core URLs for fallback/offline/CSP use. */
128
- ffmpegWasm?: FfmpegWasmLoadConfig;
129
- }
130
-
131
- export interface VideoExportResult {
132
- /** Current export state */
133
- state: VideoExportState;
134
- /** 0–100 progress percentage */
135
- progress: number;
136
- /** Human-readable description of the current phase */
137
- phase: string;
138
- /** Video duration detected from the doc (seconds) */
139
- duration: number;
140
- /** Effective output format for the current or most recent export. */
141
- outputFormat: VideoOutputFormat;
142
- /** Encoder backend ('webcodecs' when WebCodecs H.264 active, 'ffmpeg-wasm' when worker fallback active, null when idle) */
143
- backend: 'webcodecs' | 'ffmpeg-wasm' | null;
144
- /** Blob download URL (populated when state === 'complete') */
145
- downloadUrl: string | null;
146
- /** File size in bytes (populated when state === 'complete') */
147
- fileSize: number;
148
- /**
149
- * Whether an audio track was muxed into the exported MP4 (populated when
150
- * state === 'complete'). False when the doc had no audio or when audio was
151
- * skipped/degraded — see {@link audioSkippedReason}.
152
- */
153
- audioIncluded: boolean;
154
- /**
155
- * Why audio is absent, when `audioIncluded` is false. `null` means the doc
156
- * simply had no audio (not a failure); a non-null string explains a genuine
157
- * capability shortfall or runtime failure. Audio problems never fail the
158
- * export — the video always completes.
159
- */
160
- audioSkippedReason: string | null;
161
- /** Error message (populated when state === 'error') */
162
- error: string | null;
163
- /** Seconds elapsed since export started */
164
- elapsed: number;
165
- /** Estimated seconds remaining (0 when idle or complete) */
166
- estimatedRemaining: number;
167
- /** Start a new export */
168
- startExport: (doc: Doc, config: VideoExportConfig) => Promise<void>;
169
- /** Cancel an in-progress export */
170
- cancel: () => void;
171
- /** Reset state back to idle (e.g., after complete or error) */
172
- reset: () => void;
173
- }
174
-
175
- // ── Hook ───────────────────────────────────────────────────────────
176
-
177
- export function useVideoExport(): VideoExportResult {
178
- const [state, setState] = useState<VideoExportState>('idle');
179
- const [progress, setProgress] = useState(0);
180
- const [phase, setPhase] = useState('');
181
- const [duration, setDuration] = useState(0);
182
- const [outputFormat, setOutputFormat] = useState<VideoOutputFormat>('mp4');
183
- const [backend, setBackend] = useState<'webcodecs' | 'ffmpeg-wasm' | null>(null);
184
- const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
185
- const [fileSize, setFileSize] = useState(0);
186
- const [audioIncluded, setAudioIncluded] = useState(false);
187
- const [audioSkippedReason, setAudioSkippedReason] = useState<string | null>(null);
188
- const [error, setError] = useState<string | null>(null);
189
-
190
- const [elapsed, setElapsed] = useState(0);
191
- const [estimatedRemaining, setEstimatedRemaining] = useState(0);
192
-
193
- const encoderRef = useRef<MainThreadEncoder | null>(null);
194
- const gifAbortRef = useRef<AbortController | null>(null);
195
- const cancelledRef = useRef(false);
196
- const downloadUrlRef = useRef<string | null>(null);
197
- const startTimeRef = useRef<number>(0);
198
- const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
199
-
200
- const frameCapture = useFrameCapture();
201
-
202
- // Clean up on unmount
203
- useEffect(() => {
204
- return () => {
205
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
206
- if (downloadUrlRef.current) {
207
- URL.revokeObjectURL(downloadUrlRef.current);
208
- }
209
- if (encoderRef.current) {
210
- encoderRef.current.close();
211
- }
212
- gifAbortRef.current?.abort();
213
- frameCapture.destroy();
214
- };
215
- }, [frameCapture]);
216
-
217
- const reset = useCallback(() => {
218
- if (downloadUrlRef.current) {
219
- URL.revokeObjectURL(downloadUrlRef.current);
220
- downloadUrlRef.current = null;
221
- }
222
- if (encoderRef.current) {
223
- encoderRef.current.close();
224
- encoderRef.current = null;
225
- }
226
- gifAbortRef.current?.abort();
227
- gifAbortRef.current = null;
228
- frameCapture.destroy();
229
- setState('idle');
230
- setProgress(0);
231
- setPhase('');
232
- setDuration(0);
233
- setOutputFormat('mp4');
234
- setBackend(null);
235
- setDownloadUrl(null);
236
- setFileSize(0);
237
- setAudioIncluded(false);
238
- setAudioSkippedReason(null);
239
- setError(null);
240
- setElapsed(0);
241
- setEstimatedRemaining(0);
242
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
243
- cancelledRef.current = false;
244
- }, [frameCapture]);
245
-
246
- const cancel = useCallback(() => {
247
- cancelledRef.current = true;
248
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
249
- if (encoderRef.current) {
250
- encoderRef.current.close();
251
- encoderRef.current = null;
252
- }
253
- gifAbortRef.current?.abort();
254
- gifAbortRef.current = null;
255
- frameCapture.destroy();
256
- setState('idle');
257
- setProgress(0);
258
- setPhase('Cancelled');
259
- }, [frameCapture]);
260
-
261
- const startExport = useCallback(
262
- async (doc: Doc, config: VideoExportConfig) => {
263
- // Clear previous state
264
- cancelledRef.current = false;
265
- if (downloadUrlRef.current) {
266
- URL.revokeObjectURL(downloadUrlRef.current);
267
- downloadUrlRef.current = null;
268
- }
269
- setDownloadUrl(null);
270
- setFileSize(0);
271
- setAudioIncluded(false);
272
- setAudioSkippedReason(null);
273
- setError(null);
274
-
275
- const quality = config.quality ?? 'normal';
276
- const effectiveOutputFormat = config.outputFormat ?? 'mp4';
277
- const fps = config.fps ?? (effectiveOutputFormat === 'gif' ? 10 : 30);
278
- const orientation = config.orientation ?? 'landscape';
279
- const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === 'mp4';
280
- setOutputFormat(effectiveOutputFormat);
281
-
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
- });
300
- // ── Check browser support ─────────────────────────────────
301
- const webCodecsAvailable = supportsWebCodecs();
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
- }
309
- if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
310
- throw new Error(
311
- 'No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, ' +
312
- 'and the ffmpeg.wasm fallback requires SharedArrayBuffer ' +
313
- '(Cross-Origin-Isolation headers).',
314
- );
315
- }
316
-
317
- // ── Step 1: Prepare ───────────────────────────────────────
318
- setState('preparing');
319
- setPhase('Loading document…');
320
- setProgress(0);
321
- setElapsed(0);
322
- setEstimatedRemaining(0);
323
-
324
- // Start elapsed timer
325
- startTimeRef.current = performance.now();
326
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
327
- elapsedTimerRef.current = setInterval(() => {
328
- setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1000));
329
- }, 1000);
330
-
331
- // Collect images from MediaProvider if provided and images not passed directly
332
- let images = config.images;
333
- if (!images && config.mediaProvider) {
334
- images = new Map<string, ArrayBuffer>();
335
- const entries = await config.mediaProvider.listMedia();
336
- for (const entry of entries) {
337
- const url = await config.mediaProvider.resolveUrl(entry.name);
338
- const res = await fetch(url);
339
- if (res.ok) {
340
- const data = await res.arrayBuffer();
341
- images.set(entry.name, data);
342
- }
343
- }
344
- }
345
-
346
- const docDuration = await frameCapture.init(
347
- doc,
348
- { images, audio: config.audio, width, height, animationsEnabled },
349
- config.captionMode,
350
- );
351
-
352
- if (cancelledRef.current) return;
353
-
354
- setDuration(docDuration);
355
- if (docDuration <= 0) {
356
- throw new Error('Document has zero duration — nothing to export');
357
- }
358
-
359
- // ── Step 2: Create encoder ────────────────────────────────
360
- setPhase('Starting encoder…');
361
- setProgress(5);
362
-
363
- // Prefer main-thread WebCodecs (fast), but probe whether H.264
364
- // is actually supported. Linux Chromium has VideoEncoder but
365
- // no proprietary H.264 codec — fall back to the worker, which
366
- // loads ffmpeg.wasm in that case.
367
- const canUseWebCodecs =
368
- webCodecsAvailable && (await supportsWebCodecsH264({ width, height, fps, quality }));
369
-
370
- // ── Audio: tier selection + (best-effort) render ──────────
371
- // The browser frame-capture path has no cover pre-roll (Playwright
372
- // only), so the timeline is unshifted. Every audio operation below is
373
- // wrapped so a failure degrades to a silent video with a reason —
374
- // audio never aborts the export.
375
- const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
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) : [];
379
- const aacSupported =
380
- timeline.length > 0
381
- ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS)
382
- : false;
383
- const tierDecision = selectAudioTier({
384
- hasClips: timeline.length > 0,
385
- aacSupported,
386
- sharedArrayBufferAvailable,
387
- canUseMainThreadWebCodecs: canUseWebCodecs,
388
- });
389
-
390
- let renderedAudio: AudioBuffer | null = null;
391
- let audioIncludedLocal = false;
392
- let audioReasonLocal: string | null = tierDecision.reason;
393
-
394
- if (tierDecision.tier === 1 || tierDecision.tier === 2) {
395
- setPhase('Preparing audio…');
396
- try {
397
- const buffers = await resolveAudioBuffers(timeline, {
398
- audio: config.audio,
399
- images,
400
- mediaProvider: config.mediaProvider,
401
- });
402
- if (buffers.size === 0) {
403
- audioReasonLocal = 'Audio files for this document could not be loaded.';
404
- } else {
405
- const totalAudioDur = timeline.reduce(
406
- (max, c) => Math.max(max, c.startSec + c.durationSec),
407
- docDuration,
408
- );
409
- renderedAudio = await renderAudioTimeline(
410
- timeline,
411
- buffers,
412
- totalAudioDur,
413
- EXPORT_AUDIO_SAMPLE_RATE,
414
- );
415
- }
416
- } catch (audioErr: unknown) {
417
- renderedAudio = null;
418
- audioReasonLocal = `Audio could not be prepared: ${
419
- audioErr instanceof Error ? audioErr.message : String(audioErr)
420
- }`;
421
- }
422
- }
423
-
424
- const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
425
- const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
426
-
427
- if (cancelledRef.current) return;
428
-
429
- let encoder: MainThreadEncoder;
430
- if (canUseWebCodecs) {
431
- encoder = createEncoder({
432
- width,
433
- height,
434
- fps,
435
- quality,
436
- ...(useInlineAudio && renderedAudio
437
- ? {
438
- audio: {
439
- numberOfChannels: renderedAudio.numberOfChannels,
440
- sampleRate: renderedAudio.sampleRate,
441
- },
442
- }
443
- : {}),
444
- });
445
- setBackend('webcodecs');
446
- } else if (sharedArrayBufferAvailable) {
447
- const workerEncoder = createWorkerEncoder({
448
- width,
449
- height,
450
- fps,
451
- quality,
452
- ffmpegWasm: config.ffmpegWasm,
453
- });
454
- encoder = workerEncoder;
455
- const selectedBackend = await workerEncoder.ready;
456
- setBackend(selectedBackend);
457
- setPhase(
458
- selectedBackend === 'ffmpeg-wasm'
459
- ? 'Starting encoder (ffmpeg.wasm)…'
460
- : 'Starting encoder…',
461
- );
462
- } else {
463
- throw new Error(
464
- 'WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm ' +
465
- 'fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers).',
466
- );
467
- }
468
- encoderRef.current = encoder;
469
-
470
- if (cancelledRef.current) return;
471
-
472
- // ── Step 3: Capture frames and encode ─────────────────────
473
- setState('capturing');
474
- const totalFrames = Math.ceil(docDuration * fps);
475
-
476
- const captureStartTime = performance.now();
477
- // Throttle UI updates to every ~10 frames to avoid excessive re-renders.
478
- // Each setState between awaits triggers a separate render cycle.
479
- const UI_UPDATE_INTERVAL = 10;
480
-
481
- for (let i = 0; i < totalFrames; i++) {
482
- if (cancelledRef.current) return;
483
-
484
- const time = i / fps;
485
-
486
- // Update UI periodically (not every frame)
487
- if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {
488
- const captureProgress = Math.round((i / totalFrames) * 90);
489
- setProgress(5 + captureProgress);
490
- setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
491
-
492
- if (i > 0) {
493
- const elapsedCapture = (performance.now() - captureStartTime) / 1000;
494
- const avgPerFrame = elapsedCapture / i;
495
- const remaining = Math.round(avgPerFrame * (totalFrames - i));
496
- setEstimatedRemaining(remaining);
497
- }
498
- }
499
-
500
- const bitmap = await frameCapture.captureFrame(time);
501
-
502
- if (cancelledRef.current) {
503
- bitmap.close();
504
- return;
505
- }
506
-
507
- // Encode immediately — WebCodecs is fast and async internally
508
- await encoder.encodeFrame(bitmap, i);
509
- }
510
-
511
- if (cancelledRef.current) return;
512
-
513
- // ── Step 4a: Inline audio (tier 1) ────────────────────────
514
- // Encode the rendered audio into the same muxer before finalizing.
515
- if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
516
- setState('encoding');
517
- setPhase('Encoding audio…');
518
- try {
519
- await encodeAacTrack(
520
- renderedAudio,
521
- { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
522
- audioBitrate,
523
- );
524
- audioIncludedLocal = true;
525
- } catch (audioErr: unknown) {
526
- audioIncludedLocal = false;
527
- audioReasonLocal = `Audio encoding failed: ${
528
- audioErr instanceof Error ? audioErr.message : String(audioErr)
529
- }`;
530
- }
531
- }
532
-
533
- // ── Step 4: Finalize MP4 (or GIF's MP4 intermediate) ─────
534
- setState('encoding');
535
- setPhase(effectiveOutputFormat === 'gif' ? 'Finalizing GIF frames…' : 'Finalizing video…');
536
- setProgress(95);
537
-
538
- let outputBytes: ArrayBuffer | Uint8Array = await encoder.finalize();
539
- encoderRef.current = null;
540
-
541
- if (cancelledRef.current) return;
542
-
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.
562
- setPhase('Muxing audio…');
563
- try {
564
- const wav = audioBufferToWav(renderedAudio);
565
- const videoOnly =
566
- outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
567
- outputBytes = await muxAudioWithFfmpegWasm(
568
- videoOnly,
569
- wav,
570
- audioBitrate,
571
- config.ffmpegWasm,
572
- );
573
- audioIncludedLocal = true;
574
- } catch (audioErr: unknown) {
575
- audioIncludedLocal = false;
576
- audioReasonLocal = `Audio muxing failed: ${
577
- audioErr instanceof Error ? audioErr.message : String(audioErr)
578
- }`;
579
- }
580
- }
581
-
582
- if (cancelledRef.current) return;
583
-
584
- // ── Step 5: Create download URL ───────────────────────────
585
- // Normalize to a plain ArrayBuffer-backed view for Blob (ffmpeg.wasm
586
- // output may be typed over SharedArrayBuffer).
587
- const finalBytes =
588
- outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
589
- const mimeType = effectiveOutputFormat === 'gif' ? 'image/gif' : 'video/mp4';
590
- const blob = new Blob([finalBytes], { type: mimeType });
591
- const url = URL.createObjectURL(blob);
592
- downloadUrlRef.current = url;
593
-
594
- setDownloadUrl(url);
595
- setFileSize(finalBytes.byteLength);
596
- setAudioIncluded(audioIncludedLocal);
597
- setAudioSkippedReason(
598
- effectiveOutputFormat === 'gif' || audioIncludedLocal ? null : audioReasonLocal,
599
- );
600
- setState('complete');
601
- setProgress(100);
602
- setPhase('Export complete');
603
- setEstimatedRemaining(0);
604
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
605
-
606
- // Clean up
607
- frameCapture.destroy();
608
- } catch (err: unknown) {
609
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
610
- if (cancelledRef.current) return;
611
- const message = err instanceof Error ? err.message : String(err);
612
- setState('error');
613
- setError(message);
614
- setPhase('Export failed');
615
-
616
- // Clean up on error
617
- if (encoderRef.current) {
618
- encoderRef.current.close();
619
- encoderRef.current = null;
620
- }
621
- gifAbortRef.current?.abort();
622
- gifAbortRef.current = null;
623
- frameCapture.destroy();
624
- }
625
- },
626
- [frameCapture],
627
- );
628
-
629
- return {
630
- state,
631
- progress,
632
- phase,
633
- duration,
634
- outputFormat,
635
- backend,
636
- downloadUrl,
637
- fileSize,
638
- audioIncluded,
639
- audioSkippedReason,
640
- error,
641
- elapsed,
642
- estimatedRemaining,
643
- startExport,
644
- cancel,
645
- reset,
646
- };
647
- }
package/src/index.ts DELETED
@@ -1,40 +0,0 @@
1
- /**
2
- * @bendyline/squisq-video-react — Browser Video Export for Squisq Documents
3
- *
4
- * Provides React components and hooks for exporting Squisq documents
5
- * to MP4 video directly in the browser.
6
- *
7
- * - VideoExportModal: Full modal UI for configure → export → download
8
- * - VideoExportButton: Drop-in button that opens the modal
9
- * - useVideoExport: Low-level hook for custom UIs
10
- * - useFrameCapture: Frame capture via hidden iframe + html2canvas
11
- *
12
- * Encoding: WebCodecs (H.264 via hardware-accelerated VideoEncoder, Chrome 94+)
13
- */
14
-
15
- // ── Components ─────────────────────────────────────────────────────
16
- export { VideoExportModal } from './VideoExportModal.js';
17
- export type { VideoExportModalProps } from './VideoExportModal.js';
18
-
19
- export { VideoExportButton } from './VideoExportButton.js';
20
- export type { VideoExportButtonProps } from './VideoExportButton.js';
21
-
22
- // ── Hooks ──────────────────────────────────────────────────────────
23
- export { useVideoExport } from './hooks/useVideoExport.js';
24
- export type {
25
- VideoExportState,
26
- VideoExportConfig,
27
- VideoExportResult,
28
- VideoOutputFormat,
29
- } from './hooks/useVideoExport.js';
30
-
31
- export { useFrameCapture } from './hooks/useFrameCapture.js';
32
- export type { FrameCaptureHandle } from './hooks/useFrameCapture.js';
33
-
34
- // ── Encoder Utilities (for advanced usage) ─────────────────────────
35
- export { supportsWebCodecs, supportsWebCodecsH264, createEncoder } from './mainThreadEncoder.js';
36
- export type { MainThreadEncoder, EncoderConfig } from './mainThreadEncoder.js';
37
- export type { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
38
-
39
- // ── Audio (capability probe) ───────────────────────────────────────
40
- export { supportsWebCodecsAac } from './audioTrack.js';