@bendyline/squisq-video-react 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,18 +10,28 @@ import { createPortal } from 'react-dom';
10
10
  import type { Doc } from '@bendyline/squisq/schemas';
11
11
  import type { MediaProvider } from '@bendyline/squisq/schemas';
12
12
  import { VideoExportModal } from './VideoExportModal.js';
13
+ import type { VideoExportConfig } from './hooks/useVideoExport.js';
13
14
 
14
15
  export interface VideoExportButtonProps {
15
16
  /** The document to export */
16
17
  doc: Doc;
17
- /** Player IIFE bundle source */
18
- playerScript: string;
18
+ /**
19
+ * Player IIFE bundle source. Unused by the browser export path (frames
20
+ * are captured from a live in-page DocPlayer); only forwarded for
21
+ * CLI/Playwright-style pipelines that render standalone HTML.
22
+ */
23
+ playerScript?: string;
19
24
  /** Optional media provider for resolving images/audio */
20
25
  mediaProvider?: MediaProvider;
21
26
  /** Pre-collected images map */
22
27
  images?: Map<string, ArrayBuffer>;
23
28
  /** Pre-collected audio map */
24
29
  audio?: Map<string, ArrayBuffer>;
30
+ /**
31
+ * Seeds the modal's initial export settings and is merged as a base into the
32
+ * config passed to the export hook. Forwarded to {@link VideoExportModal}.
33
+ */
34
+ defaultConfig?: Partial<VideoExportConfig>;
25
35
  /** Button label (default: "Export Video") */
26
36
  label?: string;
27
37
  /** Additional inline styles for the button */
@@ -36,6 +46,7 @@ export function VideoExportButton({
36
46
  mediaProvider,
37
47
  images,
38
48
  audio,
49
+ defaultConfig,
39
50
  label = 'Export Video',
40
51
  style,
41
52
  disabled,
@@ -59,6 +70,7 @@ export function VideoExportButton({
59
70
  mediaProvider={mediaProvider}
60
71
  images={images}
61
72
  audio={audio}
73
+ defaultConfig={defaultConfig}
62
74
  onClose={handleClose}
63
75
  />,
64
76
  document.body,
@@ -19,14 +19,26 @@ import { useVideoExport, type VideoExportConfig } from './hooks/useVideoExport.j
19
19
  export interface VideoExportModalProps {
20
20
  /** The document to export */
21
21
  doc: Doc;
22
- /** Player IIFE bundle source */
23
- playerScript: string;
22
+ /**
23
+ * Player IIFE bundle source. Unused by the browser export path (frames
24
+ * are captured from a live in-page DocPlayer); only forwarded for
25
+ * CLI/Playwright-style pipelines that render standalone HTML.
26
+ */
27
+ playerScript?: string;
24
28
  /** Optional media provider for resolving images/audio */
25
29
  mediaProvider?: MediaProvider;
26
30
  /** Pre-collected images map (alternative to mediaProvider) */
27
31
  images?: Map<string, ArrayBuffer>;
28
32
  /** Pre-collected audio map */
29
33
  audio?: Map<string, ArrayBuffer>;
34
+ /**
35
+ * Seeds the modal's initial quality/fps/orientation/captionMode selections
36
+ * and is merged (as a base) into the config passed to the export hook, so a
37
+ * host can share one config shape with `useVideoExport`. The individual
38
+ * `images`/`audio`/`mediaProvider`/`playerScript` props still take
39
+ * precedence over any matching key here.
40
+ */
41
+ defaultConfig?: Partial<VideoExportConfig>;
30
42
  /** Called when the modal should close */
31
43
  onClose: () => void;
32
44
  }
@@ -139,12 +151,15 @@ export function VideoExportModal({
139
151
  mediaProvider,
140
152
  images,
141
153
  audio,
154
+ defaultConfig,
142
155
  onClose,
143
156
  }: VideoExportModalProps) {
144
- const [quality, setQuality] = useState<VideoQuality>('normal');
145
- const [fps, setFps] = useState(24);
146
- const [orientation, setOrientation] = useState<VideoOrientation>('landscape');
147
- const [captionMode, setCaptionMode] = useState<CaptionMode>('off');
157
+ const [quality, setQuality] = useState<VideoQuality>(defaultConfig?.quality ?? 'normal');
158
+ const [fps, setFps] = useState(defaultConfig?.fps ?? 24);
159
+ const [orientation, setOrientation] = useState<VideoOrientation>(
160
+ defaultConfig?.orientation ?? 'landscape',
161
+ );
162
+ const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultConfig?.captionMode ?? 'off');
148
163
 
149
164
  const exportHook = useVideoExport();
150
165
  const {
@@ -153,6 +168,8 @@ export function VideoExportModal({
153
168
  backend,
154
169
  downloadUrl,
155
170
  fileSize,
171
+ audioIncluded,
172
+ audioSkippedReason,
156
173
  error,
157
174
  elapsed,
158
175
  estimatedRemaining,
@@ -163,6 +180,8 @@ export function VideoExportModal({
163
180
 
164
181
  const handleExport = useCallback(async () => {
165
182
  const config: VideoExportConfig = {
183
+ // defaultConfig is the base; explicit props/selections win over it.
184
+ ...defaultConfig,
166
185
  quality,
167
186
  fps,
168
187
  orientation,
@@ -170,7 +189,8 @@ export function VideoExportModal({
170
189
  images,
171
190
  audio,
172
191
  mediaProvider,
173
- playerScript,
192
+ // Only thread the bundle through when the host actually supplied one.
193
+ ...(playerScript !== undefined ? { playerScript } : {}),
174
194
  };
175
195
  await startExport(doc, config);
176
196
  }, [
@@ -183,6 +203,7 @@ export function VideoExportModal({
183
203
  audio,
184
204
  mediaProvider,
185
205
  playerScript,
206
+ defaultConfig,
186
207
  startExport,
187
208
  ]);
188
209
 
@@ -318,6 +339,15 @@ export function VideoExportModal({
318
339
  <p style={{ fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }}>
319
340
  File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
320
341
  </p>
342
+ {audioIncluded ? (
343
+ <p style={{ fontSize: 12, color: '#2d6a10', margin: '0 0 4px 0' }}>
344
+ Audio included ✓
345
+ </p>
346
+ ) : (
347
+ <p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 4px 0' }}>
348
+ Video only{audioSkippedReason ? ` — ${audioSkippedReason}` : ''}
349
+ </p>
350
+ )}
321
351
  {backend && (
322
352
  <p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 12px 0' }}>
323
353
  Encoded with WebCodecs (H.264)
@@ -0,0 +1,108 @@
1
+ /**
2
+ * audioTrack — tier selection + capability probe.
3
+ *
4
+ * The tier decision is the boundary `useVideoExport` delegates to, so testing
5
+ * `selectAudioTier` directly exercises the exact logic the hook runs (jsdom
6
+ * lacks real WebCodecs / OfflineAudioContext, so the end-to-end encode path
7
+ * can only be exercised in a browser). `supportsWebCodecsAac` is verified to
8
+ * degrade to `false` when `AudioEncoder` is undefined (jsdom's state).
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import {
13
+ selectAudioTier,
14
+ supportsWebCodecsAac,
15
+ REASON_NO_AAC_NO_SAB,
16
+ audioBufferToWav,
17
+ } from '../audioTrack.js';
18
+
19
+ describe('selectAudioTier', () => {
20
+ it('tier 1 when AAC is supported on the main-thread WebCodecs encoder', () => {
21
+ expect(
22
+ selectAudioTier({
23
+ hasClips: true,
24
+ aacSupported: true,
25
+ sharedArrayBufferAvailable: true,
26
+ canUseMainThreadWebCodecs: true,
27
+ }),
28
+ ).toEqual({ tier: 1, reason: null });
29
+ });
30
+
31
+ it('tier 2 when AAC is unsupported but SharedArrayBuffer (ffmpeg.wasm) is available', () => {
32
+ expect(
33
+ selectAudioTier({
34
+ hasClips: true,
35
+ aacSupported: false,
36
+ sharedArrayBufferAvailable: true,
37
+ canUseMainThreadWebCodecs: false,
38
+ }),
39
+ ).toEqual({ tier: 2, reason: null });
40
+ });
41
+
42
+ it('tier 2 when AAC is supported but the main-thread encoder is not in use', () => {
43
+ // AAC exists but the video is going through the worker/ffmpeg path — the
44
+ // inline muxer isn't available, so fall back to the ffmpeg mux pass.
45
+ expect(
46
+ selectAudioTier({
47
+ hasClips: true,
48
+ aacSupported: true,
49
+ sharedArrayBufferAvailable: true,
50
+ canUseMainThreadWebCodecs: false,
51
+ }),
52
+ ).toEqual({ tier: 2, reason: null });
53
+ });
54
+
55
+ it('tier 3 with a reason when neither AAC nor SharedArrayBuffer is available', () => {
56
+ expect(
57
+ selectAudioTier({
58
+ hasClips: true,
59
+ aacSupported: false,
60
+ sharedArrayBufferAvailable: false,
61
+ canUseMainThreadWebCodecs: false,
62
+ }),
63
+ ).toEqual({ tier: 3, reason: REASON_NO_AAC_NO_SAB });
64
+ });
65
+
66
+ it('tier 3 with a null reason when there is no audio to include', () => {
67
+ expect(
68
+ selectAudioTier({
69
+ hasClips: false,
70
+ aacSupported: true,
71
+ sharedArrayBufferAvailable: true,
72
+ canUseMainThreadWebCodecs: true,
73
+ }),
74
+ ).toEqual({ tier: 3, reason: null });
75
+ });
76
+ });
77
+
78
+ describe('supportsWebCodecsAac', () => {
79
+ it('returns false when AudioEncoder is undefined (jsdom/Node)', async () => {
80
+ expect(typeof AudioEncoder).toBe('undefined');
81
+ await expect(supportsWebCodecsAac()).resolves.toBe(false);
82
+ });
83
+ });
84
+
85
+ describe('audioBufferToWav', () => {
86
+ it('serializes a minimal AudioBuffer-shaped value to a RIFF/WAVE header', () => {
87
+ // Hand-rolled AudioBuffer stand-in (jsdom has no AudioBuffer). Only the
88
+ // fields audioBufferToWav reads are provided.
89
+ const frames = 4;
90
+ const left = new Float32Array([0, 0.5, -0.5, 1]);
91
+ const right = new Float32Array([0, -1, 0.25, -0.25]);
92
+ const fake = {
93
+ numberOfChannels: 2,
94
+ sampleRate: 48_000,
95
+ length: frames,
96
+ getChannelData: (ch: number) => (ch === 0 ? left : right),
97
+ } as unknown as AudioBuffer;
98
+
99
+ const wav = audioBufferToWav(fake);
100
+ const ascii = (i: number, n: number) =>
101
+ String.fromCharCode(...Array.from(wav.subarray(i, i + n)));
102
+ expect(ascii(0, 4)).toBe('RIFF');
103
+ expect(ascii(8, 4)).toBe('WAVE');
104
+ expect(ascii(36, 4)).toBe('data');
105
+ // 44-byte header + 2 channels * 2 bytes * 4 frames = 60 bytes.
106
+ expect(wav.byteLength).toBe(44 + frames * 2 * 2);
107
+ });
108
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * mp4Mux audio track — verifies the additive AAC track wiring.
3
+ *
4
+ * mp4-muxer runs in Node, so we can add a raw audio sample and assert the
5
+ * finalized MP4 actually carries an `mp4a` sample-description box (i.e. the
6
+ * audio track made it into the container). We also assert the video-only
7
+ * configuration produces no such box, guarding the "byte-identical when no
8
+ * audio" contract.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { createMp4Muxer, type Mp4MuxerHandle } from '../mp4Mux.js';
13
+
14
+ /** Add one stub AVC keyframe so the always-present video track can finalize. */
15
+ function addStubVideoSample(muxer: Mp4MuxerHandle): void {
16
+ const avcDescription = new Uint8Array([
17
+ 0x01, 0x64, 0x00, 0x28, 0xff, 0xe1, 0x00, 0x04, 0x67, 0x64, 0x00, 0x28, 0x01, 0x00, 0x04, 0x68,
18
+ 0xee, 0x3c, 0x80,
19
+ ]);
20
+ muxer.addVideoChunkRaw(new Uint8Array([0x00, 0x00, 0x00, 0x02, 0x09, 0x10]), 'key', 0, 33_333, {
21
+ decoderConfig: { codec: 'avc1.640028', description: avcDescription },
22
+ });
23
+ }
24
+
25
+ /** Find the ASCII 4CC `needle` anywhere in the MP4 bytes. */
26
+ function containsFourCC(buffer: ArrayBuffer, needle: string): boolean {
27
+ const bytes = new Uint8Array(buffer);
28
+ const pat = Array.from(needle, (c) => c.charCodeAt(0));
29
+ outer: for (let i = 0; i + pat.length <= bytes.length; i++) {
30
+ for (let j = 0; j < pat.length; j++) {
31
+ if (bytes[i + j] !== pat[j]) continue outer;
32
+ }
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ describe('createMp4Muxer audio track', () => {
39
+ it('writes an mp4a sample entry when an audio track is configured', () => {
40
+ const muxer = createMp4Muxer({
41
+ width: 320,
42
+ height: 240,
43
+ fps: 30,
44
+ audio: { numberOfChannels: 2, sampleRate: 48_000 },
45
+ });
46
+
47
+ expect(muxer.hasAudioTrack).toBe(true);
48
+
49
+ // The video track needs at least one sample to finalize. Feed a raw AVC
50
+ // sample with a stub decoderConfig (bytes don't need to be a valid stream
51
+ // for the container-level box check).
52
+ addStubVideoSample(muxer);
53
+
54
+ // Feed one raw AAC sample (contents don't matter for the container check).
55
+ const sample = new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]);
56
+ // timestamp/duration are in microseconds.
57
+ muxer.addAudioChunkRaw(sample, 'key', 0, 1_000_000);
58
+
59
+ const mp4 = muxer.finalize();
60
+ expect(mp4.byteLength).toBeGreaterThan(0);
61
+ // The AAC audio sample entry box is named 'mp4a'.
62
+ expect(containsFourCC(mp4, 'mp4a')).toBe(true);
63
+ });
64
+
65
+ it('produces no audio track (no mp4a box) when audio is omitted', () => {
66
+ const muxer = createMp4Muxer({ width: 320, height: 240, fps: 30 });
67
+ expect(muxer.hasAudioTrack).toBe(false);
68
+ addStubVideoSample(muxer);
69
+ const mp4 = muxer.finalize();
70
+ expect(containsFourCC(mp4, 'mp4a')).toBe(false);
71
+ });
72
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Prop-surface tests for the video export components.
3
+ *
4
+ * `playerScript` is unused on the browser export path (frames are captured
5
+ * from a live in-page DocPlayer), so both VideoExportButton and
6
+ * VideoExportModal must accept being rendered without it.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { render, renderHook } from '@testing-library/react';
11
+ import { VideoExportButton } from '../VideoExportButton';
12
+ import { VideoExportModal } from '../VideoExportModal';
13
+ import { useVideoExport } from '../hooks/useVideoExport';
14
+ import type { Doc } from '@bendyline/squisq/schemas';
15
+
16
+ function minimalDoc(): Doc {
17
+ return {
18
+ articleId: 'export-test',
19
+ duration: 5,
20
+ blocks: [{ id: 'b1', startTime: 0, duration: 5, audioSegment: 0, layers: [] }],
21
+ audio: { segments: [] },
22
+ };
23
+ }
24
+
25
+ describe('VideoExportButton', () => {
26
+ it('renders without a playerScript prop', () => {
27
+ const { getByRole } = render(<VideoExportButton doc={minimalDoc()} />);
28
+ expect(getByRole('button', { name: 'Export Video' })).toBeTruthy();
29
+ });
30
+
31
+ it('accepts a defaultConfig prop', () => {
32
+ const { getByRole } = render(
33
+ <VideoExportButton doc={minimalDoc()} defaultConfig={{ quality: 'high', fps: 30 }} />,
34
+ );
35
+ expect(getByRole('button', { name: 'Export Video' })).toBeTruthy();
36
+ });
37
+ });
38
+
39
+ describe('VideoExportModal', () => {
40
+ it('renders the configure state without a playerScript prop', () => {
41
+ const { container } = render(<VideoExportModal doc={minimalDoc()} onClose={() => {}} />);
42
+ expect(container.textContent).toContain('Export Video');
43
+ });
44
+
45
+ it('seeds the initial selections from defaultConfig', () => {
46
+ const { container } = render(
47
+ <VideoExportModal
48
+ doc={minimalDoc()}
49
+ defaultConfig={{
50
+ quality: 'high',
51
+ fps: 30,
52
+ orientation: 'portrait',
53
+ captionMode: 'standard',
54
+ }}
55
+ onClose={() => {}}
56
+ />,
57
+ );
58
+ const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
59
+ // Order in the configure form: Quality, Frame Rate, Orientation, Captions.
60
+ expect(selects[0].value).toBe('high');
61
+ expect(selects[1].value).toBe('30');
62
+ expect(selects[2].value).toBe('portrait');
63
+ expect(selects[3].value).toBe('standard');
64
+ });
65
+ });
66
+
67
+ describe('useVideoExport result shape', () => {
68
+ it('exposes the additive audio result fields, defaulted for idle', () => {
69
+ const { result } = renderHook(() => useVideoExport());
70
+ expect(result.current.audioIncluded).toBe(false);
71
+ expect(result.current.audioSkippedReason).toBeNull();
72
+ });
73
+ });