@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,108 +0,0 @@
1
- /**
2
- * audioTrack — tier selection + capability probe.
3
- *
4
- * The tier decision is the boundary `useVideoExport` delegates to, so testing
5
- * `selectAudioTier` directly exercises the exact logic the hook runs (jsdom
6
- * lacks real WebCodecs / OfflineAudioContext, so the end-to-end encode path
7
- * can only be exercised in a browser). `supportsWebCodecsAac` is verified to
8
- * degrade to `false` when `AudioEncoder` is undefined (jsdom's state).
9
- */
10
-
11
- import { describe, it, expect } from 'vitest';
12
- import {
13
- selectAudioTier,
14
- supportsWebCodecsAac,
15
- REASON_NO_AAC_NO_SAB,
16
- audioBufferToWav,
17
- } from '../audioTrack.js';
18
-
19
- describe('selectAudioTier', () => {
20
- it('tier 1 when AAC is supported on the main-thread WebCodecs encoder', () => {
21
- expect(
22
- selectAudioTier({
23
- hasClips: true,
24
- aacSupported: true,
25
- sharedArrayBufferAvailable: true,
26
- canUseMainThreadWebCodecs: true,
27
- }),
28
- ).toEqual({ tier: 1, reason: null });
29
- });
30
-
31
- it('tier 2 when AAC is unsupported but SharedArrayBuffer (ffmpeg.wasm) is available', () => {
32
- expect(
33
- selectAudioTier({
34
- hasClips: true,
35
- aacSupported: false,
36
- sharedArrayBufferAvailable: true,
37
- canUseMainThreadWebCodecs: false,
38
- }),
39
- ).toEqual({ tier: 2, reason: null });
40
- });
41
-
42
- it('tier 2 when AAC is supported but the main-thread encoder is not in use', () => {
43
- // AAC exists but the video is going through the worker/ffmpeg path — the
44
- // inline muxer isn't available, so fall back to the ffmpeg mux pass.
45
- expect(
46
- selectAudioTier({
47
- hasClips: true,
48
- aacSupported: true,
49
- sharedArrayBufferAvailable: true,
50
- canUseMainThreadWebCodecs: false,
51
- }),
52
- ).toEqual({ tier: 2, reason: null });
53
- });
54
-
55
- it('tier 3 with a reason when neither AAC nor SharedArrayBuffer is available', () => {
56
- expect(
57
- selectAudioTier({
58
- hasClips: true,
59
- aacSupported: false,
60
- sharedArrayBufferAvailable: false,
61
- canUseMainThreadWebCodecs: false,
62
- }),
63
- ).toEqual({ tier: 3, reason: REASON_NO_AAC_NO_SAB });
64
- });
65
-
66
- it('tier 3 with a null reason when there is no audio to include', () => {
67
- expect(
68
- selectAudioTier({
69
- hasClips: false,
70
- aacSupported: true,
71
- sharedArrayBufferAvailable: true,
72
- canUseMainThreadWebCodecs: true,
73
- }),
74
- ).toEqual({ tier: 3, reason: null });
75
- });
76
- });
77
-
78
- describe('supportsWebCodecsAac', () => {
79
- it('returns false when AudioEncoder is undefined (jsdom/Node)', async () => {
80
- expect(typeof AudioEncoder).toBe('undefined');
81
- await expect(supportsWebCodecsAac()).resolves.toBe(false);
82
- });
83
- });
84
-
85
- describe('audioBufferToWav', () => {
86
- it('serializes a minimal AudioBuffer-shaped value to a RIFF/WAVE header', () => {
87
- // Hand-rolled AudioBuffer stand-in (jsdom has no AudioBuffer). Only the
88
- // fields audioBufferToWav reads are provided.
89
- const frames = 4;
90
- const left = new Float32Array([0, 0.5, -0.5, 1]);
91
- const right = new Float32Array([0, -1, 0.25, -0.25]);
92
- const fake = {
93
- numberOfChannels: 2,
94
- sampleRate: 48_000,
95
- length: frames,
96
- getChannelData: (ch: number) => (ch === 0 ? left : right),
97
- } as unknown as AudioBuffer;
98
-
99
- const wav = audioBufferToWav(fake);
100
- const ascii = (i: number, n: number) =>
101
- String.fromCharCode(...Array.from(wav.subarray(i, i + n)));
102
- expect(ascii(0, 4)).toBe('RIFF');
103
- expect(ascii(8, 4)).toBe('WAVE');
104
- expect(ascii(36, 4)).toBe('data');
105
- // 44-byte header + 2 channels * 2 bytes * 4 frames = 60 bytes.
106
- expect(wav.byteLength).toBe(44 + frames * 2 * 2);
107
- });
108
- });
@@ -1,118 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
-
3
- const ffmpegState = vi.hoisted(() => ({
4
- exitCode: 0,
5
- loadConfig: undefined as unknown,
6
- writes: [] as Array<[string, Uint8Array]>,
7
- execArgs: [] as string[],
8
- readPath: '',
9
- execPromise: null as Promise<number> | null,
10
- terminate: vi.fn(),
11
- }));
12
-
13
- vi.mock('@ffmpeg/ffmpeg', () => ({
14
- FFmpeg: class {
15
- async load(config?: unknown): Promise<boolean> {
16
- ffmpegState.loadConfig = config;
17
- return true;
18
- }
19
- async writeFile(path: string, data: Uint8Array): Promise<void> {
20
- ffmpegState.writes.push([path, data]);
21
- }
22
- async exec(args: string[]): Promise<number> {
23
- ffmpegState.execArgs = args;
24
- return ffmpegState.execPromise ?? ffmpegState.exitCode;
25
- }
26
- async readFile(path: string): Promise<Uint8Array> {
27
- ffmpegState.readPath = path;
28
- return new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
29
- }
30
- terminate(): void {
31
- ffmpegState.terminate();
32
- }
33
- },
34
- }));
35
-
36
- import { buildGifFfmpegArgs, transcodeMp4ToGifWithFfmpegWasm } from '../gifTranscode.js';
37
-
38
- describe('GIF ffmpeg.wasm transcode', () => {
39
- beforeEach(() => {
40
- ffmpegState.exitCode = 0;
41
- ffmpegState.loadConfig = undefined;
42
- ffmpegState.writes = [];
43
- ffmpegState.execArgs = [];
44
- ffmpegState.readPath = '';
45
- ffmpegState.execPromise = null;
46
- ffmpegState.terminate.mockClear();
47
- });
48
-
49
- it('builds a looping palette-based GIF command from the shared argument builder', () => {
50
- const args = buildGifFfmpegArgs({ width: 960, height: 540, loop: 0 });
51
- expect(args.slice(0, 3)).toEqual(['-y', '-i', 'video.mp4']);
52
- expect(args).toContain('-filter_complex');
53
- expect(args.join(' ')).toContain('palettegen=stats_mode=diff');
54
- expect(args.join(' ')).toContain('paletteuse=dither=sierra2_4a:diff_mode=rectangle');
55
- expect(args.slice(-3)).toEqual(['-loop', '0', 'out.gif']);
56
- });
57
-
58
- it('writes the MP4 intermediate, reads GIF89a bytes, and threads runtime URLs', async () => {
59
- const input = new Uint8Array([1, 2, 3]);
60
- const output = await transcodeMp4ToGifWithFfmpegWasm(
61
- input,
62
- { width: 960, height: 540 },
63
- {
64
- coreURL: '/vendor/core.js',
65
- wasmURL: '/vendor/core.wasm',
66
- classWorkerURL: '/vendor/class-worker.js',
67
- },
68
- );
69
-
70
- expect(ffmpegState.loadConfig).toEqual({
71
- coreURL: '/vendor/core.js',
72
- wasmURL: '/vendor/core.wasm',
73
- classWorkerURL: '/vendor/class-worker.js',
74
- });
75
- expect(ffmpegState.writes).toEqual([['video.mp4', input]]);
76
- expect(ffmpegState.execArgs.at(-1)).toBe('out.gif');
77
- expect(ffmpegState.readPath).toBe('out.gif');
78
- expect(new TextDecoder().decode(output)).toBe('GIF89a');
79
- expect(ffmpegState.terminate).toHaveBeenCalledOnce();
80
- });
81
-
82
- it('reports ffmpeg failures and still terminates the runtime', async () => {
83
- ffmpegState.exitCode = 7;
84
- await expect(
85
- transcodeMp4ToGifWithFfmpegWasm(new Uint8Array([1]), { width: 960, height: 540 }),
86
- ).rejects.toThrow('GIF transcode failed with exit code 7');
87
- expect(ffmpegState.terminate).toHaveBeenCalledOnce();
88
- });
89
-
90
- it('rejects an empty MP4 before allocating the runtime', async () => {
91
- await expect(
92
- transcodeMp4ToGifWithFfmpegWasm(new Uint8Array(), { width: 960, height: 540 }),
93
- ).rejects.toThrow('empty MP4');
94
- expect(ffmpegState.terminate).not.toHaveBeenCalled();
95
- });
96
-
97
- it('terminates an in-progress palette transcode when aborted', async () => {
98
- let finishExec!: (exitCode: number) => void;
99
- ffmpegState.execPromise = new Promise<number>((resolve) => {
100
- finishExec = resolve;
101
- });
102
- const controller = new AbortController();
103
- const pending = transcodeMp4ToGifWithFfmpegWasm(
104
- new Uint8Array([1]),
105
- { width: 960, height: 540 },
106
- undefined,
107
- controller.signal,
108
- );
109
-
110
- await vi.waitFor(() => expect(ffmpegState.execArgs).not.toEqual([]));
111
- controller.abort();
112
- expect(ffmpegState.terminate).toHaveBeenCalledOnce();
113
- finishExec(0);
114
-
115
- await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
116
- expect(ffmpegState.terminate).toHaveBeenCalledOnce();
117
- });
118
- });
@@ -1,72 +0,0 @@
1
- /**
2
- * mp4Mux audio track — verifies the additive AAC track wiring.
3
- *
4
- * mp4-muxer runs in Node, so we can add a raw audio sample and assert the
5
- * finalized MP4 actually carries an `mp4a` sample-description box (i.e. the
6
- * audio track made it into the container). We also assert the video-only
7
- * configuration produces no such box, guarding the "byte-identical when no
8
- * audio" contract.
9
- */
10
-
11
- import { describe, it, expect } from 'vitest';
12
- import { createMp4Muxer, type Mp4MuxerHandle } from '../mp4Mux.js';
13
-
14
- /** Add one stub AVC keyframe so the always-present video track can finalize. */
15
- function addStubVideoSample(muxer: Mp4MuxerHandle): void {
16
- const avcDescription = new Uint8Array([
17
- 0x01, 0x64, 0x00, 0x28, 0xff, 0xe1, 0x00, 0x04, 0x67, 0x64, 0x00, 0x28, 0x01, 0x00, 0x04, 0x68,
18
- 0xee, 0x3c, 0x80,
19
- ]);
20
- muxer.addVideoChunkRaw(new Uint8Array([0x00, 0x00, 0x00, 0x02, 0x09, 0x10]), 'key', 0, 33_333, {
21
- decoderConfig: { codec: 'avc1.640028', description: avcDescription },
22
- });
23
- }
24
-
25
- /** Find the ASCII 4CC `needle` anywhere in the MP4 bytes. */
26
- function containsFourCC(buffer: ArrayBuffer, needle: string): boolean {
27
- const bytes = new Uint8Array(buffer);
28
- const pat = Array.from(needle, (c) => c.charCodeAt(0));
29
- outer: for (let i = 0; i + pat.length <= bytes.length; i++) {
30
- for (let j = 0; j < pat.length; j++) {
31
- if (bytes[i + j] !== pat[j]) continue outer;
32
- }
33
- return true;
34
- }
35
- return false;
36
- }
37
-
38
- describe('createMp4Muxer audio track', () => {
39
- it('writes an mp4a sample entry when an audio track is configured', () => {
40
- const muxer = createMp4Muxer({
41
- width: 320,
42
- height: 240,
43
- fps: 30,
44
- audio: { numberOfChannels: 2, sampleRate: 48_000 },
45
- });
46
-
47
- expect(muxer.hasAudioTrack).toBe(true);
48
-
49
- // The video track needs at least one sample to finalize. Feed a raw AVC
50
- // sample with a stub decoderConfig (bytes don't need to be a valid stream
51
- // for the container-level box check).
52
- addStubVideoSample(muxer);
53
-
54
- // Feed one raw AAC sample (contents don't matter for the container check).
55
- const sample = new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
56
- // timestamp/duration are in microseconds.
57
- muxer.addAudioChunkRaw(sample, 'key', 0, 1_000_000);
58
-
59
- const mp4 = muxer.finalize();
60
- expect(mp4.byteLength).toBeGreaterThan(0);
61
- // The AAC audio sample entry box is named 'mp4a'.
62
- expect(containsFourCC(mp4, 'mp4a')).toBe(true);
63
- });
64
-
65
- it('produces no audio track (no mp4a box) when audio is omitted', () => {
66
- const muxer = createMp4Muxer({ width: 320, height: 240, fps: 30 });
67
- expect(muxer.hasAudioTrack).toBe(false);
68
- addStubVideoSample(muxer);
69
- const mp4 = muxer.finalize();
70
- expect(containsFourCC(mp4, 'mp4a')).toBe(false);
71
- });
72
- });
@@ -1,155 +0,0 @@
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
- });
@@ -1,148 +0,0 @@
1
- /**
2
- * Prop-surface tests for the video export components.
3
- *
4
- * `playerScript` is unused on the browser export path (frames are captured
5
- * from a live in-page DocPlayer), so both VideoExportButton and
6
- * VideoExportModal must accept being rendered without it.
7
- */
8
-
9
- import { describe, it, expect } from 'vitest';
10
- import { fireEvent, render, renderHook } from '@testing-library/react';
11
- import { VideoExportButton } from '../VideoExportButton';
12
- import { VideoExportModal } from '../VideoExportModal';
13
- import { useVideoExport } from '../hooks/useVideoExport';
14
- import type { Doc } from '@bendyline/squisq/schemas';
15
-
16
- function minimalDoc(): Doc {
17
- return {
18
- articleId: 'export-test',
19
- duration: 5,
20
- blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
21
- audio: { segments: [] },
22
- };
23
- }
24
-
25
- describe('VideoExportButton', () => {
26
- it('renders without a playerScript prop', () => {
27
- const { getByRole } = render(<VideoExportButton doc={minimalDoc()} />);
28
- expect(getByRole('button', { name: 'Export Video' })).toBeTruthy();
29
- });
30
-
31
- it('accepts a defaultConfig prop', () => {
32
- const { getByRole } = render(
33
- <VideoExportButton doc={minimalDoc()} defaultConfig={{ quality: 'high', fps: 30 }} />,
34
- );
35
- expect(getByRole('button', { name: 'Export Video' })).toBeTruthy();
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
- });
50
- });
51
-
52
- describe('VideoExportModal', () => {
53
- it('renders the configure state without a playerScript prop', () => {
54
- const { container } = render(<VideoExportModal doc={minimalDoc()} onClose={() => {}} />);
55
- expect(container.textContent).toContain('Export Video');
56
- });
57
-
58
- it('seeds the initial selections from defaultConfig', () => {
59
- const { container } = render(
60
- <VideoExportModal
61
- doc={minimalDoc()}
62
- defaultConfig={{
63
- quality: 'high',
64
- fps: 30,
65
- orientation: 'portrait',
66
- captionMode: 'standard',
67
- }}
68
- onClose={() => {}}
69
- />,
70
- );
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');
138
- });
139
- });
140
-
141
- describe('useVideoExport result shape', () => {
142
- it('exposes the additive audio result fields, defaulted for idle', () => {
143
- const { result } = renderHook(() => useVideoExport());
144
- expect(result.current.outputFormat).toBe('mp4');
145
- expect(result.current.audioIncluded).toBe(false);
146
- expect(result.current.audioSkippedReason).toBeNull();
147
- });
148
- });