@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
package/src/VideoExportModal.tsx
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* States:
|
|
5
5
|
* configure → exporting (capturing + encoding) → complete | error
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Hosts may supply a resolved color scheme so this portaled dialog matches
|
|
8
|
+
* the surface that opened it.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { useState, useCallback } from 'react';
|
|
@@ -12,7 +13,11 @@ import type { Doc } from '@bendyline/squisq/schemas';
|
|
|
12
13
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
13
14
|
import type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';
|
|
14
15
|
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
useVideoExport,
|
|
18
|
+
type VideoExportConfig,
|
|
19
|
+
type VideoOutputFormat,
|
|
20
|
+
} from './hooks/useVideoExport.js';
|
|
16
21
|
|
|
17
22
|
// ── Types ──────────────────────────────────────────────────────────
|
|
18
23
|
|
|
@@ -32,13 +37,15 @@ export interface VideoExportModalProps {
|
|
|
32
37
|
/** Pre-collected audio map */
|
|
33
38
|
audio?: Map<string, ArrayBuffer>;
|
|
34
39
|
/**
|
|
35
|
-
* Seeds the modal's initial quality
|
|
36
|
-
*
|
|
37
|
-
* host can share one config shape with `useVideoExport`.
|
|
38
|
-
* `images`/`audio`/`mediaProvider`/`playerScript` props still
|
|
39
|
-
* precedence over any matching key here.
|
|
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.
|
|
40
45
|
*/
|
|
41
46
|
defaultConfig?: Partial<VideoExportConfig>;
|
|
47
|
+
/** Visual color scheme for the portaled dialog. Defaults to light. */
|
|
48
|
+
colorScheme?: 'light' | 'dark';
|
|
42
49
|
/** Called when the modal should close */
|
|
43
50
|
onClose: () => void;
|
|
44
51
|
}
|
|
@@ -52,12 +59,72 @@ function formatDuration(seconds: number): string {
|
|
|
52
59
|
return `${m}m ${s}s`;
|
|
53
60
|
}
|
|
54
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
|
+
|
|
55
74
|
// ── Styles ─────────────────────────────────────────────────────────
|
|
56
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
|
+
|
|
57
125
|
const overlayStyle: React.CSSProperties = {
|
|
58
126
|
position: 'fixed',
|
|
59
127
|
inset: 0,
|
|
60
|
-
background: 'rgba(0, 0, 0, 0.5)',
|
|
61
128
|
display: 'flex',
|
|
62
129
|
alignItems: 'center',
|
|
63
130
|
justifyContent: 'center',
|
|
@@ -65,22 +132,18 @@ const overlayStyle: React.CSSProperties = {
|
|
|
65
132
|
};
|
|
66
133
|
|
|
67
134
|
const modalStyle: React.CSSProperties = {
|
|
68
|
-
background: '#FFFDF7',
|
|
69
|
-
border: '1px solid #c9b98a',
|
|
70
135
|
borderRadius: 0,
|
|
71
136
|
padding: '24px 28px',
|
|
72
137
|
minWidth: 380,
|
|
73
138
|
maxWidth: 480,
|
|
74
139
|
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
75
140
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
76
|
-
color: '#4a3c1f',
|
|
77
141
|
};
|
|
78
142
|
|
|
79
143
|
const titleStyle: React.CSSProperties = {
|
|
80
144
|
margin: '0 0 16px 0',
|
|
81
145
|
fontSize: 18,
|
|
82
146
|
fontWeight: 600,
|
|
83
|
-
color: '#2d2310',
|
|
84
147
|
};
|
|
85
148
|
|
|
86
149
|
const labelStyle: React.CSSProperties = {
|
|
@@ -88,7 +151,6 @@ const labelStyle: React.CSSProperties = {
|
|
|
88
151
|
fontSize: 13,
|
|
89
152
|
fontWeight: 500,
|
|
90
153
|
marginBottom: 4,
|
|
91
|
-
color: '#5a4a2a',
|
|
92
154
|
};
|
|
93
155
|
|
|
94
156
|
const selectStyle: React.CSSProperties = {
|
|
@@ -96,10 +158,7 @@ const selectStyle: React.CSSProperties = {
|
|
|
96
158
|
padding: '6px 8px',
|
|
97
159
|
fontSize: 13,
|
|
98
160
|
fontFamily: 'inherit',
|
|
99
|
-
border: '1px solid #c9b98a',
|
|
100
161
|
borderRadius: 0,
|
|
101
|
-
background: '#fff',
|
|
102
|
-
color: '#4a3c1f',
|
|
103
162
|
marginBottom: 12,
|
|
104
163
|
};
|
|
105
164
|
|
|
@@ -109,9 +168,7 @@ const btnPrimary: React.CSSProperties = {
|
|
|
109
168
|
fontFamily: 'inherit',
|
|
110
169
|
fontWeight: 500,
|
|
111
170
|
cursor: 'pointer',
|
|
112
|
-
background: '#8B6914',
|
|
113
171
|
color: '#fff',
|
|
114
|
-
border: '1px solid #7a5c10',
|
|
115
172
|
borderRadius: 0,
|
|
116
173
|
};
|
|
117
174
|
|
|
@@ -121,16 +178,12 @@ const btnSecondary: React.CSSProperties = {
|
|
|
121
178
|
fontFamily: 'inherit',
|
|
122
179
|
fontWeight: 500,
|
|
123
180
|
cursor: 'pointer',
|
|
124
|
-
background: '#E8DFC6',
|
|
125
|
-
color: '#4a3c1f',
|
|
126
|
-
border: '1px solid #c9b98a',
|
|
127
181
|
borderRadius: 0,
|
|
128
182
|
};
|
|
129
183
|
|
|
130
184
|
const progressBarOuterStyle: React.CSSProperties = {
|
|
131
185
|
width: '100%',
|
|
132
186
|
height: 8,
|
|
133
|
-
background: '#E8DFC6',
|
|
134
187
|
borderRadius: 0,
|
|
135
188
|
overflow: 'hidden',
|
|
136
189
|
marginBottom: 8,
|
|
@@ -152,20 +205,55 @@ export function VideoExportModal({
|
|
|
152
205
|
images,
|
|
153
206
|
audio,
|
|
154
207
|
defaultConfig,
|
|
208
|
+
colorScheme = 'light',
|
|
155
209
|
onClose,
|
|
156
210
|
}: VideoExportModalProps) {
|
|
211
|
+
const initialOutputFormat = defaultConfig?.outputFormat ?? 'mp4';
|
|
212
|
+
const [outputFormat, setOutputFormat] = useState<VideoOutputFormat>(initialOutputFormat);
|
|
157
213
|
const [quality, setQuality] = useState<VideoQuality>(defaultConfig?.quality ?? 'normal');
|
|
158
|
-
const [fps, setFps] = useState(defaultConfig?.fps ?? 24);
|
|
214
|
+
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === 'gif' ? 10 : 24));
|
|
159
215
|
const [orientation, setOrientation] = useState<VideoOrientation>(
|
|
160
216
|
defaultConfig?.orientation ?? 'landscape',
|
|
161
217
|
);
|
|
162
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
|
+
};
|
|
163
250
|
|
|
164
251
|
const exportHook = useVideoExport();
|
|
165
252
|
const {
|
|
166
253
|
state,
|
|
167
254
|
progress,
|
|
168
255
|
backend,
|
|
256
|
+
outputFormat: completedOutputFormat,
|
|
169
257
|
downloadUrl,
|
|
170
258
|
fileSize,
|
|
171
259
|
audioIncluded,
|
|
@@ -178,10 +266,23 @@ export function VideoExportModal({
|
|
|
178
266
|
reset: resetExport,
|
|
179
267
|
} = exportHook;
|
|
180
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
|
+
|
|
181
280
|
const handleExport = useCallback(async () => {
|
|
182
281
|
const config: VideoExportConfig = {
|
|
183
282
|
// defaultConfig is the base; explicit props/selections win over it.
|
|
184
283
|
...defaultConfig,
|
|
284
|
+
outputFormat,
|
|
285
|
+
animationsEnabled,
|
|
185
286
|
quality,
|
|
186
287
|
fps,
|
|
187
288
|
orientation,
|
|
@@ -195,6 +296,8 @@ export function VideoExportModal({
|
|
|
195
296
|
await startExport(doc, config);
|
|
196
297
|
}, [
|
|
197
298
|
doc,
|
|
299
|
+
outputFormat,
|
|
300
|
+
animationsEnabled,
|
|
198
301
|
quality,
|
|
199
302
|
fps,
|
|
200
303
|
orientation,
|
|
@@ -212,11 +315,11 @@ export function VideoExportModal({
|
|
|
212
315
|
const a = document.createElement('a');
|
|
213
316
|
a.href = downloadUrl;
|
|
214
317
|
const ts = new Date().toISOString().slice(0, 10);
|
|
215
|
-
a.download = `document-${ts}
|
|
318
|
+
a.download = `document-${ts}.${completedOutputFormat}`;
|
|
216
319
|
document.body.appendChild(a);
|
|
217
320
|
a.click();
|
|
218
321
|
document.body.removeChild(a);
|
|
219
|
-
}, [downloadUrl]);
|
|
322
|
+
}, [downloadUrl, completedOutputFormat]);
|
|
220
323
|
|
|
221
324
|
const handleClose = useCallback(() => {
|
|
222
325
|
if (state === 'capturing' || state === 'encoding' || state === 'preparing') {
|
|
@@ -229,17 +332,37 @@ export function VideoExportModal({
|
|
|
229
332
|
const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';
|
|
230
333
|
|
|
231
334
|
return (
|
|
232
|
-
<div
|
|
233
|
-
|
|
234
|
-
|
|
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>
|
|
235
344
|
|
|
236
345
|
{/* ── Configure State ── */}
|
|
237
346
|
{state === 'idle' && (
|
|
238
347
|
<>
|
|
239
348
|
<div>
|
|
240
|
-
<label style={
|
|
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>
|
|
241
363
|
<select
|
|
242
|
-
|
|
364
|
+
aria-label="Quality"
|
|
365
|
+
style={themedSelectStyle}
|
|
243
366
|
value={quality}
|
|
244
367
|
onChange={(e) => setQuality(e.target.value as VideoQuality)}
|
|
245
368
|
>
|
|
@@ -250,12 +373,14 @@ export function VideoExportModal({
|
|
|
250
373
|
</div>
|
|
251
374
|
|
|
252
375
|
<div>
|
|
253
|
-
<label style={
|
|
376
|
+
<label style={themedLabelStyle}>Frame Rate</label>
|
|
254
377
|
<select
|
|
255
|
-
|
|
378
|
+
aria-label="Frame Rate"
|
|
379
|
+
style={themedSelectStyle}
|
|
256
380
|
value={fps}
|
|
257
381
|
onChange={(e) => setFps(Number(e.target.value))}
|
|
258
382
|
>
|
|
383
|
+
<option value={10}>10 fps — recommended for GIF</option>
|
|
259
384
|
<option value={15}>15 fps — fast export</option>
|
|
260
385
|
<option value={24}>24 fps — cinematic</option>
|
|
261
386
|
<option value={30}>30 fps — smooth</option>
|
|
@@ -263,21 +388,27 @@ export function VideoExportModal({
|
|
|
263
388
|
</div>
|
|
264
389
|
|
|
265
390
|
<div>
|
|
266
|
-
<label style={
|
|
391
|
+
<label style={themedLabelStyle}>Orientation</label>
|
|
267
392
|
<select
|
|
268
|
-
|
|
393
|
+
aria-label="Orientation"
|
|
394
|
+
style={themedSelectStyle}
|
|
269
395
|
value={orientation}
|
|
270
396
|
onChange={(e) => setOrientation(e.target.value as VideoOrientation)}
|
|
271
397
|
>
|
|
272
|
-
<option value="landscape">
|
|
273
|
-
|
|
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>
|
|
274
404
|
</select>
|
|
275
405
|
</div>
|
|
276
406
|
|
|
277
407
|
<div>
|
|
278
|
-
<label style={
|
|
408
|
+
<label style={themedLabelStyle}>Captions</label>
|
|
279
409
|
<select
|
|
280
|
-
|
|
410
|
+
aria-label="Captions"
|
|
411
|
+
style={themedSelectStyle}
|
|
281
412
|
value={captionMode}
|
|
282
413
|
onChange={(e) => setCaptionMode(e.target.value as CaptionMode)}
|
|
283
414
|
>
|
|
@@ -287,12 +418,30 @@ export function VideoExportModal({
|
|
|
287
418
|
</select>
|
|
288
419
|
</div>
|
|
289
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
|
+
|
|
290
439
|
<div style={footerStyle}>
|
|
291
|
-
<button style={
|
|
440
|
+
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
292
441
|
Cancel
|
|
293
442
|
</button>
|
|
294
|
-
<button style={
|
|
295
|
-
Export Video
|
|
443
|
+
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
444
|
+
{outputFormat === 'gif' ? 'Export GIF' : 'Export Video'}
|
|
296
445
|
</button>
|
|
297
446
|
</div>
|
|
298
447
|
</>
|
|
@@ -302,30 +451,30 @@ export function VideoExportModal({
|
|
|
302
451
|
{isExporting && (
|
|
303
452
|
<>
|
|
304
453
|
{backend && (
|
|
305
|
-
<p style={{ fontSize: 12, color:
|
|
306
|
-
Encoder:
|
|
454
|
+
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 8px 0' }}>
|
|
455
|
+
Encoder: {encoderLabel(completedOutputFormat, backend)}
|
|
307
456
|
</p>
|
|
308
457
|
)}
|
|
309
458
|
|
|
310
|
-
<div style={progressBarOuterStyle}>
|
|
459
|
+
<div style={{ ...progressBarOuterStyle, background: palette.secondary }}>
|
|
311
460
|
<div
|
|
312
461
|
style={{
|
|
313
462
|
width: `${progress}%`,
|
|
314
463
|
height: '100%',
|
|
315
|
-
background:
|
|
464
|
+
background: palette.primary,
|
|
316
465
|
transition: 'width 0.3s ease',
|
|
317
466
|
}}
|
|
318
467
|
/>
|
|
319
468
|
</div>
|
|
320
469
|
|
|
321
470
|
<p style={{ fontSize: 13, margin: '0 0 4px 0' }}>{progress}% complete</p>
|
|
322
|
-
<p style={{ fontSize: 12, color:
|
|
471
|
+
<p style={{ fontSize: 12, color: palette.muted, margin: 0 }}>
|
|
323
472
|
{formatDuration(elapsed)} elapsed
|
|
324
473
|
{estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`}
|
|
325
474
|
</p>
|
|
326
475
|
|
|
327
476
|
<div style={footerStyle}>
|
|
328
|
-
<button style={
|
|
477
|
+
<button style={themedSecondaryButtonStyle} onClick={cancelExport}>
|
|
329
478
|
Cancel
|
|
330
479
|
</button>
|
|
331
480
|
</div>
|
|
@@ -335,31 +484,37 @@ export function VideoExportModal({
|
|
|
335
484
|
{/* ── Complete State ── */}
|
|
336
485
|
{state === 'complete' && (
|
|
337
486
|
<>
|
|
338
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color:
|
|
339
|
-
|
|
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' }}>
|
|
340
491
|
File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
|
|
341
492
|
</p>
|
|
342
|
-
{
|
|
343
|
-
<p style={{ fontSize: 12, color:
|
|
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' }}>
|
|
344
499
|
Audio included ✓
|
|
345
500
|
</p>
|
|
346
501
|
) : (
|
|
347
|
-
<p style={{ fontSize: 12, color:
|
|
502
|
+
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 4px 0' }}>
|
|
348
503
|
Video only{audioSkippedReason ? ` — ${audioSkippedReason}` : ''}
|
|
349
504
|
</p>
|
|
350
505
|
)}
|
|
351
506
|
{backend && (
|
|
352
|
-
<p style={{ fontSize: 12, color:
|
|
353
|
-
Encoded with
|
|
507
|
+
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 12px 0' }}>
|
|
508
|
+
Encoded with {encoderLabel(completedOutputFormat, backend)}
|
|
354
509
|
</p>
|
|
355
510
|
)}
|
|
356
511
|
|
|
357
512
|
<div style={footerStyle}>
|
|
358
|
-
<button style={
|
|
513
|
+
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
359
514
|
Close
|
|
360
515
|
</button>
|
|
361
|
-
<button style={
|
|
362
|
-
Download
|
|
516
|
+
<button style={themedPrimaryButtonStyle} onClick={handleDownload}>
|
|
517
|
+
Download {completedOutputFormat.toUpperCase()}
|
|
363
518
|
</button>
|
|
364
519
|
</div>
|
|
365
520
|
</>
|
|
@@ -368,11 +523,13 @@ export function VideoExportModal({
|
|
|
368
523
|
{/* ── Error State ── */}
|
|
369
524
|
{state === 'error' && (
|
|
370
525
|
<>
|
|
371
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color:
|
|
526
|
+
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: palette.danger }}>
|
|
527
|
+
Export failed
|
|
528
|
+
</p>
|
|
372
529
|
<p
|
|
373
530
|
style={{
|
|
374
531
|
fontSize: 13,
|
|
375
|
-
color:
|
|
532
|
+
color: palette.label,
|
|
376
533
|
margin: '0 0 12px 0',
|
|
377
534
|
wordBreak: 'break-word',
|
|
378
535
|
}}
|
|
@@ -381,11 +538,11 @@ export function VideoExportModal({
|
|
|
381
538
|
</p>
|
|
382
539
|
|
|
383
540
|
<div style={footerStyle}>
|
|
384
|
-
<button style={
|
|
541
|
+
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
385
542
|
Close
|
|
386
543
|
</button>
|
|
387
|
-
<button style={
|
|
388
|
-
Retry
|
|
544
|
+
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
545
|
+
Retry {outputFormat === 'gif' ? 'GIF' : 'video'}
|
|
389
546
|
</button>
|
|
390
547
|
</div>
|
|
391
548
|
</>
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
});
|