@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.
- package/README.md +63 -22
- package/dist/index.d.ts +33 -13
- package/dist/index.js +511 -212
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +110 -80
- package/dist/workers/encode.worker.js.map +1 -1
- package/dist/workers/ffmpeg.class-worker.d.ts +2 -0
- package/dist/workers/ffmpeg.class-worker.js +180 -0
- package/dist/workers/ffmpeg.class-worker.js.map +1 -0
- package/package.json +7 -5
- package/src/VideoExportButton.tsx +12 -5
- package/src/VideoExportModal.tsx +219 -62
- package/src/__tests__/gifTranscode.test.ts +118 -0
- package/src/__tests__/useVideoExportGif.test.ts +155 -0
- package/src/__tests__/videoExportProps.test.tsx +82 -7
- package/src/__tests__/workerEncoder.test.ts +143 -0
- package/src/audioTrack.ts +18 -9
- package/src/gifTranscode.ts +75 -0
- package/src/hooks/useFrameCapture.ts +26 -21
- package/src/hooks/useVideoExport.ts +104 -15
- package/src/index.ts +2 -0
- package/src/mainThreadEncoder.ts +5 -10
- package/src/workerEncoder.ts +66 -14
- package/src/workers/encode.worker.ts +134 -86
- package/src/workers/ffmpeg.class-worker.ts +11 -0
- package/src/workers/workerTypes.ts +9 -1
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
189
|
+
// Wait for this exact player's instance API.
|
|
177
190
|
return new Promise<number>((resolve, reject) => {
|
|
178
191
|
const timeout = setTimeout(() => {
|
|
179
|
-
const
|
|
180
|
-
const hasSeek = typeof
|
|
181
|
-
const hasDur = typeof
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
213
|
-
if (!container ||
|
|
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
|
|
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
|