@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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @bendyline/squisq-video-react
2
2
 
3
- React components and hooks for exporting Squisq documents to MP4 video directly in the browser. Uses WebCodecs for hardware-accelerated H.264 encoding with html2canvas for frame capture.
3
+ React components and hooks for exporting Squisq documents to MP4 video directly in the browser. Uses WebCodecs for hardware-accelerated H.264 encoding (with an ffmpeg.wasm worker fallback) and html2canvas for frame capture. As of v1.5 the exported MP4 also carries an **audio** track (narration + timed media).
4
4
 
5
5
  Part of the [Squisq](https://github.com/bendyline/squisq) monorepo.
6
6
 
@@ -27,6 +27,11 @@ function App() {
27
27
  }
28
28
  ```
29
29
 
30
+ **v1.5:** `playerScript` is now **optional** — the browser export captures frames
31
+ from a live in-page `DocPlayer`, so the standalone bundle is only needed for
32
+ CLI/Playwright-style pipelines. A new `defaultConfig?: Partial<VideoExportConfig>`
33
+ prop seeds the modal's initial quality/fps/orientation/caption selections.
34
+
30
35
  ### Full Export Modal
31
36
 
32
37
  ```tsx
@@ -85,10 +90,13 @@ function CustomExport({ doc, images, audio }) {
85
90
  const {
86
91
  state, // 'idle' | 'preparing' | 'capturing' | 'encoding' | 'complete' | 'error'
87
92
  progress, // 0–100
93
+ backend, // 'webcodecs' | 'ffmpeg-wasm' | null
88
94
  elapsed,
89
95
  estimatedRemaining,
90
96
  downloadUrl,
91
97
  fileSize,
98
+ audioIncluded, // whether an audio track was muxed in
99
+ audioSkippedReason, // null when the doc had no audio; a string explains a shortfall
92
100
  error,
93
101
  startExport,
94
102
  cancel,
@@ -97,7 +105,7 @@ function CustomExport({ doc, images, audio }) {
97
105
 
98
106
  return (
99
107
  <div>
100
- <button onClick={() => startExport({ doc, images, audio, quality: 'normal', fps: 30 })}>
108
+ <button onClick={() => startExport(doc, { images, audio, quality: 'normal', fps: 30 })}>
101
109
  Export
102
110
  </button>
103
111
  {state === 'capturing' && <p>Progress: {progress}%</p>}
@@ -113,16 +121,34 @@ function CustomExport({ doc, images, audio }) {
113
121
 
114
122
  ## Browser Requirements
115
123
 
116
- WebCodecs H.264 encoding requires Chrome 94+ or Edge 94+. Use `supportsWebCodecs()` to check at runtime:
124
+ WebCodecs H.264 encoding requires Chrome 94+ or Edge 94+. When WebCodecs H.264
125
+ is unavailable, the export automatically falls back to an ffmpeg.wasm worker —
126
+ which requires `SharedArrayBuffer` (i.e. Cross-Origin-Isolation headers on the
127
+ host page). Use `supportsWebCodecs()` to probe at runtime:
117
128
 
118
129
  ```ts
119
- import { supportsWebCodecs } from '@bendyline/squisq-video-react';
130
+ import {
131
+ supportsWebCodecs,
132
+ supportsWebCodecsH264,
133
+ supportsWebCodecsAac,
134
+ } from '@bendyline/squisq-video-react';
120
135
 
121
136
  if (!supportsWebCodecs()) {
122
- // Fall back or show unsupported message
137
+ // ffmpeg.wasm fallback will be used (needs Cross-Origin-Isolation)
123
138
  }
124
139
  ```
125
140
 
141
+ **Audio tiers.** The audio track is muxed via WebCodecs AAC when available
142
+ (`supportsWebCodecsAac()`); otherwise it is skipped and the export reports
143
+ `audioIncluded: false` with an `audioSkippedReason`. Audio problems never fail
144
+ the export — the video always completes. `supportsWebCodecsH264(config)` probes a
145
+ specific encoder configuration; `EncoderConfig` is also exported.
146
+
147
+ ## Full API Reference
148
+
149
+ See [docs/API.md](https://github.com/bendyline/squisq/blob/main/docs/API.md)
150
+ for complete prop tables, `VideoExportConfig`, and the encoder utilities.
151
+
126
152
  ## Related Packages
127
153
 
128
154
  | Package | Description |
@@ -0,0 +1,47 @@
1
+ // src/mp4Mux.ts
2
+ import { Muxer, ArrayBufferTarget } from "mp4-muxer";
3
+ function createMp4Muxer(options) {
4
+ const target = new ArrayBufferTarget();
5
+ const muxer = new Muxer({
6
+ target,
7
+ video: {
8
+ codec: "avc",
9
+ width: options.width,
10
+ height: options.height
11
+ },
12
+ // Only declare the audio track when requested so the video-only
13
+ // configuration stays byte-identical to the historical output.
14
+ ...options.audio ? {
15
+ audio: {
16
+ codec: "aac",
17
+ numberOfChannels: options.audio.numberOfChannels,
18
+ sampleRate: options.audio.sampleRate
19
+ }
20
+ } : {},
21
+ fastStart: "in-memory"
22
+ });
23
+ return {
24
+ hasAudioTrack: options.audio !== void 0,
25
+ addVideoChunk(chunk, meta) {
26
+ muxer.addVideoChunk(chunk, meta);
27
+ },
28
+ addVideoChunkRaw(data, type, timestampMicros, durationMicros, meta) {
29
+ muxer.addVideoChunkRaw(data, type, timestampMicros, durationMicros, meta);
30
+ },
31
+ addAudioChunk(chunk, meta) {
32
+ muxer.addAudioChunk(chunk, meta);
33
+ },
34
+ addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta) {
35
+ muxer.addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta);
36
+ },
37
+ finalize() {
38
+ muxer.finalize();
39
+ return target.buffer;
40
+ }
41
+ };
42
+ }
43
+
44
+ export {
45
+ createMp4Muxer
46
+ };
47
+ //# sourceMappingURL=chunk-6DKLHXX3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/mp4Mux.ts"],"sourcesContent":["/**\n * mp4Mux — Thin wrapper around mp4-muxer for WebCodecs encoding.\n *\n * Creates a Muxer instance configured for H.264 video (and, optionally, an\n * AAC audio track), accumulates encoded chunks, and produces a final MP4\n * ArrayBuffer. When no `audio` option is supplied the muxer configuration is\n * byte-identical to the video-only path.\n */\n\nimport { Muxer, ArrayBufferTarget } from 'mp4-muxer';\n\nexport interface Mp4MuxerOptions {\n width: number;\n height: number;\n fps: number;\n /**\n * When present, declares an AAC audio track alongside the video track.\n * Feed encoded audio via {@link Mp4MuxerHandle.addAudioChunk} (from a\n * WebCodecs `AudioEncoder`) or {@link Mp4MuxerHandle.addAudioChunkRaw}.\n */\n audio?: {\n numberOfChannels: number;\n sampleRate: number;\n };\n}\n\nexport interface Mp4MuxerHandle {\n /** Add an encoded video chunk to the muxer. */\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;\n /**\n * Add a raw video sample (bytes + timing) without an `EncodedVideoChunk`.\n * Useful where no `VideoEncoder` instance is available (e.g. Node tests).\n */\n addVideoChunkRaw(\n data: Uint8Array,\n type: 'key' | 'delta',\n timestampMicros: number,\n durationMicros: number,\n meta?: EncodedVideoChunkMetadata,\n ): void;\n /** Add an encoded audio chunk (from a WebCodecs `AudioEncoder`). */\n addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;\n /**\n * Add a raw audio sample (bytes + timing) without an `EncodedAudioChunk`.\n * Useful where no `AudioEncoder` instance is available (e.g. Node tests).\n */\n addAudioChunkRaw(\n data: Uint8Array,\n type: 'key' | 'delta',\n timestampMicros: number,\n durationMicros: number,\n meta?: EncodedAudioChunkMetadata,\n ): void;\n /** Whether this muxer was created with an audio track. */\n readonly hasAudioTrack: boolean;\n /** Finalize and return the MP4 as an ArrayBuffer. */\n finalize(): ArrayBuffer;\n}\n\n/**\n * Create an MP4 muxer configured for H.264 video, plus an optional AAC track.\n */\nexport function createMp4Muxer(options: Mp4MuxerOptions): Mp4MuxerHandle {\n const target = new ArrayBufferTarget();\n\n const muxer = new Muxer({\n target,\n video: {\n codec: 'avc',\n width: options.width,\n height: options.height,\n },\n // Only declare the audio track when requested so the video-only\n // configuration stays byte-identical to the historical output.\n ...(options.audio\n ? {\n audio: {\n codec: 'aac' as const,\n numberOfChannels: options.audio.numberOfChannels,\n sampleRate: options.audio.sampleRate,\n },\n }\n : {}),\n fastStart: 'in-memory',\n });\n\n return {\n hasAudioTrack: options.audio !== undefined,\n\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {\n muxer.addVideoChunk(chunk, meta);\n },\n\n addVideoChunkRaw(\n data: Uint8Array,\n type: 'key' | 'delta',\n timestampMicros: number,\n durationMicros: number,\n meta?: EncodedVideoChunkMetadata,\n ) {\n muxer.addVideoChunkRaw(data, type, timestampMicros, durationMicros, meta);\n },\n\n addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) {\n muxer.addAudioChunk(chunk, meta);\n },\n\n addAudioChunkRaw(\n data: Uint8Array,\n type: 'key' | 'delta',\n timestampMicros: number,\n durationMicros: number,\n meta?: EncodedAudioChunkMetadata,\n ) {\n muxer.addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta);\n },\n\n finalize(): ArrayBuffer {\n muxer.finalize();\n return target.buffer;\n },\n };\n}\n"],"mappings":";AASA,SAAS,OAAO,yBAAyB;AAqDlC,SAAS,eAAe,SAA0C;AACvE,QAAM,SAAS,IAAI,kBAAkB;AAErC,QAAM,QAAQ,IAAI,MAAM;AAAA,IACtB;AAAA,IACA,OAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO,QAAQ;AAAA,MACf,QAAQ,QAAQ;AAAA,IAClB;AAAA;AAAA;AAAA,IAGA,GAAI,QAAQ,QACR;AAAA,MACE,OAAO;AAAA,QACL,OAAO;AAAA,QACP,kBAAkB,QAAQ,MAAM;AAAA,QAChC,YAAY,QAAQ,MAAM;AAAA,MAC5B;AAAA,IACF,IACA,CAAC;AAAA,IACL,WAAW;AAAA,EACb,CAAC;AAED,SAAO;AAAA,IACL,eAAe,QAAQ,UAAU;AAAA,IAEjC,cAAc,OAA0B,MAAkC;AACxE,YAAM,cAAc,OAAO,IAAI;AAAA,IACjC;AAAA,IAEA,iBACE,MACA,MACA,iBACA,gBACA,MACA;AACA,YAAM,iBAAiB,MAAM,MAAM,iBAAiB,gBAAgB,IAAI;AAAA,IAC1E;AAAA,IAEA,cAAc,OAA0B,MAAkC;AACxE,YAAM,cAAc,OAAO,IAAI;AAAA,IACjC;AAAA,IAEA,iBACE,MACA,MACA,iBACA,gBACA,MACA;AACA,YAAM,iBAAiB,MAAM,MAAM,iBAAiB,gBAAgB,IAAI;AAAA,IAC1E;AAAA,IAEA,WAAwB;AACtB,YAAM,SAAS;AACf,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,44 +1,8 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { Doc, MediaProvider } from '@bendyline/squisq/schemas';
2
+ import { MediaProvider, Doc } from '@bendyline/squisq/schemas';
3
3
  import { VideoQuality, VideoOrientation, RenderHtmlOptions } from '@bendyline/squisq-video';
4
4
  import { CaptionMode } from '@bendyline/squisq-react';
5
5
 
6
- interface VideoExportModalProps {
7
- /** The document to export */
8
- doc: Doc;
9
- /** Player IIFE bundle source */
10
- playerScript: string;
11
- /** Optional media provider for resolving images/audio */
12
- mediaProvider?: MediaProvider;
13
- /** Pre-collected images map (alternative to mediaProvider) */
14
- images?: Map<string, ArrayBuffer>;
15
- /** Pre-collected audio map */
16
- audio?: Map<string, ArrayBuffer>;
17
- /** Called when the modal should close */
18
- onClose: () => void;
19
- }
20
- declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
21
-
22
- interface VideoExportButtonProps {
23
- /** The document to export */
24
- doc: Doc;
25
- /** Player IIFE bundle source */
26
- playerScript: string;
27
- /** Optional media provider for resolving images/audio */
28
- mediaProvider?: MediaProvider;
29
- /** Pre-collected images map */
30
- images?: Map<string, ArrayBuffer>;
31
- /** Pre-collected audio map */
32
- audio?: Map<string, ArrayBuffer>;
33
- /** Button label (default: "Export Video") */
34
- label?: string;
35
- /** Additional inline styles for the button */
36
- style?: React.CSSProperties;
37
- /** Whether the button is disabled */
38
- disabled?: boolean;
39
- }
40
- declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
41
-
42
6
  /**
43
7
  * useVideoExport — Main orchestration hook for browser video export.
44
8
  *
@@ -96,6 +60,19 @@ interface VideoExportResult {
96
60
  downloadUrl: string | null;
97
61
  /** File size in bytes (populated when state === 'complete') */
98
62
  fileSize: number;
63
+ /**
64
+ * Whether an audio track was muxed into the exported MP4 (populated when
65
+ * state === 'complete'). False when the doc had no audio or when audio was
66
+ * skipped/degraded — see {@link audioSkippedReason}.
67
+ */
68
+ audioIncluded: boolean;
69
+ /**
70
+ * Why audio is absent, when `audioIncluded` is false. `null` means the doc
71
+ * simply had no audio (not a failure); a non-null string explains a genuine
72
+ * capability shortfall or runtime failure. Audio problems never fail the
73
+ * export — the video always completes.
74
+ */
75
+ audioSkippedReason: string | null;
99
76
  /** Error message (populated when state === 'error') */
100
77
  error: string | null;
101
78
  /** Seconds elapsed since export started */
@@ -111,6 +88,63 @@ interface VideoExportResult {
111
88
  }
112
89
  declare function useVideoExport(): VideoExportResult;
113
90
 
91
+ interface VideoExportModalProps {
92
+ /** The document to export */
93
+ doc: Doc;
94
+ /**
95
+ * Player IIFE bundle source. Unused by the browser export path (frames
96
+ * are captured from a live in-page DocPlayer); only forwarded for
97
+ * CLI/Playwright-style pipelines that render standalone HTML.
98
+ */
99
+ playerScript?: string;
100
+ /** Optional media provider for resolving images/audio */
101
+ mediaProvider?: MediaProvider;
102
+ /** Pre-collected images map (alternative to mediaProvider) */
103
+ images?: Map<string, ArrayBuffer>;
104
+ /** Pre-collected audio map */
105
+ audio?: Map<string, ArrayBuffer>;
106
+ /**
107
+ * Seeds the modal's initial quality/fps/orientation/captionMode selections
108
+ * and is merged (as a base) into the config passed to the export hook, so a
109
+ * host can share one config shape with `useVideoExport`. The individual
110
+ * `images`/`audio`/`mediaProvider`/`playerScript` props still take
111
+ * precedence over any matching key here.
112
+ */
113
+ defaultConfig?: Partial<VideoExportConfig>;
114
+ /** Called when the modal should close */
115
+ onClose: () => void;
116
+ }
117
+ declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, defaultConfig, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
118
+
119
+ interface VideoExportButtonProps {
120
+ /** The document to export */
121
+ doc: Doc;
122
+ /**
123
+ * Player IIFE bundle source. Unused by the browser export path (frames
124
+ * are captured from a live in-page DocPlayer); only forwarded for
125
+ * CLI/Playwright-style pipelines that render standalone HTML.
126
+ */
127
+ playerScript?: string;
128
+ /** Optional media provider for resolving images/audio */
129
+ mediaProvider?: MediaProvider;
130
+ /** Pre-collected images map */
131
+ images?: Map<string, ArrayBuffer>;
132
+ /** Pre-collected audio map */
133
+ audio?: Map<string, ArrayBuffer>;
134
+ /**
135
+ * Seeds the modal's initial export settings and is merged as a base into the
136
+ * config passed to the export hook. Forwarded to {@link VideoExportModal}.
137
+ */
138
+ defaultConfig?: Partial<VideoExportConfig>;
139
+ /** Button label (default: "Export Video") */
140
+ label?: string;
141
+ /** Additional inline styles for the button */
142
+ style?: React.CSSProperties;
143
+ /** Whether the button is disabled */
144
+ disabled?: boolean;
145
+ }
146
+ declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, defaultConfig, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
147
+
114
148
  /**
115
149
  * useFrameCapture — Hidden div + html2canvas frame capture.
116
150
  *
@@ -153,10 +187,25 @@ interface EncoderConfig {
153
187
  height: number;
154
188
  fps: number;
155
189
  quality: 'draft' | 'normal' | 'high';
190
+ /**
191
+ * When present, the underlying muxer is configured with an AAC audio track
192
+ * and {@link MainThreadEncoder.addAudioChunk} becomes usable. Absent → the
193
+ * encoder produces a video-only MP4 exactly as before.
194
+ */
195
+ audio?: {
196
+ numberOfChannels: number;
197
+ sampleRate: number;
198
+ };
156
199
  }
157
200
  interface MainThreadEncoder {
158
201
  /** Encode a single frame. The bitmap is closed after encoding. */
159
202
  encodeFrame(bitmap: ImageBitmap, frameIndex: number): void;
203
+ /**
204
+ * Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
205
+ * Only valid when the encoder was created with an `audio` config; otherwise a
206
+ * no-op. Must be called before {@link finalize}.
207
+ */
208
+ addAudioChunk?(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
160
209
  /** Flush pending frames and finalize the MP4. Returns the MP4 ArrayBuffer. */
161
210
  finalize(): Promise<ArrayBuffer>;
162
211
  /** Close the encoder without producing output (e.g., on cancel). */
@@ -166,6 +215,13 @@ interface MainThreadEncoder {
166
215
  * Check whether the browser supports WebCodecs video encoding.
167
216
  */
168
217
  declare function supportsWebCodecs(): boolean;
218
+ /**
219
+ * Probe whether the WebCodecs encoder actually supports the H.264 profile
220
+ * we use. The `VideoEncoder` global can exist while the specific codec is
221
+ * unavailable — this is the case on Linux Chromium, which ships without
222
+ * the proprietary H.264 encoder.
223
+ */
224
+ declare function supportsWebCodecsH264(config: EncoderConfig): Promise<boolean>;
169
225
  /**
170
226
  * Create a main-thread WebCodecs encoder.
171
227
  *
@@ -173,4 +229,30 @@ declare function supportsWebCodecs(): boolean;
173
229
  */
174
230
  declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
175
231
 
176
- export { type FrameCaptureHandle, type MainThreadEncoder, VideoExportButton, type VideoExportButtonProps, type VideoExportConfig, VideoExportModal, type VideoExportModalProps, type VideoExportResult, type VideoExportState, createEncoder, supportsWebCodecs, useFrameCapture, useVideoExport };
232
+ /**
233
+ * audioTrack — In-browser audio rendering + AAC encoding for MP4 export.
234
+ *
235
+ * Replaces the CLI's ffmpeg `adelay`/`atrim`/`amix` graph with browser-native
236
+ * primitives:
237
+ *
238
+ * - {@link supportsWebCodecsAac} probes whether the browser can encode AAC
239
+ * (WebCodecs `AudioEncoder`).
240
+ * - {@link renderAudioTimeline} mixes an {@link AudioTimelineClip} list into a
241
+ * single `AudioBuffer` using an `OfflineAudioContext` (decode → schedule →
242
+ * render).
243
+ * - {@link encodeAacTrack} feeds that buffer through a WebCodecs
244
+ * `AudioEncoder` and hands the chunks to an mp4-muxer audio track (tier 1).
245
+ * - {@link audioBufferToWav} + {@link muxAudioWithFfmpegWasm} cover the
246
+ * ffmpeg.wasm fallback (tier 2).
247
+ * - {@link selectAudioTier} is the pure capability→tier decision shared with
248
+ * `useVideoExport` (and unit-tested at the module boundary).
249
+ */
250
+
251
+ /**
252
+ * Whether the browser can encode AAC (`mp4a.40.2`) via WebCodecs.
253
+ * Returns false when `AudioEncoder` is undefined (e.g. Node/jsdom, Safari,
254
+ * older Chromium) or the config is unsupported.
255
+ */
256
+ declare function supportsWebCodecsAac(sampleRate?: number, channels?: number): Promise<boolean>;
257
+
258
+ export { type EncoderConfig, type FrameCaptureHandle, type MainThreadEncoder, VideoExportButton, type VideoExportButtonProps, type VideoExportConfig, VideoExportModal, type VideoExportModalProps, type VideoExportResult, type VideoExportState, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264, useFrameCapture, useVideoExport };