@bendyline/squisq-video-react 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,143 +0,0 @@
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 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
- }