@bendyline/squisq-video-react 1.2.2 → 2.0.1

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.
@@ -0,0 +1,155 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { Doc } from '@bendyline/squisq/schemas';
4
+
5
+ const mocks = vi.hoisted(() => {
6
+ const bitmap = { close: vi.fn() } as unknown as ImageBitmap;
7
+ return {
8
+ bitmap,
9
+ frameInit: vi.fn(async () => 0.1),
10
+ captureFrame: vi.fn(async () => bitmap),
11
+ destroy: vi.fn(),
12
+ encodeFrame: vi.fn(async (frame: ImageBitmap) => frame.close()),
13
+ finalize: vi.fn(async () => new Uint8Array([0, 0, 0, 1]).buffer),
14
+ closeEncoder: vi.fn(),
15
+ computeAudioTimeline: vi.fn(() => []),
16
+ supportsAac: vi.fn(async () => true),
17
+ gifTranscode: vi.fn(
18
+ async (_video: Uint8Array, _options: unknown, _loadConfig?: unknown, _signal?: AbortSignal) =>
19
+ new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]),
20
+ ),
21
+ };
22
+ });
23
+
24
+ vi.mock('../hooks/useFrameCapture.js', () => ({
25
+ useFrameCapture: () => ({
26
+ init: mocks.frameInit,
27
+ captureFrame: mocks.captureFrame,
28
+ destroy: mocks.destroy,
29
+ }),
30
+ }));
31
+
32
+ vi.mock('../mainThreadEncoder.js', () => ({
33
+ supportsWebCodecs: () => true,
34
+ supportsWebCodecsH264: async () => true,
35
+ createEncoder: () => ({
36
+ encodeFrame: mocks.encodeFrame,
37
+ finalize: mocks.finalize,
38
+ close: mocks.closeEncoder,
39
+ }),
40
+ }));
41
+
42
+ vi.mock('../gifTranscode.js', () => ({
43
+ transcodeMp4ToGifWithFfmpegWasm: mocks.gifTranscode,
44
+ }));
45
+
46
+ vi.mock('../audioTrack.js', () => ({
47
+ supportsWebCodecsAac: mocks.supportsAac,
48
+ selectAudioTier: () => ({ tier: 3, reason: null }),
49
+ renderAudioTimeline: vi.fn(),
50
+ encodeAacTrack: vi.fn(),
51
+ audioBufferToWav: vi.fn(),
52
+ muxAudioWithFfmpegWasm: vi.fn(),
53
+ EXPORT_AUDIO_SAMPLE_RATE: 48_000,
54
+ EXPORT_AUDIO_CHANNELS: 2,
55
+ }));
56
+
57
+ vi.mock('@bendyline/squisq-video', async () => {
58
+ const actual =
59
+ await vi.importActual<typeof import('@bendyline/squisq-video')>('@bendyline/squisq-video');
60
+ return { ...actual, computeAudioTimeline: mocks.computeAudioTimeline };
61
+ });
62
+
63
+ import { useVideoExport } from '../hooks/useVideoExport.js';
64
+
65
+ const doc: Doc = {
66
+ articleId: 'gif-hook-test',
67
+ duration: 0.1,
68
+ blocks: [{ id: 'b1', startTime: 0, duration: 0.1, audioSegment: 0, layers: [] }],
69
+ audio: {
70
+ segments: [{ name: 'narration', src: 'narration.mp3', startTime: 0, duration: 0.1 }],
71
+ },
72
+ };
73
+
74
+ describe('useVideoExport GIF flow', () => {
75
+ let createObjectUrl: ReturnType<typeof vi.spyOn>;
76
+ let revokeObjectUrl: ReturnType<typeof vi.spyOn>;
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ mocks.gifTranscode.mockResolvedValue(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]));
81
+ createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:gif-output');
82
+ revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
83
+ if (typeof SharedArrayBuffer === 'undefined') {
84
+ vi.stubGlobal('SharedArrayBuffer', ArrayBuffer);
85
+ }
86
+ });
87
+
88
+ afterEach(() => {
89
+ createObjectUrl.mockRestore();
90
+ revokeObjectUrl.mockRestore();
91
+ vi.unstubAllGlobals();
92
+ });
93
+
94
+ it('uses compact/static defaults, skips audio, and emits an image/gif Blob', async () => {
95
+ const { result, unmount } = renderHook(() => useVideoExport());
96
+
97
+ await act(async () => {
98
+ await result.current.startExport(doc, { outputFormat: 'gif' });
99
+ });
100
+
101
+ expect(mocks.frameInit).toHaveBeenCalledWith(
102
+ doc,
103
+ expect.objectContaining({ width: 960, height: 540, animationsEnabled: false }),
104
+ undefined,
105
+ );
106
+ expect(mocks.captureFrame).toHaveBeenCalledTimes(1);
107
+ expect(mocks.computeAudioTimeline).not.toHaveBeenCalled();
108
+ expect(mocks.supportsAac).not.toHaveBeenCalled();
109
+ expect(mocks.gifTranscode).toHaveBeenCalledWith(
110
+ expect.any(Uint8Array),
111
+ { width: 960, height: 540, loop: 0 },
112
+ undefined,
113
+ expect.any(AbortSignal),
114
+ );
115
+
116
+ const blob = createObjectUrl.mock.calls[0][0] as Blob;
117
+ expect(blob.type).toBe('image/gif');
118
+ expect(result.current.state).toBe('complete');
119
+ expect(result.current.outputFormat).toBe('gif');
120
+ expect(result.current.audioIncluded).toBe(false);
121
+ expect(result.current.audioSkippedReason).toBeNull();
122
+
123
+ unmount();
124
+ });
125
+
126
+ it('aborts the palette worker when the export is cancelled', async () => {
127
+ mocks.gifTranscode.mockImplementationOnce(
128
+ async (_video: Uint8Array, _options: unknown, _loadConfig: unknown, signal?: AbortSignal) =>
129
+ new Promise<Uint8Array<ArrayBuffer>>((_resolve, reject) => {
130
+ if (!signal) throw new Error('Expected an AbortSignal');
131
+ signal.addEventListener(
132
+ 'abort',
133
+ () => reject(new DOMException('cancelled', 'AbortError')),
134
+ { once: true },
135
+ );
136
+ }),
137
+ );
138
+ const { result, unmount } = renderHook(() => useVideoExport());
139
+
140
+ let exportPromise!: Promise<void>;
141
+ await act(async () => {
142
+ exportPromise = result.current.startExport(doc, { outputFormat: 'gif' });
143
+ await vi.waitFor(() => expect(mocks.gifTranscode).toHaveBeenCalledOnce());
144
+ });
145
+ const signal = mocks.gifTranscode.mock.calls[0][3] as AbortSignal;
146
+
147
+ act(() => result.current.cancel());
148
+ await act(async () => exportPromise);
149
+
150
+ expect(signal.aborted).toBe(true);
151
+ expect(result.current.state).toBe('idle');
152
+ expect(result.current.phase).toBe('Cancelled');
153
+ unmount();
154
+ });
155
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from 'vitest';
10
- import { render, renderHook } from '@testing-library/react';
10
+ import { fireEvent, render, renderHook } from '@testing-library/react';
11
11
  import { VideoExportButton } from '../VideoExportButton';
