@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.
Files changed (44) hide show
  1. package/COPYING.GPL-2.0.txt +339 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +39 -0
  4. package/README.md +34 -7
  5. package/dist/chunk-KQG6DZ7G.js +549 -0
  6. package/dist/chunk-VI5AIVOQ.js +1951 -0
  7. package/dist/chunk-VILZP3GL.js +856 -0
  8. package/dist/chunk-YZN7D4IC.js +300 -0
  9. package/dist/components/index.d.ts +90 -0
  10. package/dist/components/index.js +11 -0
  11. package/dist/encoder/index.d.ts +95 -0
  12. package/dist/encoder/index.js +13 -0
  13. package/dist/hooks/index.d.ts +32 -0
  14. package/dist/hooks/index.js +10 -0
  15. package/dist/index.d.ts +8 -277
  16. package/dist/index.js +12 -1503
  17. package/dist/useVideoExport-NtqZVgaz.d.ts +115 -0
  18. package/dist/workers/encode.worker.js +37 -20
  19. package/dist/workers/ffmpeg.class-worker.js +0 -1
  20. package/package.json +26 -8
  21. package/dist/chunk-6DKLHXX3.js +0 -47
  22. package/dist/chunk-6DKLHXX3.js.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/workers/encode.worker.js.map +0 -1
  25. package/dist/workers/ffmpeg.class-worker.js.map +0 -1
  26. package/src/VideoExportButton.tsx +0 -87
  27. package/src/VideoExportModal.tsx +0 -553
  28. package/src/__tests__/audioTrack.test.ts +0 -108
  29. package/src/__tests__/gifTranscode.test.ts +0 -118
  30. package/src/__tests__/mp4MuxAudio.test.ts +0 -72
  31. package/src/__tests__/useVideoExportGif.test.ts +0 -155
  32. package/src/__tests__/videoExportProps.test.tsx +0 -148
  33. package/src/__tests__/workerEncoder.test.ts +0 -143
  34. package/src/audioTrack.ts +0 -332
  35. package/src/gifTranscode.ts +0 -75
  36. package/src/hooks/useFrameCapture.ts +0 -268
  37. package/src/hooks/useVideoExport.ts +0 -647
  38. package/src/index.ts +0 -40
  39. package/src/mainThreadEncoder.ts +0 -158
  40. package/src/mp4Mux.ts +0 -123
  41. package/src/workerEncoder.ts +0 -175
  42. package/src/workers/encode.worker.ts +0 -439
  43. package/src/workers/ffmpeg.class-worker.ts +0 -11
  44. package/src/workers/workerTypes.ts +0 -89
@@ -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 &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
-
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
- });