@bendyline/squisq-video-react 1.2.2 → 2.0.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.
@@ -4,7 +4,8 @@
4
4
  * States:
5
5
  * configure → exporting (capturing + encoding) → complete | error
6
6
  *
7
- * Inline styles match the site's cream/gold palette (from FileToolbar).
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 { useVideoExport, type VideoExportConfig } from './hooks/useVideoExport.js';
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/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
+ * 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}.mp4`;
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 style={overlayStyle} onClick={handleClose}>
233
- <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
234
- <h2 style={titleStyle}>Export Video</h2>
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={labelStyle}>Quality</label>
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
- style={selectStyle}
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={labelStyle}>Frame Rate</label>
376
+ <label style={themedLabelStyle}>Frame Rate</label>
254
377
  <select
255
- style={selectStyle}
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={labelStyle}>Orientation</label>
391
+ <label style={themedLabelStyle}>Orientation</label>
267
392
  <select
268
- style={selectStyle}
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">Landscape (1920 × 1080)</option>
273
- <option value="portrait">Portrait (1080 × 1920)</option>
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={labelStyle}>Captions</label>
408
+ <label style={themedLabelStyle}>Captions</label>
279
409
  <select
280
- style={selectStyle}
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 &amp; 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={btnSecondary} onClick={handleClose}>
440
+ <button style={themedSecondaryButtonStyle} onClick={handleClose}>
292
441
  Cancel
293
442
  </button>
294
- <button style={btnPrimary} onClick={handleExport}>
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: '#8a7a5a', margin: '0 0 8px 0' }}>
306
- Encoder: WebCodecs (H.264)
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: '#8B6914',
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: '#8a7a5a', margin: 0 }}>
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={btnSecondary} onClick={cancelExport}>
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: '#2d6a10' }}>Export complete!</p>
339
- <p style={{ fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }}>
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
- {audioIncluded ? (
343
- <p style={{ fontSize: 12, color: '#2d6a10', margin: '0 0 4px 0' }}>
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: '#8a7a5a', margin: '0 0 4px 0' }}>
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: '#8a7a5a', margin: '0 0 12px 0' }}>
353
- Encoded with WebCodecs (H.264)
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={btnSecondary} onClick={handleClose}>
513
+ <button style={themedSecondaryButtonStyle} onClick={handleClose}>
359
514
  Close
360
515
  </button>
361
- <button style={btnPrimary} onClick={handleDownload}>
362
- Download MP4
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: '#a03020' }}>Export failed</p>
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: '#5a4a2a',
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={btnSecondary} onClick={handleClose}>
541
+ <button style={themedSecondaryButtonStyle} onClick={handleClose}>
385
542
  Close
386
543
  </button>
387
- <button style={btnPrimary} onClick={handleExport}>
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
+ });