12
12
  import { VideoExportModal } from '../VideoExportModal';
13
13
  import { useVideoExport } from '../hooks/useVideoExport';
@@ -34,6 +34,19 @@ describe('VideoExportButton', () => {
34
34
  );
35
35
  expect(getByRole('button', { name: 'Export Video' })).toBeTruthy();
36
36
  });
37
+
38
+ it('uses a GIF-specific default label when configured for GIF output', () => {
39
+ const { getByRole } = render(
40
+ <VideoExportButton doc={minimalDoc()} defaultConfig={{ outputFormat: 'gif' }} />,
41
+ );
42
+ expect(getByRole('button', { name: 'Export GIF' })).toBeTruthy();
43
+ });
44
+
45
+ it('forwards the requested color scheme to its portaled modal', () => {
46
+ const { getByRole } = render(<VideoExportButton doc={minimalDoc()} colorScheme="dark" />);
47
+ fireEvent.click(getByRole('button', { name: 'Export Video' }));
48
+ expect(document.querySelector('[data-color-scheme="dark"]')).toBeTruthy();
49
+ });
37
50
  });
38
51
 
39
52
  describe('VideoExportModal', () => {
@@ -55,18 +68,80 @@ describe('VideoExportModal', () => {
55
68
  onClose={() => {}}
56
69
  />,
57
70
  );
58
- const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
59
- // Order in the configure form: Quality, Frame Rate, Orientation, Captions.
60
- expect(selects[0].value).toBe('high');
61
- expect(selects[1].value).toBe('30');
62
- expect(selects[2].value).toBe('portrait');
63
- expect(selects[3].value).toBe('standard');
71
+ expect((container.querySelector('[aria-label="Format"]') as HTMLSelectElement).value).toBe(
72
+ 'mp4',
73
+ );
74
+ expect((container.querySelector('[aria-label="Quality"]') as HTMLSelectElement).value).toBe(
75
+ 'high',
76
+ );
77
+ expect((container.querySelector('[aria-label="Frame Rate"]') as HTMLSelectElement).value).toBe(
78
+ '30',
79
+ );
80
+ expect((container.querySelector('[aria-label="Orientation"]') as HTMLSelectElement).value).toBe(
81
+ 'portrait',
82
+ );
83
+ expect((container.querySelector('[aria-label="Captions"]') as HTMLSelectElement).value).toBe(
84
+ 'standard',
85
+ );
86
+ expect(
87
+ (container.querySelector('[aria-label="Animations and transitions"]') as HTMLSelectElement)
88
+ .value,
89
+ ).toBe('enabled');
90
+ });
91
+
92
+ it('defaults GIF export to 10fps with animations and transitions disabled', () => {
93
+ const { container, getByRole } = render(
94
+ <VideoExportModal
95
+ doc={minimalDoc()}
96
+ defaultConfig={{ outputFormat: 'gif' }}
97
+ onClose={() => {}}
98
+ />,
99
+ );
100
+
101
+ expect(getByRole('heading', { name: 'Export Animated GIF' })).toBeTruthy();
102
+ expect((container.querySelector('[aria-label="Frame Rate"]') as HTMLSelectElement).value).toBe(
103
+ '10',
104
+ );
105
+ expect(
106
+ (container.querySelector('[aria-label="Animations and transitions"]') as HTMLSelectElement)
107
+ .value,
108
+ ).toBe('disabled');
109
+ expect(container.textContent).toContain('Landscape (960 × 540)');
110
+ expect(getByRole('button', { name: 'Export GIF' })).toBeTruthy();
111
+ });
112
+
113
+ it('applies GIF recommendations when the format selection changes', () => {
114
+ const { container } = render(<VideoExportModal doc={minimalDoc()} onClose={() => {}} />);
115
+ fireEvent.change(container.querySelector('[aria-label="Format"]')!, {
116
+ target: { value: 'gif' },
117
+ });
118
+
119
+ expect((container.querySelector('[aria-label="Frame Rate"]') as HTMLSelectElement).value).toBe(
120
+ '10',
121
+ );
122
+ expect(
123
+ (container.querySelector('[aria-label="Animations and transitions"]') as HTMLSelectElement)
124
+ .value,
125
+ ).toBe('disabled');
126
+ });
127
+
128
+ it('applies dark colors to the modal surface and native controls', () => {
129
+ const { container } = render(
130
+ <VideoExportModal doc={minimalDoc()} colorScheme="dark" onClose={() => {}} />,
131
+ );
132
+ const overlay = container.querySelector('[data-color-scheme="dark"]');
133
+ const modal = overlay?.firstElementChild as HTMLElement | null;
134
+ const select = container.querySelector('select');
135
+ expect(modal?.style.colorScheme).toBe('dark');
136
+ expect(modal?.style.background).toBe('rgb(17, 24, 39)');
137
+ expect(select?.style.colorScheme).toBe('dark');
64
138
  });
65
139
  });
