@bendyline/squisq-video-react 2.0.2 → 2.2.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.
- package/COPYING.GPL-2.0.txt +339 -0
- package/LICENSE +21 -0
- package/NOTICE.md +39 -0
- package/README.md +34 -7
- package/dist/chunk-KQG6DZ7G.js +549 -0
- package/dist/chunk-VI5AIVOQ.js +1951 -0
- package/dist/chunk-VILZP3GL.js +856 -0
- package/dist/chunk-YZN7D4IC.js +300 -0
- package/dist/components/index.d.ts +90 -0
- package/dist/components/index.js +11 -0
- package/dist/encoder/index.d.ts +95 -0
- package/dist/encoder/index.js +13 -0
- package/dist/hooks/index.d.ts +32 -0
- package/dist/hooks/index.js +10 -0
- package/dist/index.d.ts +8 -277
- package/dist/index.js +12 -1503
- package/dist/useVideoExport-NtqZVgaz.d.ts +115 -0
- package/dist/workers/encode.worker.js +37 -20
- package/dist/workers/ffmpeg.class-worker.js +0 -1
- package/package.json +26 -8
- package/dist/chunk-6DKLHXX3.js +0 -47
- package/dist/chunk-6DKLHXX3.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/workers/encode.worker.js.map +0 -1
- package/dist/workers/ffmpeg.class-worker.js.map +0 -1
- package/src/VideoExportButton.tsx +0 -87
- package/src/VideoExportModal.tsx +0 -553
- package/src/__tests__/audioTrack.test.ts +0 -108
- package/src/__tests__/gifTranscode.test.ts +0 -118
- package/src/__tests__/mp4MuxAudio.test.ts +0 -72
- package/src/__tests__/useVideoExportGif.test.ts +0 -155
- package/src/__tests__/videoExportProps.test.tsx +0 -148
- package/src/__tests__/workerEncoder.test.ts +0 -143
- package/src/audioTrack.ts +0 -332
- package/src/gifTranscode.ts +0 -75
- package/src/hooks/useFrameCapture.ts +0 -268
- package/src/hooks/useVideoExport.ts +0 -647
- package/src/index.ts +0 -40
- package/src/mainThreadEncoder.ts +0 -158
- package/src/mp4Mux.ts +0 -123
- package/src/workerEncoder.ts +0 -175
- package/src/workers/encode.worker.ts +0 -439
- package/src/workers/ffmpeg.class-worker.ts +0 -11
- package/src/workers/workerTypes.ts +0 -89
package/src/VideoExportModal.tsx
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VideoExportModal — Modal dialog for configuring and monitoring video export.
|
|
3
|
-
*
|
|
4
|
-
* States:
|
|
5
|
-
* configure → exporting (capturing + encoding) → complete | error
|
|
6
|
-
*
|
|
7
|
-
* Hosts may supply a resolved color scheme so this portaled dialog matches
|
|
8
|
-
* the surface that opened it.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { useState, useCallback } from 'react';
|
|
12
|
-
import type { Doc } from '@bendyline/squisq/schemas';
|
|
13
|
-
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
14
|
-
import type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';
|
|
15
|
-
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
16
|
-
import {
|
|
17
|
-
useVideoExport,
|
|
18
|
-
type VideoExportConfig,
|
|
19
|
-
type VideoOutputFormat,
|
|
20
|
-
} from './hooks/useVideoExport.js';
|
|
21
|
-
|
|
22
|
-
// ── Types ──────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export interface VideoExportModalProps {
|
|
25
|
-
/** The document to export */
|
|
26
|
-
doc: Doc;
|
|
27
|
-
/**
|
|
28
|
-
* Player IIFE bundle source. Unused by the browser export path (frames
|
|
29
|
-
* are captured from a live in-page DocPlayer); only forwarded for
|
|
30
|
-
* CLI/Playwright-style pipelines that render standalone HTML.
|
|
31
|
-
*/
|
|
32
|
-
playerScript?: string;
|
|
33
|
-
/** Optional media provider for resolving images/audio */
|
|
34
|
-
mediaProvider?: MediaProvider;
|
|
35
|
-
/** Pre-collected images map (alternative to mediaProvider) */
|
|
36
|
-
images?: Map<string, ArrayBuffer>;
|
|
37
|
-
/** Pre-collected audio map */
|
|
38
|
-
audio?: Map<string, ArrayBuffer>;
|
|
39
|
-
/**
|
|
40
|
-
* Seeds the modal's initial format, motion, quality, FPS, orientation, and
|
|
41
|
-
* caption selections. It is merged (as a base) into the config passed to the
|
|
42
|
-
* export hook, so a host can share one config shape with `useVideoExport`.
|
|
43
|
-
* The individual `images`/`audio`/`mediaProvider`/`playerScript` props still
|
|
44
|
-
* take precedence over any matching key here.
|
|
45
|
-
*/
|
|
46
|
-
defaultConfig?: Partial<VideoExportConfig>;
|
|
47
|
-
/** Visual color scheme for the portaled dialog. Defaults to light. */
|
|
48
|
-
colorScheme?: 'light' | 'dark';
|
|
49
|
-
/** Called when the modal should close */
|
|
50
|
-
onClose: () => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Helpers ────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
function formatDuration(seconds: number): string {
|
|
56
|
-
if (seconds < 60) return `${seconds}s`;
|
|
57
|
-
const m = Math.floor(seconds / 60);
|
|
58
|
-
const s = seconds % 60;
|
|
59
|
-
return `${m}m ${s}s`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function encoderLabel(
|
|
63
|
-
outputFormat: VideoOutputFormat,
|
|
64
|
-
backend: 'webcodecs' | 'ffmpeg-wasm',
|
|
65
|
-
): string {
|
|
66
|
-
if (outputFormat === 'gif') {
|
|
67
|
-
return backend === 'webcodecs'
|
|
68
|
-
? 'WebCodecs (H.264) → ffmpeg.wasm (GIF)'
|
|
69
|
-
: 'ffmpeg.wasm (H.264 → GIF)';
|
|
70
|
-
}
|
|
71
|
-
return backend === 'webcodecs' ? 'WebCodecs (H.264)' : 'ffmpeg.wasm (H.264)';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── Styles ─────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
interface VideoExportPalette {
|
|
77
|
-
overlay: string;
|
|
78
|
-
surface: string;
|
|
79
|
-
control: string;
|
|
80
|
-
border: string;
|
|
81
|
-
text: string;
|
|
82
|
-
heading: string;
|
|
83
|
-
label: string;
|
|
84
|
-
muted: string;
|
|
85
|
-
secondary: string;
|
|
86
|
-
primary: string;
|
|
87
|
-
primaryBorder: string;
|
|
88
|
-
success: string;
|
|
89
|
-
danger: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const VIDEO_EXPORT_PALETTES: Record<'light' | 'dark', VideoExportPalette> = {
|
|
93
|
-
light: {
|
|
94
|
-
overlay: 'rgba(0, 0, 0, 0.5)',
|
|
95
|
-
surface: '#FFFDF7',
|
|
96
|
-
control: '#ffffff',
|
|
97
|
-
border: '#c9b98a',
|
|
98
|
-
text: '#4a3c1f',
|
|
99
|
-
heading: '#2d2310',
|
|
100
|
-
label: '#5a4a2a',
|
|
101
|
-
muted: '#8a7a5a',
|
|
102
|
-
secondary: '#E8DFC6',
|
|
103
|
-
primary: '#8B6914',
|
|
104
|
-
primaryBorder: '#7a5c10',
|
|
105
|
-
success: '#2d6a10',
|
|
106
|
-
danger: '#a03020',
|
|
107
|
-
},
|
|
108
|
-
dark: {
|
|
109
|
-
overlay: 'rgba(2, 6, 23, 0.72)',
|
|
110
|
-
surface: '#111827',
|
|
111
|
-
control: '#0f172a',
|
|
112
|
-
border: '#475569',
|
|
113
|
-
text: '#e5e7eb',
|
|
114
|
-
heading: '#f8fafc',
|
|
115
|
-
label: '#cbd5e1',
|
|
116
|
-
muted: '#94a3b8',
|
|
117
|
-
secondary: '#1e293b',
|
|
118
|
-
primary: '#9a7416',
|
|
119
|
-
primaryBorder: '#d1a73b',
|
|
120
|
-
success: '#86efac',
|
|
121
|
-
danger: '#fca5a5',
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const overlayStyle: React.CSSProperties = {
|
|
126
|
-
position: 'fixed',
|
|
127
|
-
inset: 0,
|
|
128
|
-
display: 'flex',
|
|
129
|
-
alignItems: 'center',
|
|
130
|
-
justifyContent: 'center',
|
|
131
|
-
zIndex: 10000,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const modalStyle: React.CSSProperties = {
|
|
135
|
-
borderRadius: 0,
|
|
136
|
-
padding: '24px 28px',
|
|
137
|
-
minWidth: 380,
|
|
138
|
-
maxWidth: 480,
|
|
139
|
-
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
140
|
-
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const titleStyle: React.CSSProperties = {
|
|
144
|
-
margin: '0 0 16px 0',
|
|
145
|
-
fontSize: 18,
|
|
146
|
-
fontWeight: 600,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const labelStyle: React.CSSProperties = {
|
|
150
|
-
display: 'block',
|
|
151
|
-
fontSize: 13,
|
|
152
|
-
fontWeight: 500,
|
|
153
|
-
marginBottom: 4,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const selectStyle: React.CSSProperties = {
|
|
157
|
-
width: '100%',
|
|
158
|
-
padding: '6px 8px',
|
|
159
|
-
fontSize: 13,
|
|
160
|
-
fontFamily: 'inherit',
|
|
161
|
-
borderRadius: 0,
|
|
162
|
-
marginBottom: 12,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const btnPrimary: React.CSSProperties = {
|
|
166
|
-
padding: '8px 20px',
|
|
167
|
-
fontSize: 14,
|
|
168
|
-
fontFamily: 'inherit',
|
|
169
|
-
fontWeight: 500,
|
|
170
|
-
cursor: 'pointer',
|
|
171
|
-
color: '#fff',
|
|
172
|
-
borderRadius: 0,
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const btnSecondary: React.CSSProperties = {
|
|
176
|
-
padding: '8px 20px',
|
|
177
|
-
fontSize: 14,
|
|
178
|
-
fontFamily: 'inherit',
|
|
179
|
-
fontWeight: 500,
|
|
180
|
-
cursor: 'pointer',
|
|
181
|
-
borderRadius: 0,
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const progressBarOuterStyle: React.CSSProperties = {
|
|
185
|
-
width: '100%',
|
|
186
|
-
height: 8,
|
|
187
|
-
borderRadius: 0,
|
|
188
|
-
overflow: 'hidden',
|
|
189
|
-
marginBottom: 8,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const footerStyle: React.CSSProperties = {
|
|
193
|
-
display: 'flex',
|
|
194
|
-
justifyContent: 'flex-end',
|
|
195
|
-
gap: 8,
|
|
196
|
-
marginTop: 20,
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// ── Component ──────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
export function VideoExportModal({
|
|
202
|
-
doc,
|
|
203
|
-
playerScript,
|
|
204
|
-
mediaProvider,
|
|
205
|
-
images,
|
|
206
|
-
audio,
|
|
207
|
-
defaultConfig,
|
|
208
|
-
colorScheme = 'light',
|
|
209
|
-
onClose,
|
|
210
|
-
}: VideoExportModalProps) {
|
|
211
|
-
const initialOutputFormat = defaultConfig?.outputFormat ?? 'mp4';
|
|
212
|
-
const [outputFormat, setOutputFormat] = useState<VideoOutputFormat>(initialOutputFormat);
|
|
213
|
-
const [quality, setQuality] = useState<VideoQuality>(defaultConfig?.quality ?? 'normal');
|
|
214
|
-
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === 'gif' ? 10 : 24));
|
|
215
|
-
const [orientation, setOrientation] = useState<VideoOrientation>(
|
|
216
|
-
defaultConfig?.orientation ?? 'landscape',
|
|
217
|
-
);
|
|
218
|
-
const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultConfig?.captionMode ?? 'off');
|
|
219
|
-
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
220
|
-
defaultConfig?.animationsEnabled ?? initialOutputFormat === 'mp4',
|
|
221
|
-
);
|
|
222
|
-
const palette = VIDEO_EXPORT_PALETTES[colorScheme];
|
|
223
|
-
const themedModalStyle: React.CSSProperties = {
|
|
224
|
-
...modalStyle,
|
|
225
|
-
background: palette.surface,
|
|
226
|
-
border: `1px solid ${palette.border}`,
|
|
227
|
-
color: palette.text,
|
|
228
|
-
colorScheme,
|
|
229
|
-
};
|
|
230
|
-
const themedTitleStyle: React.CSSProperties = { ...titleStyle, color: palette.heading };
|
|
231
|
-
const themedLabelStyle: React.CSSProperties = { ...labelStyle, color: palette.label };
|
|
232
|
-
const themedSelectStyle: React.CSSProperties = {
|
|
233
|
-
...selectStyle,
|
|
234
|
-
border: `1px solid ${palette.border}`,
|
|
235
|
-
background: palette.control,
|
|
236
|
-
color: palette.text,
|
|
237
|
-
colorScheme,
|
|
238
|
-
};
|
|
239
|
-
const themedPrimaryButtonStyle: React.CSSProperties = {
|
|
240
|
-
...btnPrimary,
|
|
241
|
-
background: palette.primary,
|
|
242
|
-
border: `1px solid ${palette.primaryBorder}`,
|
|
243
|
-
};
|
|
244
|
-
const themedSecondaryButtonStyle: React.CSSProperties = {
|
|
245
|
-
...btnSecondary,
|
|
246
|
-
background: palette.secondary,
|
|
247
|
-
color: palette.text,
|
|
248
|
-
border: `1px solid ${palette.border}`,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const exportHook = useVideoExport();
|
|
252
|
-
const {
|
|
253
|
-
state,
|
|
254
|
-
progress,
|
|
255
|
-
backend,
|
|
256
|
-
outputFormat: completedOutputFormat,
|
|
257
|
-
downloadUrl,
|
|
258
|
-
fileSize,
|
|
259
|
-
audioIncluded,
|
|
260
|
-
audioSkippedReason,
|
|
261
|
-
error,
|
|
262
|
-
elapsed,
|
|
263
|
-
estimatedRemaining,
|
|
264
|
-
startExport,
|
|
265
|
-
cancel: cancelExport,
|
|
266
|
-
reset: resetExport,
|
|
267
|
-
} = exportHook;
|
|
268
|
-
|
|
269
|
-
const handleOutputFormatChange = useCallback((next: VideoOutputFormat) => {
|
|
270
|
-
setOutputFormat(next);
|
|
271
|
-
if (next === 'gif') {
|
|
272
|
-
setFps(10);
|
|
273
|
-
setAnimationsEnabled(false);
|
|
274
|
-
} else {
|
|
275
|
-
setFps(24);
|
|
276
|
-
setAnimationsEnabled(true);
|
|
277
|
-
}
|
|
278
|
-
}, []);
|
|
279
|
-
|
|
280
|
-
const handleExport = useCallback(async () => {
|
|
281
|
-
const config: VideoExportConfig = {
|
|
282
|
-
// defaultConfig is the base; explicit props/selections win over it.
|
|
283
|
-
...defaultConfig,
|
|
284
|
-
outputFormat,
|
|
285
|
-
animationsEnabled,
|
|
286
|
-
quality,
|
|
287
|
-
fps,
|
|
288
|
-
orientation,
|
|
289
|
-
captionMode,
|
|
290
|
-
images,
|
|
291
|
-
audio,
|
|
292
|
-
mediaProvider,
|
|
293
|
-
// Only thread the bundle through when the host actually supplied one.
|
|
294
|
-
...(playerScript !== undefined ? { playerScript } : {}),
|
|
295
|
-
};
|
|
296
|
-
await startExport(doc, config);
|
|
297
|
-
}, [
|
|
298
|
-
doc,
|
|
299
|
-
outputFormat,
|
|
300
|
-
animationsEnabled,
|
|
301
|
-
quality,
|
|
302
|
-
fps,
|
|
303
|
-
orientation,
|
|
304
|
-
captionMode,
|
|
305
|
-
images,
|
|
306
|
-
audio,
|
|
307
|
-
mediaProvider,
|
|
308
|
-
playerScript,
|
|
309
|
-
defaultConfig,
|
|
310
|
-
startExport,
|
|
311
|
-
]);
|
|
312
|
-
|
|
313
|
-
const handleDownload = useCallback(() => {
|
|
314
|
-
if (!downloadUrl) return;
|
|
315
|
-
const a = document.createElement('a');
|
|
316
|
-
a.href = downloadUrl;
|
|
317
|
-
const ts = new Date().toISOString().slice(0, 10);
|
|
318
|
-
a.download = `document-${ts}.${completedOutputFormat}`;
|
|
319
|
-
document.body.appendChild(a);
|
|
320
|
-
a.click();
|
|
321
|
-
document.body.removeChild(a);
|
|
322
|
-
}, [downloadUrl, completedOutputFormat]);
|
|
323
|
-
|
|
324
|
-
const handleClose = useCallback(() => {
|
|
325
|
-
if (state === 'capturing' || state === 'encoding' || state === 'preparing') {
|
|
326
|
-
cancelExport();
|
|
327
|
-
}
|
|
328
|
-
resetExport();
|
|
329
|
-
onClose();
|
|
330
|
-
}, [state, cancelExport, resetExport, onClose]);
|
|
331
|
-
|
|
332
|
-
const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<div
|
|
336
|
-
style={{ ...overlayStyle, background: palette.overlay }}
|
|
337
|
-
data-color-scheme={colorScheme}
|
|
338
|
-
onClick={handleClose}
|
|
339
|
-
>
|
|
340
|
-
<div style={themedModalStyle} onClick={(e) => e.stopPropagation()}>
|
|
341
|
-
<h2 style={themedTitleStyle}>
|
|
342
|
-
{outputFormat === 'gif' ? 'Export Animated GIF' : 'Export Video'}
|
|
343
|
-
</h2>
|
|
344
|
-
|
|
345
|
-
{/* ── Configure State ── */}
|
|
346
|
-
{state === 'idle' && (
|
|
347
|
-
<>
|
|
348
|
-
<div>
|
|
349
|
-
<label style={themedLabelStyle}>Format</label>
|
|
350
|
-
<select
|
|
351
|
-
aria-label="Format"
|
|
352
|
-
style={themedSelectStyle}
|
|
353
|
-
value={outputFormat}
|
|
354
|
-
onChange={(e) => handleOutputFormatChange(e.target.value as VideoOutputFormat)}
|
|
355
|
-
>
|
|
356
|
-
<option value="mp4">MP4 video</option>
|
|
357
|
-
<option value="gif">Animated GIF</option>
|
|
358
|
-
</select>
|
|
359
|
-
</div>
|
|
360
|
-
|
|
361
|
-
<div>
|
|
362
|
-
<label style={themedLabelStyle}>Quality</label>
|
|
363
|
-
<select
|
|
364
|
-
aria-label="Quality"
|
|
365
|
-
style={themedSelectStyle}
|
|
366
|
-
value={quality}
|
|
367
|
-
onChange={(e) => setQuality(e.target.value as VideoQuality)}
|
|
368
|
-
>
|
|
369
|
-
<option value="draft">Draft — fast, lower quality</option>
|
|
370
|
-
<option value="normal">Normal — balanced</option>
|
|
371
|
-
<option value="high">High — best quality, slower</option>
|
|
372
|
-
</select>
|
|
373
|
-
</div>
|
|
374
|
-
|
|
375
|
-
<div>
|
|
376
|
-
<label style={themedLabelStyle}>Frame Rate</label>
|
|
377
|
-
<select
|
|
378
|
-
aria-label="Frame Rate"
|
|
379
|
-
style={themedSelectStyle}
|
|
380
|
-
value={fps}
|
|
381
|
-
onChange={(e) => setFps(Number(e.target.value))}
|
|
382
|
-
>
|
|
383
|
-
<option value={10}>10 fps — recommended for GIF</option>
|
|
384
|
-
<option value={15}>15 fps — fast export</option>
|
|
385
|
-
<option value={24}>24 fps — cinematic</option>
|
|
386
|
-
<option value={30}>30 fps — smooth</option>
|
|
387
|
-
</select>
|
|
388
|
-
</div>
|
|
389
|
-
|
|
390
|
-
<div>
|
|
391
|
-
<label style={themedLabelStyle}>Orientation</label>
|
|
392
|
-
<select
|
|
393
|
-
aria-label="Orientation"
|
|
394
|
-
style={themedSelectStyle}
|
|
395
|
-
value={orientation}
|
|
396
|
-
onChange={(e) => setOrientation(e.target.value as VideoOrientation)}
|
|
397
|
-
>
|
|
398
|
-
<option value="landscape">
|
|
399
|
-
Landscape ({outputFormat === 'gif' ? '960 × 540' : '1920 × 1080'})
|
|
400
|
-
</option>
|
|
401
|
-
<option value="portrait">
|
|
402
|
-
Portrait ({outputFormat === 'gif' ? '540 × 960' : '1080 × 1920'})
|
|
403
|
-
</option>
|
|
404
|
-
</select>
|
|
405
|
-
</div>
|
|
406
|
-
|
|
407
|
-
<div>
|
|
408
|
-
<label style={themedLabelStyle}>Captions</label>
|
|
409
|
-
<select
|
|
410
|
-
aria-label="Captions"
|
|
411
|
-
style={themedSelectStyle}
|
|
412
|
-
value={captionMode}
|
|
413
|
-
onChange={(e) => setCaptionMode(e.target.value as CaptionMode)}
|
|
414
|
-
>
|
|
415
|
-
<option value="off">None</option>
|
|
416
|
-
<option value="standard">Standard (top bar)</option>
|
|
417
|
-
<option value="social">Social media (large words)</option>
|
|
418
|
-
</select>
|
|
419
|
-
</div>
|
|
420
|
-
|
|
421
|
-
<div>
|
|
422
|
-
<label style={themedLabelStyle}>Animations & transitions</label>
|
|
423
|
-
<select
|
|
424
|
-
aria-label="Animations and transitions"
|
|
425
|
-
style={themedSelectStyle}
|
|
426
|
-
value={animationsEnabled ? 'enabled' : 'disabled'}
|
|
427
|
-
onChange={(e) => setAnimationsEnabled(e.target.value === 'enabled')}
|
|
428
|
-
>
|
|
429
|
-
<option value="enabled">Enabled</option>
|
|
430
|
-
<option value="disabled">Disabled — smaller files</option>
|
|
431
|
-
</select>
|
|
432
|
-
{outputFormat === 'gif' && animationsEnabled && (
|
|
433
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '-6px 0 12px' }}>
|
|
434
|
-
Disabling motion is recommended for much smaller GIFs.
|
|
435
|
-
</p>
|
|
436
|
-
)}
|
|
437
|
-
</div>
|
|
438
|
-
|
|
439
|
-
<div style={footerStyle}>
|
|
440
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
441
|
-
Cancel
|
|
442
|
-
</button>
|
|
443
|
-
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
444
|
-
{outputFormat === 'gif' ? 'Export GIF' : 'Export Video'}
|
|
445
|
-
</button>
|
|
446
|
-
</div>
|
|
447
|
-
</>
|
|
448
|
-
)}
|
|
449
|
-
|
|
450
|
-
{/* ── Exporting State ── */}
|
|
451
|
-
{isExporting && (
|
|
452
|
-
<>
|
|
453
|
-
{backend && (
|
|
454
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 8px 0' }}>
|
|
455
|
-
Encoder: {encoderLabel(completedOutputFormat, backend)}
|
|
456
|
-
</p>
|
|
457
|
-
)}
|
|
458
|
-
|
|
459
|
-
<div style={{ ...progressBarOuterStyle, background: palette.secondary }}>
|
|
460
|
-
<div
|
|
461
|
-
style={{
|
|
462
|
-
width: `${progress}%`,
|
|
463
|
-
height: '100%',
|
|
464
|
-
background: palette.primary,
|
|
465
|
-
transition: 'width 0.3s ease',
|
|
466
|
-
}}
|
|
467
|
-
/>
|
|
468
|
-
</div>
|
|
469
|
-
|
|
470
|
-
<p style={{ fontSize: 13, margin: '0 0 4px 0' }}>{progress}% complete</p>
|
|
471
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: 0 }}>
|
|
472
|
-
{formatDuration(elapsed)} elapsed
|
|
473
|
-
{estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`}
|
|
474
|
-
</p>
|
|
475
|
-
|
|
476
|
-
<div style={footerStyle}>
|
|
477
|
-
<button style={themedSecondaryButtonStyle} onClick={cancelExport}>
|
|
478
|
-
Cancel
|
|
479
|
-
</button>
|
|
480
|
-
</div>
|
|
481
|
-
</>
|
|
482
|
-
)}
|
|
483
|
-
|
|
484
|
-
{/* ── Complete State ── */}
|
|
485
|
-
{state === 'complete' && (
|
|
486
|
-
<>
|
|
487
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: palette.success }}>
|
|
488
|
-
Export complete!
|
|
489
|
-
</p>
|
|
490
|
-
<p style={{ fontSize: 13, color: palette.label, margin: '0 0 4px 0' }}>
|
|
491
|
-
File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
|
|
492
|
-
</p>
|
|
493
|
-
{completedOutputFormat === 'gif' ? (
|
|
494
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 4px 0' }}>
|
|
495
|
-
Animated GIF does not include audio.
|
|
496
|
-
</p>
|
|
497
|
-
) : audioIncluded ? (
|
|
498
|
-
<p style={{ fontSize: 12, color: palette.success, margin: '0 0 4px 0' }}>
|
|
499
|
-
Audio included ✓
|
|
500
|
-
</p>
|
|
501
|
-
) : (
|
|
502
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 4px 0' }}>
|
|
503
|
-
Video only{audioSkippedReason ? ` — ${audioSkippedReason}` : ''}
|
|
504
|
-
</p>
|
|
505
|
-
)}
|
|
506
|
-
{backend && (
|
|
507
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 12px 0' }}>
|
|
508
|
-
Encoded with {encoderLabel(completedOutputFormat, backend)}
|
|
509
|
-
</p>
|
|
510
|
-
)}
|
|
511
|
-
|
|
512
|
-
<div style={footerStyle}>
|
|
513
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
514
|
-
Close
|
|
515
|
-
</button>
|
|
516
|
-
<button style={themedPrimaryButtonStyle} onClick={handleDownload}>
|
|
517
|
-
Download {completedOutputFormat.toUpperCase()}
|
|
518
|
-
</button>
|
|
519
|
-
</div>
|
|
520
|
-
</>
|
|
521
|
-
)}
|
|
522
|
-
|
|
523
|
-
{/* ── Error State ── */}
|
|
524
|
-
{state === 'error' && (
|
|
525
|
-
<>
|
|
526
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: palette.danger }}>
|
|
527
|
-
Export failed
|
|
528
|
-
</p>
|
|
529
|
-
<p
|
|
530
|
-
style={{
|
|
531
|
-
fontSize: 13,
|
|
532
|
-
color: palette.label,
|
|
533
|
-
margin: '0 0 12px 0',
|
|
534
|
-
wordBreak: 'break-word',
|
|
535
|
-
}}
|
|
536
|
-
>
|
|
537
|
-
{error}
|
|
538
|
-
</p>
|
|
539
|
-
|
|
540
|
-
<div style={footerStyle}>
|
|
541
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
542
|
-
Close
|
|
543
|
-
</button>
|
|
544
|
-
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
545
|
-
Retry {outputFormat === 'gif' ? 'GIF' : 'video'}
|
|
546
|
-
</button>
|
|
547
|
-
</div>
|
|
548
|
-
</>
|
|
549
|
-
)}
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
);
|
|
553
|
-
}
|
|
@@ -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
|
-
});
|