@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
package/src/audioTrack.ts DELETED
@@ -1,332 +0,0 @@
1
- /**
2
- * audioTrack — In-browser audio rendering + AAC encoding for MP4 export.
3
- *
4
- * Replaces the CLI's ffmpeg `adelay`/`atrim`/`amix` graph with browser-native
5
- * primitives:
6
- *
7
- * - {@link supportsWebCodecsAac} probes whether the browser can encode AAC
8
- * (WebCodecs `AudioEncoder`).
9
- * - {@link renderAudioTimeline} mixes an {@link AudioTimelineClip} list into a
10
- * single `AudioBuffer` using an `OfflineAudioContext` (decode → schedule →
11
- * render).
12
- * - {@link encodeAacTrack} feeds that buffer through a WebCodecs
13
- * `AudioEncoder` and hands the chunks to an mp4-muxer audio track (tier 1).
14
- * - {@link audioBufferToWav} + {@link muxAudioWithFfmpegWasm} cover the
15
- * ffmpeg.wasm fallback (tier 2).
16
- * - {@link selectAudioTier} is the pure capability→tier decision shared with
17
- * `useVideoExport` (and unit-tested at the module boundary).
18
- */
19
-
20
- import {
21
- ffmpegAudioMuxArgs,
22
- type AudioTimelineClip,
23
- type FfmpegWasmLoadConfig,
24
- } from '@bendyline/squisq-video';
25
-
26
- /** Sample rate the export renders + encodes audio at. */
27
- export const EXPORT_AUDIO_SAMPLE_RATE = 48_000;
28
- /** Channel count the export renders + encodes audio at (stereo). */
29
- export const EXPORT_AUDIO_CHANNELS = 2;
30
-
31
- /** Tier-3 reason used when AAC encoding is impossible and no ffmpeg fallback. */
32
- export const REASON_NO_AAC_NO_SAB =
33
- 'This browser cannot encode AAC audio (WebCodecs AudioEncoder unavailable) ' +
34
- 'and the ffmpeg.wasm fallback requires cross-origin isolation (SharedArrayBuffer).';
35
-
36
- // ── Capability probe ───────────────────────────────────────────────
37
-
38
- /**
39
- * Whether the browser can encode AAC (`mp4a.40.2`) via WebCodecs.
40
- * Returns false when `AudioEncoder` is undefined (e.g. Node/jsdom, Safari,
41
- * older Chromium) or the config is unsupported.
42
- */
43
- export async function supportsWebCodecsAac(
44
- sampleRate: number = EXPORT_AUDIO_SAMPLE_RATE,
45
- channels: number = EXPORT_AUDIO_CHANNELS,
46
- ): Promise<boolean> {
47
- if (typeof AudioEncoder === 'undefined') return false;
48
- try {
49
- const support = await AudioEncoder.isConfigSupported({
50
- codec: 'mp4a.40.2',
51
- sampleRate,
52
- numberOfChannels: channels,
53
- bitrate: 128_000,
54
- });
55
- return support.supported === true;
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
- // ── Tier selection (pure) ──────────────────────────────────────────
62
-
63
- export interface AudioTierInputs {
64
- /** Whether the doc has any audio timeline clips to include at all. */
65
- hasClips: boolean;
66
- /** Whether WebCodecs AAC encoding is available. */
67
- aacSupported: boolean;
68
- /** Whether `SharedArrayBuffer` (ffmpeg.wasm path) is available. */
69
- sharedArrayBufferAvailable: boolean;
70
- /** Whether the main-thread WebCodecs H.264 encoder is in use (tier-1 host). */
71
- canUseMainThreadWebCodecs: boolean;
72
- }
73
-
74
- export interface AudioTierDecision {
75
- /**
76
- * 1 → inline AAC into the video muxer.
77
- * 2 → finalize video-only then ffmpeg.wasm mux pass.
78
- * 3 → video-only (no audio).
79
- */
80
- tier: 1 | 2 | 3;
81
- /**
82
- * Reason audio will be absent, when tier 3. `null` means "there was simply
83
- * no audio to include" (not a failure). Non-null for a genuine capability
84
- * shortfall. Always `null` for tiers 1 and 2 (the attempt is expected to
85
- * succeed; the caller downgrades with a fresh reason on runtime failure).
86
- */
87
- reason: string | null;
88
- }
89
-
90
- /**
91
- * Decide which audio tier to run from capability booleans. Pure — the single
92
- * source of truth for tier selection, shared with `useVideoExport`.
93
- */
94
- export function selectAudioTier(inputs: AudioTierInputs): AudioTierDecision {
95
- if (!inputs.hasClips) {
96
- return { tier: 3, reason: null };
97
- }
98
- if (inputs.aacSupported && inputs.canUseMainThreadWebCodecs) {
99
- return { tier: 1, reason: null };
100
- }
101
- if (inputs.sharedArrayBufferAvailable) {
102
- return { tier: 2, reason: null };
103
- }
104
- return { tier: 3, reason: REASON_NO_AAC_NO_SAB };
105
- }
106
-
107
- // ── OfflineAudioContext rendering ──────────────────────────────────
108
-
109
- type OfflineCtor = new (
110
- numberOfChannels: number,
111
- length: number,
112
- sampleRate: number,
113
- ) => OfflineAudioContext;
114
-
115
- function resolveOfflineAudioContext(): OfflineCtor {
116
- const g = globalThis as unknown as {
117
- OfflineAudioContext?: OfflineCtor;
118
- webkitOfflineAudioContext?: OfflineCtor;
119
- };
120
- const ctor = g.OfflineAudioContext ?? g.webkitOfflineAudioContext;
121
- if (!ctor) {
122
- throw new Error('OfflineAudioContext is not available in this environment.');
123
- }
124
- return ctor;
125
- }
126
-
127
- /**
128
- * Mix a list of timeline clips into one `AudioBuffer`.
129
- *
130
- * Decodes each unique source once, then schedules an `AudioBufferSourceNode`
131
- * per clip at its `startSec` with the trim window (`offset = sourceInSec`,
132
- * `duration = durationSec`). Undecodable sources are skipped rather than
133
- * failing the whole render.
134
- */
135
- export async function renderAudioTimeline(
136
- clips: AudioTimelineClip[],
137
- buffers: Map<string, ArrayBuffer>,
138
- totalDurationSec: number,
139
- sampleRate: number = EXPORT_AUDIO_SAMPLE_RATE,
140
- ): Promise<AudioBuffer> {
141
- const Ctor = resolveOfflineAudioContext();
142
- const channels = EXPORT_AUDIO_CHANNELS;
143
- const length = Math.max(1, Math.ceil(totalDurationSec * sampleRate));
144
- const ctx = new Ctor(channels, length, sampleRate);
145
-
146
- // Decode each unique source once. decodeAudioData detaches the buffer, so
147
- // hand it a copy to keep the caller's ArrayBuffer reusable.
148
- const decoded = new Map<string, AudioBuffer>();
149
- for (const [src, data] of buffers) {
150
- try {
151
- decoded.set(src, await ctx.decodeAudioData(data.slice(0)));
152
- } catch {
153
- // Skip undecodable sources; the clip is simply omitted.
154
- }
155
- }
156
-
157
- for (const clip of clips) {
158
- const buffer = decoded.get(clip.src);
159
- if (!buffer) continue;
160
- const node = ctx.createBufferSource();
161
- node.buffer = buffer;
162
- node.connect(ctx.destination);
163
- const when = Math.max(0, clip.startSec);
164
- const offset = Math.max(0, clip.sourceInSec);
165
- const duration = Math.max(0, clip.durationSec);
166
- node.start(when, offset, duration);
167
- }
168
-
169
- return ctx.startRendering();
170
- }
171
-
172
- // ── WebCodecs AAC encode (tier 1) ──────────────────────────────────
173
-
174
- /** Minimal muxer surface {@link encodeAacTrack} needs. */
175
- export interface AudioChunkSink {
176
- addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
177
- }
178
-
179
- /**
180
- * Encode an `AudioBuffer` to AAC via WebCodecs and feed the chunks to `sink`.
181
- * Resolves once every chunk has been flushed to the muxer. Throws only if the
182
- * `AudioEncoder` errors — the caller wraps this so audio failure never aborts
183
- * the video export.
184
- */
185
- export async function encodeAacTrack(
186
- audioBuffer: AudioBuffer,
187
- sink: AudioChunkSink,
188
- bitrate: number,
189
- ): Promise<void> {
190
- if (typeof AudioEncoder === 'undefined' || typeof AudioData === 'undefined') {
191
- throw new Error('WebCodecs AudioEncoder is not available.');
192
- }
193
-
194
- const sampleRate = audioBuffer.sampleRate;
195
- const channels = audioBuffer.numberOfChannels;
196
-
197
- let encodeError: Error | null = null;
198
- const encoder = new AudioEncoder({
199
- output: (chunk, meta) => sink.addAudioChunk(chunk, meta ?? undefined),
200
- error: (err) => {
201
- encodeError = err instanceof Error ? err : new Error(String(err));
202
- },
203
- });
204
-
205
- encoder.configure({ codec: 'mp4a.40.2', sampleRate, numberOfChannels: channels, bitrate });
206
-
207
- const FRAME = 1024; // samples per AudioData chunk
208
- const total = audioBuffer.length;
209
- const channelData: Float32Array[] = [];
210
- for (let ch = 0; ch < channels; ch++) {
211
- channelData.push(audioBuffer.getChannelData(ch));
212
- }
213
-
214
- for (let offset = 0; offset < total; offset += FRAME) {
215
- if (encodeError) break;
216
- const count = Math.min(FRAME, total - offset);
217
- // Planar layout: all of channel 0, then all of channel 1, …
218
- const planar = new Float32Array(count * channels);
219
- for (let ch = 0; ch < channels; ch++) {
220
- planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
221
- }
222
- const timestamp = Math.round((offset / sampleRate) * 1_000_000);
223
- const audioData = new AudioData({
224
- format: 'f32-planar',
225
- sampleRate,
226
- numberOfFrames: count,
227
- numberOfChannels: channels,
228
- timestamp,
229
- data: planar,
230
- });
231
- encoder.encode(audioData);
232
- audioData.close();
233
- }
234
-
235
- await encoder.flush();
236
- encoder.close();
237
- if (encodeError) throw encodeError;
238
- }
239
-
240
- // ── WAV serialization (tier 2 input) ───────────────────────────────
241
-
242
- /**
243
- * Serialize an `AudioBuffer` to a 16-bit PCM WAV byte array. Used to hand the
244
- * rendered audio to the ffmpeg.wasm fallback for muxing.
245
- */
246
- export function audioBufferToWav(buffer: AudioBuffer): Uint8Array {
247
- const channels = buffer.numberOfChannels;
248
- const sampleRate = buffer.sampleRate;
249
- const frames = buffer.length;
250
- const bytesPerSample = 2;
251
- const blockAlign = channels * bytesPerSample;
252
- const dataSize = frames * blockAlign;
253
- const out = new ArrayBuffer(44 + dataSize);
254
- const view = new DataView(out);
255
-
256
- const writeStr = (offset: number, str: string) => {
257
- for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
258
- };
259
-
260
- writeStr(0, 'RIFF');
261
- view.setUint32(4, 36 + dataSize, true);
262
- writeStr(8, 'WAVE');
263
- writeStr(12, 'fmt ');
264
- view.setUint32(16, 16, true); // PCM chunk size
265
- view.setUint16(20, 1, true); // PCM format
266
- view.setUint16(22, channels, true);
267
- view.setUint32(24, sampleRate, true);
268
- view.setUint32(28, sampleRate * blockAlign, true); // byte rate
269
- view.setUint16(32, blockAlign, true);
270
- view.setUint16(34, bytesPerSample * 8, true);
271
- writeStr(36, 'data');
272
- view.setUint32(40, dataSize, true);
273
-
274
- const channelData: Float32Array[] = [];
275
- for (let ch = 0; ch < channels; ch++) channelData.push(buffer.getChannelData(ch));
276
-
277
- let pos = 44;
278
- for (let i = 0; i < frames; i++) {
279
- for (let ch = 0; ch < channels; ch++) {
280
- let sample = channelData[ch][i];
281
- sample = Math.max(-1, Math.min(1, sample));
282
- view.setInt16(pos, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
283
- pos += 2;
284
- }
285
- }
286
-
287
- return new Uint8Array(out);
288
- }
289
-
290
- // ── ffmpeg.wasm mux fallback (tier 2) ──────────────────────────────
291
-
292
- /**
293
- * Mux a pre-encoded video-only MP4 with a WAV audio track in a single
294
- * ffmpeg.wasm pass (`-c:v copy -c:a aac -af apad -shortest`). Requires
295
- * `SharedArrayBuffer` (cross-origin isolation). Returns the muxed MP4 bytes.
296
- */
297
- export async function muxAudioWithFfmpegWasm(
298
- videoMp4: Uint8Array,
299
- wav: Uint8Array,
300
- audioBitrate: number,
301
- loadConfig?: FfmpegWasmLoadConfig,
302
- ): Promise<Uint8Array> {
303
- const { FFmpeg } = await import('@ffmpeg/ffmpeg');
304
- const ffmpeg = new FFmpeg();
305
- try {
306
- await ffmpeg.load({
307
- ...loadConfig,
308
- classWorkerURL:
309
- loadConfig?.classWorkerURL ??
310
- new URL('./workers/ffmpeg.class-worker.js', import.meta.url).href,
311
- });
312
- await ffmpeg.writeFile('video.mp4', videoMp4);
313
- await ffmpeg.writeFile('audio.wav', wav);
314
- const exitCode = await ffmpeg.exec([
315
- '-i',
316
- 'video.mp4',
317
- '-i',
318
- 'audio.wav',
319
- '-c:v',
320
- 'copy',
321
- ...ffmpegAudioMuxArgs(audioBitrate),
322
- 'out.mp4',
323
- ]);
324
- if (exitCode !== 0) {
325
- throw new Error(`ffmpeg.wasm audio mux failed with exit code ${exitCode}`);
326
- }
327
- const data = await ffmpeg.readFile('out.mp4');
328
- return data instanceof Uint8Array ? data : new TextEncoder().encode(data as string);
329
- } finally {
330
- ffmpeg.terminate();
331
- }
332
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * Animated GIF finalization for the browser export path.
3
- *
4
- * The frame-capture pipeline already produces a compact, video-only MP4. For
5
- * the GIF MVP we use that MP4 as a bounded-memory intermediate, then run a
6
- * palette generation/application pass through ffmpeg.wasm.
7
- */
8
-
9
- import {
10
- ffmpegGifOutputArgs,
11
- type FfmpegWasmLoadConfig,
12
- type GifOutputOptions,
13
- } from '@bendyline/squisq-video';
14
-
15
- /** Build the deterministic ffmpeg arguments used for MP4 -> animated GIF. */
16
- export function buildGifFfmpegArgs(options: GifOutputOptions): string[] {
17
- return ['-y', '-i', 'video.mp4', ...ffmpegGifOutputArgs(options), 'out.gif'];
18
- }
19
-
20
- /**
21
- * Convert a video-only MP4 into a looping animated GIF with a generated
22
- * palette. The ffmpeg runtime is always terminated, including on failure.
23
- */
24
- export async function transcodeMp4ToGifWithFfmpegWasm(
25
- videoMp4: Uint8Array,
26
- options: GifOutputOptions,
27
- loadConfig?: FfmpegWasmLoadConfig,
28
- signal?: AbortSignal,
29
- ): Promise<Uint8Array> {
30
- if (videoMp4.byteLength === 0) {
31
- throw new Error('Cannot create an animated GIF from an empty MP4.');
32
- }
33
- if (signal?.aborted) {
34
- throw new DOMException('Animated GIF export was cancelled.', 'AbortError');
35
- }
36
-
37
- const args = buildGifFfmpegArgs(options);
38
- const { FFmpeg } = await import('@ffmpeg/ffmpeg');
39
- const ffmpeg = new FFmpeg();
40
- let terminated = false;
41
- const terminate = () => {
42
- if (terminated) return;
43
- terminated = true;
44
- ffmpeg.terminate();
45
- };
46
- const handleAbort = () => terminate();
47
- signal?.addEventListener('abort', handleAbort, { once: true });
48
- try {
49
- await ffmpeg.load({
50
- ...loadConfig,
51
- classWorkerURL:
52
- loadConfig?.classWorkerURL ??
53
- new URL('./workers/ffmpeg.class-worker.js', import.meta.url).href,
54
- });
55
- if (signal?.aborted) {
56
- throw new DOMException('Animated GIF export was cancelled.', 'AbortError');
57
- }
58
- await ffmpeg.writeFile('video.mp4', videoMp4);
59
- if (signal?.aborted) {
60
- throw new DOMException('Animated GIF export was cancelled.', 'AbortError');
61
- }
62
- const exitCode = await ffmpeg.exec(args);
63
- if (signal?.aborted) {
64
- throw new DOMException('Animated GIF export was cancelled.', 'AbortError');
65
- }
66
- if (exitCode !== 0) {
67
- throw new Error(`ffmpeg.wasm GIF transcode failed with exit code ${exitCode}`);
68
- }
69
- const data = await ffmpeg.readFile('out.gif');
70
- return data instanceof Uint8Array ? data : new TextEncoder().encode(data as string);
71
- } finally {
72
- signal?.removeEventListener('abort', handleAbort);
73
- terminate();
74
- }
75
- }
@@ -1,268 +0,0 @@
1
- /**
2
- * useFrameCapture — Hidden div + html2canvas frame capture.
3
- *
4
- * Mounts a DocPlayer in renderMode inside a hidden div (same document),
5
- * then captures individual frames by seeking the player and rendering
6
- * the DOM to a canvas via html2canvas.
7
- *
8
- * Uses React directly — no script injection, no iframes, no eval.
9
- *
10
- * Returns an ImageBitmap for each frame (transferable to a Worker).
11
- */
12
-
13
- import { createElement } from 'react';
14
- import { createRoot, type Root } from 'react-dom/client';
15
- import { useRef, useCallback, useMemo } from 'react';
16
- import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
17
- import type { RenderHtmlOptions } from '@bendyline/squisq-video';
18
- import { DocPlayer, MediaContext } from '@bendyline/squisq-react';
19
- import type { SquisqRenderAPI, CaptionMode, CaptionStyle } from '@bendyline/squisq-react';
20
- import html2canvas from 'html2canvas';
21
-
22
- export interface FrameCaptureHandle {
23
- /** Initialize the hidden player. Returns the video duration in seconds. */
24
- init: (
25
- doc: Doc,
26
- renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,
27
- captionMode?: CaptionMode,
28
- ) => Promise<number>;
29
- /** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
30
- captureFrame: (time: number) => Promise<ImageBitmap>;
31
- /** Clean up resources. */
32
- destroy: () => void;
33
- }
34
-
35
- /** Extension → MIME type map (hoisted to avoid per-image allocation). */
36
- const MIME_MAP: Record<string, string> = {
37
- jpg: 'image/jpeg',
38
- jpeg: 'image/jpeg',
39
- png: 'image/png',
40
- gif: 'image/gif',
41
- webp: 'image/webp',
42
- svg: 'image/svg+xml',
43
- bmp: 'image/bmp',
44
- avif: 'image/avif',
45
- };
46
-
47
- /** Convert an ArrayBuffer to a base64 data URI using chunked encoding (O(n)). */
48
- function arrayBufferToDataUrl(buffer: ArrayBuffer, mime: string): string {
49
- const bytes = new Uint8Array(buffer);
50
- const chunks: string[] = [];
51
- const CHUNK = 8192;
52
- for (let i = 0; i < bytes.length; i += CHUNK) {
53
- chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
54
- }
55
- return `data:${mime};base64,${btoa(chunks.join(''))}`;
56
- }
57
-
58
- /**
59
- * Create an inline MediaProvider from a map of paths to ArrayBuffers.
60
- */
61
- function createInlineProvider(images: Map<string, ArrayBuffer>): MediaProvider {
62
- const dataUrls = new Map<string, string>();
63
- const mimeTypes = new Map<string, string>();
64
- for (const [path, buffer] of images) {
65
- const ext = path.split('.').pop()?.toLowerCase() ?? '';
66
- const mime = MIME_MAP[ext] ?? 'application/octet-stream';
67
- dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
68
- mimeTypes.set(path, mime);
69
- }
70
-
71
- return {
72
- async resolveUrl(relativePath: string): Promise<string> {
73
- return dataUrls.get(relativePath) ?? relativePath;
74
- },
75
- async listMedia() {
76
- return [...dataUrls.keys()].map((name) => ({
77
- name,
78
- mimeType: mimeTypes.get(name) ?? 'application/octet-stream',
79
- size: 0,
80
- }));
81
- },
82
- async addMedia() {
83
- throw new Error('Read-only');
84
- },
85
- async removeMedia() {
86
- throw new Error('Read-only');
87
- },
88
- dispose() {},
89
- };
90
- }
91
-
92
- /**
93
- * Hook that manages a hidden div for frame capture.
94
- */
95
- export function useFrameCapture(): FrameCaptureHandle {
96
- const containerRef = useRef<HTMLDivElement | null>(null);
97
- const rootRef = useRef<Root | null>(null);
98
- const renderAPIRef = useRef<SquisqRenderAPI | null>(null);
99
- const dimensionsRef = useRef<{ width: number; height: number }>({ width: 1920, height: 1080 });
100
-
101
- const init = useCallback(
102
- async (
103
- doc: Doc,
104
- renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,
105
- captionMode?: CaptionMode,
106
- ): Promise<number> => {
107
- // Clean up any existing container.
108
- // Defer unmount to avoid "synchronously unmount a root while React
109
- // was already rendering" when init() is called from a React handler.
110
- if (rootRef.current || containerRef.current) {
111
- const oldRoot = rootRef.current;
112
- const oldContainer = containerRef.current;
113
- rootRef.current = null;
114
- containerRef.current = null;
115
- renderAPIRef.current = null;
116
- await new Promise<void>((resolve) => {
117
- setTimeout(() => {
118
- if (oldRoot) oldRoot.unmount();
119
- if (oldContainer) oldContainer.remove();
120
- resolve();
121
- }, 0);
122
- });
123
- }
124
-
125
- const width = renderOptions.width ?? 1920;
126
- const height = renderOptions.height ?? 1080;
127
- const animationsEnabled = renderOptions.animationsEnabled ?? true;
128
- dimensionsRef.current = { width, height };
129
-
130
- // Create a hidden container
131
- const container = document.createElement('div');
132
- container.style.cssText =
133
- `position:fixed;left:0;top:0;width:${width}px;height:${height}px;` +
134
- 'opacity:0;pointer-events:none;z-index:-1;overflow:hidden;';
135
- document.body.appendChild(container);
136
- containerRef.current = container;
137
-
138
- // Create render root
139
- const renderRoot = document.createElement('div');
140
- renderRoot.id = 'squisq-capture-root';
141
- renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
142
- container.appendChild(renderRoot);
143
-
144
- // Build media provider from images
145
- const mediaProvider = renderOptions.images
146
- ? createInlineProvider(renderOptions.images)
147
- : null;
148
-
149
- // Mount DocPlayer in renderMode via React
150
- const root = createRoot(renderRoot);
151
- rootRef.current = root;
152
-
153
- // Derive caption props from captionMode
154
- const captionsEnabled = captionMode !== undefined && captionMode !== 'off';
155
- const captionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
156
- let resolveRenderAPI!: (api: SquisqRenderAPI) => void;
157
- const renderAPIReady = new Promise<SquisqRenderAPI>((resolve) => {
158
- resolveRenderAPI = resolve;
159
- });
160
-
161
- const playerElement = createElement(DocPlayer, {
162
- doc,
163
- basePath: '.',
164
- renderMode: true,
165
- animationsEnabled,
166
- showControls: false,
167
- autoPlay: false,
168
- forceViewport: { width, height, name: 'export' },
169
- captionsEnabled,
170
- captionStyle,
171
- onRenderAPIReady: (api: SquisqRenderAPI | null) => {
172
- if (containerRef.current !== container) return;
173
- renderAPIRef.current = api;
174
- if (api) resolveRenderAPI(api);
175
- },
176
- });
177
-
178
- // Defer rendering to the next microtask to avoid "synchronously unmount
179
- // a root while React was already rendering" when init() is called during
180
- // a React render cycle (e.g., from startExport in VideoExportModal).
181
- await new Promise<void>((resolve) => setTimeout(resolve, 0));
182
-
183
- if (mediaProvider) {
184
- root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
185
- } else {
186
- root.render(playerElement);
187
- }
188
-
189
- // Wait for this exact player's instance API.
190
- return new Promise<number>((resolve, reject) => {
191
- const timeout = setTimeout(() => {
192
- const api = renderAPIRef.current;
193
- const hasSeek = typeof api?.seekTo === 'function';
194
- const hasDur = typeof api?.getDuration === 'function';
195
- const rootEl = containerRef.current?.querySelector('#squisq-capture-root');
196
- const hasPlayer = rootEl ? rootEl.querySelector('.doc-player') !== null : false;
197
- reject(
198
- new Error(
199
- `Render API did not initialize within 15s. ` +
200
- `seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`,
201
- ),
202
- );
203
- }, 15000);
204
-
205
- void renderAPIReady.then((api) => {
206
- clearTimeout(timeout);
207
- resolve(api.getDuration());
208
- });
209
- });
210
- },
211
- [],
212
- );
213
-
214
- const captureFrame = useCallback(async (time: number): Promise<ImageBitmap> => {
215
- const container = containerRef.current;
216
- const api = renderAPIRef.current;
217
- if (!container || !api) {
218
- throw new Error('Frame capture not initialized — call init() first');
219
- }
220
-
221
- const { width, height } = dimensionsRef.current;
222
-
223
- // Seek the player to the target time
224
- await api.seekTo(time);
225
-
226
- // Wait for the DOM to update after seek
227
- await new Promise<void>((resolve) =>
228
- requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
229
- );
230
-
231
- const root = container.querySelector('#squisq-capture-root') as HTMLElement;
232
- if (!root) {
233
- throw new Error('Capture root element not found');
234
- }
235
-
236
- // Render the DOM to a canvas via html2canvas.
237
- // We're in the same document (no iframe), so COEP doesn't block cloning.
238
- const canvas = await html2canvas(root, {
239
- width,
240
- height,
241
- scale: 1,
242
- useCORS: true,
243
- allowTaint: true,
244
- backgroundColor: '#000000',
245
- logging: false,
246
- });
247
-
248
- // Convert to ImageBitmap (transferable to worker — zero-copy)
249
- const bitmap = await createImageBitmap(canvas);
250
- return bitmap;
251
- }, []);
252
-
253
- const destroy = useCallback(() => {
254
- if (rootRef.current) {
255
- rootRef.current.unmount();
256
- rootRef.current = null;
257
- }
258
- if (containerRef.current) {
259
- containerRef.current.remove();
260
- containerRef.current = null;
261
- }
262
- renderAPIRef.current = null;
263
- }, []);
264
-
265
- // Return a stable object to prevent useEffect cleanup loops
266
- // in consumers that depend on the handle reference.
267
- return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
268
- }