66
140
 
67
141
  describe('useVideoExport result shape', () => {
68
142
  it('exposes the additive audio result fields, defaulted for idle', () => {
69
143
  const { result } = renderHook(() => useVideoExport());
144
+ expect(result.current.outputFormat).toBe('mp4');
70
145
  expect(result.current.audioIncluded).toBe(false);
71
146
  expect(result.current.audioSkippedReason).toBeNull();
72
147
  });
@@ -0,0 +1,143 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createWorkerEncoder } from '../workerEncoder.js';
4
+ import type { MainToWorkerMessage, WorkerToMainMessage } from '../workers/workerTypes.js';
5
+
6
+ class FakeWorker {
7
+ static instances: FakeWorker[] = [];
8
+
9
+ onmessage: ((event: MessageEvent<WorkerToMainMessage>) => void) | null = null;
10
+ onerror: ((event: ErrorEvent) => void) | null = null;
11
+ readonly posted: MainToWorkerMessage[] = [];
12
+ readonly terminate = vi.fn();
13
+
14
+ constructor() {
15
+ FakeWorker.instances.push(this);
16
+ }
17
+
18
+ postMessage(message: MainToWorkerMessage): void {
19
+ this.posted.push(message);
20
+ }
21
+
22
+ emit(message: WorkerToMainMessage): void {
23
+ this.onmessage?.({ data: message } as MessageEvent<WorkerToMainMessage>);
24
+ }
25
+ }
26
+
27
+ function createEncoder() {
28
+ return createWorkerEncoder({ width: 640, height: 360, fps: 30, quality: 'normal' });
29
+ }
30
+
31
+ function bitmap(): ImageBitmap {
32
+ return { close: vi.fn() } as unknown as ImageBitmap;
33
+ }
34
+
35
+ describe('createWorkerEncoder', () => {
36
+ beforeEach(() => {
37
+ FakeWorker.instances = [];
38
+ vi.stubGlobal('Worker', FakeWorker);
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ it('rejects invalid configs before allocating a worker', () => {
46
+ expect(() =>
47
+ createWorkerEncoder({ width: 640, height: 360, fps: 0, quality: 'normal' }),
48
+ ).toThrow('Video FPS');
49
+ expect(FakeWorker.instances).toHaveLength(0);
50
+ });
51
+
52
+ it('keeps frame backpressure until the worker acknowledges the frame', async () => {
53
+ const encoder = createEncoder();
54
+ const worker = FakeWorker.instances[0];
55
+ worker.emit({ type: 'capabilities', backend: 'webcodecs' });
56
+ await expect(encoder.ready).resolves.toBe('webcodecs');
57
+
58
+ let completed = false;
59
+ const pending = encoder.encodeFrame(bitmap(), 7).then(() => {
60
+ completed = true;
61
+ });
62
+
63
+ await Promise.resolve();
64
+ expect(completed).toBe(false);
65
+ expect(worker.posted[worker.posted.length - 1]).toMatchObject({ type: 'frame', frameIndex: 7 });
66
+
67
+ worker.emit({ type: 'frame-complete', frameIndex: 7 });
68
+ await pending;
69
+ expect(completed).toBe(true);
70
+ });
71
+
72
+ it('threads self-hosted ffmpeg core URLs through worker initialization', () => {
73
+ createWorkerEncoder({
74
+ width: 640,
75
+ height: 360,
76
+ fps: 30,
77
+ quality: 'normal',
78
+ ffmpegWasm: {
79
+ coreURL: '/vendor/ffmpeg-core.js',
80
+ wasmURL: '/vendor/ffmpeg-core.wasm',
81
+ },
82
+ });
83
+
84
+ expect(FakeWorker.instances[0].posted[0]).toMatchObject({
85
+ type: 'init',
86
+ ffmpegWasm: {
87
+ coreURL: '/vendor/ffmpeg-core.js',
88
+ wasmURL: '/vendor/ffmpeg-core.wasm',
89
+ },
90
+ });
91
+ });
92
+
93
+ it('does not finalize until every submitted frame has completed', async () => {
94
+ const encoder = createEncoder();
95
+ const worker = FakeWorker.instances[0];
96
+ worker.emit({ type: 'capabilities', backend: 'ffmpeg-wasm' });
97
+ await encoder.ready;
98
+
99
+ const frame = encoder.encodeFrame(bitmap(), 3);
100
+ const finalized = encoder.finalize();
101
+ await Promise.resolve();
102
+ expect(worker.posted.some((message) => message.type === 'finalize')).toBe(false);
103
+
104
+ worker.emit({ type: 'frame-complete', frameIndex: 3 });
105
+ await frame;
106
+ await Promise.resolve();
107
+ expect(worker.posted[worker.posted.length - 1]).toEqual({ type: 'finalize' });
108
+
109
+ const result = new ArrayBuffer(4);
110
+ worker.emit({ type: 'complete', data: result, size: result.byteLength });
111
+ await expect(finalized).resolves.toBe(result);
112
+ expect(worker.terminate).toHaveBeenCalledOnce();
113
+ });
114
+
115
+ it('rejects readiness when closed during initialization', async () => {
116
+ const encoder = createEncoder();
117
+ const worker = FakeWorker.instances[0];
118
+ const readiness = expect(encoder.ready).rejects.toThrow('Encoder closed');
119
+
120
+ encoder.close();
121
+
122
+ await readiness;
123
+ expect(worker.posted[worker.posted.length - 1]).toEqual({ type: 'cancel' });
124
+ expect(worker.terminate).toHaveBeenCalledOnce();
125
+ });
126
+
127
+ it('rejects an in-flight finalization when closed', async () => {
128
+ const encoder = createEncoder();
129
+ const worker = FakeWorker.instances[0];
130
+ worker.emit({ type: 'capabilities', backend: 'webcodecs' });
131
+ await encoder.ready;
132
+
133
+ const finalized = encoder.finalize();
134
+ await Promise.resolve();
135
+ expect(worker.posted[worker.posted.length - 1]).toEqual({ type: 'finalize' });
136
+
137
+ encoder.close();
138
+
139
+ await expect(finalized).rejects.toThrow('Encoder closed');
140
+ expect(worker.posted[worker.posted.length - 1]).toEqual({ type: 'cancel' });
141
+ expect(worker.terminate).toHaveBeenCalledOnce();
142
+ });
143
+ });
package/src/audioTrack.ts CHANGED
@@ -17,7 +17,11 @@
17
17
  * `useVideoExport` (and unit-tested at the module boundary).
18
18
  */
19
19
 
20
- import type { AudioTimelineClip } from '@bendyline/squisq-video';
20
+ import {
21
+ ffmpegAudioMuxArgs,
22
+ type AudioTimelineClip,
23
+ type FfmpegWasmLoadConfig,
24
+ } from '@bendyline/squisq-video';
21
25
 
22
26
  /** Sample rate the export renders + encodes audio at. */
23
27
  export const EXPORT_AUDIO_SAMPLE_RATE = 48_000;
@@ -287,34 +291,39 @@ export function audioBufferToWav(buffer: AudioBuffer): Uint8Array {
287
291
 
288
292
  /**
289
293
  * Mux a pre-encoded video-only MP4 with a WAV audio track in a single
290
- * ffmpeg.wasm pass (`-c:v copy -c:a aac -shortest`). Requires
294
+ * ffmpeg.wasm pass (`-c:v copy -c:a aac -af apad -shortest`). Requires
291
295
  * `SharedArrayBuffer` (cross-origin isolation). Returns the muxed MP4 bytes.
292
296
  */
293
297
  export async function muxAudioWithFfmpegWasm(
294
298
  videoMp4: Uint8Array,
295
299
  wav: Uint8Array,
296
300
  audioBitrate: number,
301
+ loadConfig?: FfmpegWasmLoadConfig,
297
302
  ): Promise<Uint8Array> {
298
303
  const { FFmpeg } = await import('@ffmpeg/ffmpeg');
299
304
  const ffmpeg = new FFmpeg();
300
- await ffmpeg.load();
301
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
+ });
302
312
  await ffmpeg.writeFile('video.mp4', videoMp4);
303
313
  await ffmpeg.writeFile('audio.wav', wav);
304
- await ffmpeg.exec([
314
+ const exitCode = await ffmpeg.exec([
305
315
  '-i',
306
316
  'video.mp4',
307
317
  '-i',
308
318
  'audio.wav',
309
319
  '-c:v',
310
320
  'copy',
311
- '-c:a',
312
- 'aac',
313
- '-b:a',
314
- String(audioBitrate),
315
- '-shortest',
321
+ ...ffmpegAudioMuxArgs(audioBitrate),
316
322
  'out.mp4',
317
323
  ]);
324
+ if (exitCode !== 0) {
325
+ throw new Error(`ffmpeg.wasm audio mux failed with exit code ${exitCode}`);
326
+ }
318
327
  const data = await ffmpeg.readFile('out.mp4');
319
328
  return data instanceof Uint8Array ? data : new TextEncoder().encode(data as string);
320
329
  } finally {
@@ -0,0 +1,75 @@
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
+ }
@@ -16,7 +16,7 @@ import { useRef, useCallback, useMemo } from 'react';
16
16
  import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
17
17
  import type { RenderHtmlOptions } from '@bendyline/squisq-video';
18
18
  import { DocPlayer, MediaContext } from '@bendyline/squisq-react';
19
- import type { SquisqWindow, CaptionMode, CaptionStyle } from '@bendyline/squisq-react';
19
+ import type { SquisqRenderAPI, CaptionMode, CaptionStyle } from '@bendyline/squisq-react';
20
20
  import html2canvas from 'html2canvas';
21
21
 
22
22
  export interface FrameCaptureHandle {
@@ -95,6 +95,7 @@ function createInlineProvider(images: Map<string, ArrayBuffer>): MediaProvider {
95
95
  export function useFrameCapture(): FrameCaptureHandle {
96
96
  const containerRef = useRef<HTMLDivElement | null>(null);
97
97
  const rootRef = useRef<Root | null>(null);
98
+ const renderAPIRef = useRef<SquisqRenderAPI | null>(null);
98
99
  const dimensionsRef = useRef<{ width: number; height: number }>({ width: 1920, height: 1080 });
99
100
 
100
101
  const init = useCallback(
@@ -111,6 +112,7 @@ export function useFrameCapture(): FrameCaptureHandle {
111
112
  const oldContainer = containerRef.current;
112
113
  rootRef.current = null;
113
114
  containerRef.current = null;
115
+ renderAPIRef.current = null;
114
116
  await new Promise<void>((resolve) => {
115
117
  setTimeout(() => {
116
118
  if (oldRoot) oldRoot.unmount();
@@ -122,6 +124,7 @@ export function useFrameCapture(): FrameCaptureHandle {
122
124
 
123
125
  const width = renderOptions.width ?? 1920;
124
126
  const height = renderOptions.height ?? 1080;
127
+ const animationsEnabled = renderOptions.animationsEnabled ?? true;
125
128
  dimensionsRef.current = { width, height };
126
129
 
127
130
  // Create a hidden container
@@ -150,16 +153,26 @@ export function useFrameCapture(): FrameCaptureHandle {
150
153
  // Derive caption props from captionMode
151
154
  const captionsEnabled = captionMode !== undefined && captionMode !== 'off';
152
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
+ });
153
160
 
154
161
  const playerElement = createElement(DocPlayer, {
155
162
  doc,
156
163
  basePath: '.',
157
164
  renderMode: true,
165
+ animationsEnabled,
158
166
  showControls: false,
159
167
  autoPlay: false,
160
168
  forceViewport: { width, height, name: 'export' },
161
169
  captionsEnabled,
162
170
  captionStyle,
171
+ onRenderAPIReady: (api: SquisqRenderAPI | null) => {
172
+ if (containerRef.current !== container) return;
173
+ renderAPIRef.current = api;
174
+ if (api) resolveRenderAPI(api);
175
+ },
163
176
  });
164
177
 
165
178
  // Defer rendering to the next microtask to avoid "synchronously unmount
@@ -173,12 +186,12 @@ export function useFrameCapture(): FrameCaptureHandle {
173
186
  root.render(playerElement);
174
187
  }
175
188
 
176
- // Wait for the render API to appear on window
189
+ // Wait for this exact player's instance API.
177
190
  return new Promise<number>((resolve, reject) => {
178
191
  const timeout = setTimeout(() => {
179
- const w = window as SquisqWindow;
180
- const hasSeek = typeof w.seekTo === 'function';
181
- const hasDur = typeof w.getDuration === 'function';
192
+ const api = renderAPIRef.current;
193
+ const hasSeek = typeof api?.seekTo === 'function';
194
+ const hasDur = typeof api?.getDuration === 'function';
182
195
  const rootEl = containerRef.current?.querySelector('#squisq-capture-root');
183
196
  const hasPlayer = rootEl ? rootEl.querySelector('.doc-player') !== null : false;
184
197
  reject(
@@ -189,19 +202,10 @@ export function useFrameCapture(): FrameCaptureHandle {
189
202
  );
190
203
  }, 15000);
191
204
 
192
- const checkApi = () => {
193
- const w = window as SquisqWindow;
194
- if (typeof w.getDuration === 'function' && typeof w.seekTo === 'function') {
195
- clearTimeout(timeout);
196
- const duration = w.getDuration();
197
- resolve(duration);
198
- } else {
199
- requestAnimationFrame(checkApi);
200
- }
201
- };
202
-
203
- // Give React time to mount and run useEffects
204
- setTimeout(checkApi, 500);
205
+ void renderAPIReady.then((api) => {
206
+ clearTimeout(timeout);
207
+ resolve(api.getDuration());
208
+ });
205
209
  });
206
210
  },
207
211
  [],
@@ -209,15 +213,15 @@ export function useFrameCapture(): FrameCaptureHandle {
209
213
 
210
214
  const captureFrame = useCallback(async (time: number): Promise<ImageBitmap> => {
211
215
  const container = containerRef.current;
212
- const w = window as SquisqWindow;
213
- if (!container || typeof w.seekTo !== 'function') {
216
+ const api = renderAPIRef.current;
217
+ if (!container || !api) {
214
218
  throw new Error('Frame capture not initialized — call init() first');
215
219
  }
216
220
 
217
221
  const { width, height } = dimensionsRef.current;
218
222
 
219
223
  // Seek the player to the target time
220
- await w.seekTo(time);
224
+ await api.seekTo(time);
221
225
 
222
226
  // Wait for the DOM to update after seek
223
227
  await new Promise<void>((resolve) =>
@@ -255,6 +259,7 @@ export function useFrameCapture(): FrameCaptureHandle {
255
259
  containerRef.current.remove();
256
260
  containerRef.current = null;
257
261
  }
262
+ renderAPIRef.current = null;
258
263
  }, []);
259
264
 
260
265
  // Return a stable object to prevent useEffect cleanup loops