@bendyline/squisq-video-react 1.2.0 → 1.2.2
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 +31 -5
- package/dist/chunk-6DKLHXX3.js +47 -0
- package/dist/chunk-6DKLHXX3.js.map +1 -0
- package/dist/index.d.ts +120 -38
- package/dist/index.js +320 -25
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +3 -22
- package/dist/workers/encode.worker.js.map +1 -1
- package/package.json +8 -5
- package/src/VideoExportButton.tsx +14 -2
- package/src/VideoExportModal.tsx +37 -7
- package/src/__tests__/audioTrack.test.ts +108 -0
- package/src/__tests__/mp4MuxAudio.test.ts +72 -0
- package/src/__tests__/videoExportProps.test.tsx +73 -0
- package/src/audioTrack.ts +323 -0
- package/src/hooks/useFrameCapture.ts +1 -1
- package/src/hooks/useVideoExport.ts +187 -6
- package/src/index.ts +5 -2
- package/src/mainThreadEncoder.ts +26 -13
- package/src/mp4Mux.ts +77 -3
- package/src/workers/encode.worker.ts +6 -9
- package/dist/chunk-LZYWP4HB.js +0 -28
- package/dist/chunk-LZYWP4HB.js.map +0 -1
- package/src/__tests__/encodeParams.test.ts +0 -33
- package/src/workers/encodeParams.ts +0 -24
|
@@ -10,18 +10,28 @@ import { createPortal } from 'react-dom';
|
|
|
10
10
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
11
11
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
12
12
|
import { VideoExportModal } from './VideoExportModal.js';
|
|
13
|
+
import type { VideoExportConfig } from './hooks/useVideoExport.js';
|
|
13
14
|
|
|
14
15
|
export interface VideoExportButtonProps {
|
|
15
16
|
/** The document to export */
|
|
16
17
|
doc: Doc;
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Player IIFE bundle source. Unused by the browser export path (frames
|
|
20
|
+
* are captured from a live in-page DocPlayer); only forwarded for
|
|
21
|
+
* CLI/Playwright-style pipelines that render standalone HTML.
|
|
22
|
+
*/
|
|
23
|
+
playerScript?: string;
|
|
19
24
|
/** Optional media provider for resolving images/audio */
|
|
20
25
|
mediaProvider?: MediaProvider;
|
|
21
26
|
/** Pre-collected images map */
|
|
22
27
|
images?: Map<string, ArrayBuffer>;
|
|
23
28
|
/** Pre-collected audio map */
|
|
24
29
|
audio?: Map<string, ArrayBuffer>;
|
|
30
|
+
/**
|
|
31
|
+
* Seeds the modal's initial export settings and is merged as a base into the
|
|
32
|
+
* config passed to the export hook. Forwarded to {@link VideoExportModal}.
|
|
33
|
+
*/
|
|
34
|
+
defaultConfig?: Partial<VideoExportConfig>;
|
|
25
35
|
/** Button label (default: "Export Video") */
|
|
26
36
|
label?: string;
|
|
27
37
|
/** Additional inline styles for the button */
|
|
@@ -36,6 +46,7 @@ export function VideoExportButton({
|
|
|
36
46
|
mediaProvider,
|
|
37
47
|
images,
|
|
38
48
|
audio,
|
|
49
|
+
defaultConfig,
|
|
39
50
|
label = 'Export Video',
|
|
40
51
|
style,
|
|
41
52
|
disabled,
|
|
@@ -59,6 +70,7 @@ export function VideoExportButton({
|
|
|
59
70
|
mediaProvider={mediaProvider}
|
|
60
71
|
images={images}
|
|
61
72
|
audio={audio}
|
|
73
|
+
defaultConfig={defaultConfig}
|
|
62
74
|
onClose={handleClose}
|
|
63
75
|
/>,
|
|
64
76
|
document.body,
|
package/src/VideoExportModal.tsx
CHANGED
|
@@ -19,14 +19,26 @@ import { useVideoExport, type VideoExportConfig } from './hooks/useVideoExport.j
|
|
|
19
19
|
export interface VideoExportModalProps {
|
|
20
20
|
/** The document to export */
|
|
21
21
|
doc: Doc;
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Player IIFE bundle source. Unused by the browser export path (frames
|
|
24
|
+
* are captured from a live in-page DocPlayer); only forwarded for
|
|
25
|
+
* CLI/Playwright-style pipelines that render standalone HTML.
|
|
26
|
+
*/
|
|
27
|
+
playerScript?: string;
|
|
24
28
|
/** Optional media provider for resolving images/audio */
|
|
25
29
|
mediaProvider?: MediaProvider;
|
|
26
30
|
/** Pre-collected images map (alternative to mediaProvider) */
|
|
27
31
|
images?: Map<string, ArrayBuffer>;
|
|
28
32
|
/** Pre-collected audio map */
|
|
29
33
|
audio?: Map<string, ArrayBuffer>;
|
|
34
|
+
/**
|
|
35
|
+
* Seeds the modal's initial quality/fps/orientation/captionMode selections
|
|
36
|
+
* and is merged (as a base) into the config passed to the export hook, so a
|
|
37
|
+
* host can share one config shape with `useVideoExport`. The individual
|
|
38
|
+
* `images`/`audio`/`mediaProvider`/`playerScript` props still take
|
|
39
|
+
* precedence over any matching key here.
|
|
40
|
+
*/
|
|
41
|
+
defaultConfig?: Partial<VideoExportConfig>;
|
|
30
42
|
/** Called when the modal should close */
|
|
31
43
|
onClose: () => void;
|
|
32
44
|
}
|
|
@@ -139,12 +151,15 @@ export function VideoExportModal({
|
|
|
139
151
|
mediaProvider,
|
|
140
152
|
images,
|
|
141
153
|
audio,
|
|
154
|
+
defaultConfig,
|
|
142
155
|
onClose,
|
|
143
156
|
}: VideoExportModalProps) {
|
|
144
|
-
const [quality, setQuality] = useState<VideoQuality>('normal');
|
|
145
|
-
const [fps, setFps] = useState(24);
|
|
146
|
-
const [orientation, setOrientation] = useState<VideoOrientation>(
|
|
147
|
-
|
|
157
|
+
const [quality, setQuality] = useState<VideoQuality>(defaultConfig?.quality ?? 'normal');
|
|
158
|
+
const [fps, setFps] = useState(defaultConfig?.fps ?? 24);
|
|
159
|
+
const [orientation, setOrientation] = useState<VideoOrientation>(
|
|
160
|
+
defaultConfig?.orientation ?? 'landscape',
|
|
161
|
+
);
|
|
162
|
+
const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultConfig?.captionMode ?? 'off');
|
|
148
163
|
|
|
149
164
|
const exportHook = useVideoExport();
|
|
150
165
|
const {
|
|
@@ -153,6 +168,8 @@ export function VideoExportModal({
|
|
|
153
168
|
backend,
|
|
154
169
|
downloadUrl,
|
|
155
170
|
fileSize,
|
|
171
|
+
audioIncluded,
|
|
172
|
+
audioSkippedReason,
|
|
156
173
|
error,
|
|
157
174
|
elapsed,
|
|
158
175
|
estimatedRemaining,
|
|
@@ -163,6 +180,8 @@ export function VideoExportModal({
|
|
|
163
180
|
|
|
164
181
|
const handleExport = useCallback(async () => {
|
|
165
182
|
const config: VideoExportConfig = {
|
|
183
|
+
// defaultConfig is the base; explicit props/selections win over it.
|
|
184
|
+
...defaultConfig,
|
|
166
185
|
quality,
|
|
167
186
|
fps,
|
|
168
187
|
orientation,
|
|
@@ -170,7 +189,8 @@ export function VideoExportModal({
|
|
|
170
189
|
images,
|
|
171
190
|
audio,
|
|
172
191
|
mediaProvider,
|
|
173
|
-
|
|
192
|
+
// Only thread the bundle through when the host actually supplied one.
|
|
193
|
+
...(playerScript !== undefined ? { playerScript } : {}),
|
|
174
194
|
};
|
|
175
195
|
await startExport(doc, config);
|
|
176
196
|
}, [
|
|
@@ -183,6 +203,7 @@ export function VideoExportModal({
|
|
|
183
203
|
audio,
|
|
184
204
|
mediaProvider,
|
|
185
205
|
playerScript,
|
|
206
|
+
defaultConfig,
|
|
186
207
|
startExport,
|
|
187
208
|
]);
|
|
188
209
|
|
|
@@ -318,6 +339,15 @@ export function VideoExportModal({
|
|
|
318
339
|
<p style={{ fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }}>
|
|
319
340
|
File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
|
|
320
341
|
</p>
|
|
342
|
+
{audioIncluded ? (
|
|
343
|
+
<p style={{ fontSize: 12, color: '#2d6a10', margin: '0 0 4px 0' }}>
|
|
344
|
+
Audio included ✓
|
|
345
|
+
</p>
|
|
346
|
+
) : (
|
|
347
|
+
<p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 4px 0' }}>
|
|
348
|
+
Video only{audioSkippedReason ? ` — ${audioSkippedReason}` : ''}
|
|
349
|
+
</p>
|
|
350
|
+
)}
|
|
321
351
|
{backend && (
|
|
322
352
|
<p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 12px 0' }}>
|
|
323
353
|
Encoded with WebCodecs (H.264)
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { 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
|
+
|
|
39
|
+
describe('VideoExportModal', () => {
|
|
40
|
+
it('renders the configure state without a playerScript prop', () => {
|
|
41
|
+
const { container } = render(<VideoExportModal doc={minimalDoc()} onClose={() => {}} />);
|
|
42
|
+
expect(container.textContent).toContain('Export Video');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('seeds the initial selections from defaultConfig', () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<VideoExportModal
|
|
48
|
+
doc={minimalDoc()}
|
|
49
|
+
defaultConfig={{
|
|
50
|
+
quality: 'high',
|
|
51
|
+
fps: 30,
|
|
52
|
+
orientation: 'portrait',
|
|
53
|
+
captionMode: 'standard',
|
|
54
|
+
}}
|
|
55
|
+
onClose={() => {}}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
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');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('useVideoExport result shape', () => {
|
|
68
|
+
it('exposes the additive audio result fields, defaulted for idle', () => {
|
|
69
|
+
const { result } = renderHook(() => useVideoExport());
|
|
70
|
+
expect(result.current.audioIncluded).toBe(false);
|
|
71
|
+
expect(result.current.audioSkippedReason).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|