@bendyline/squisq-video-react 2.4.19 → 2.4.21

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
@@ -220,6 +220,36 @@ available, and otherwise skipped with `audioIncluded: false` plus an
220
220
  completes. GIF skips audio preparation entirely. `supportsWebCodecsH264(config)` probes a specific encoder
221
221
  configuration; `EncoderConfig` and `FfmpegWasmLoadConfig` are also exported.
222
222
 
223
+ ## Audio export
224
+
225
+ `@bendyline/squisq-video-react/encoder` also exports a streaming audio-file
226
+ encoder and a whole-document audio mix for hosts that export audio on its own
227
+ (or save generated narration):
228
+
229
+ ```ts
230
+ import {
231
+ createAudioFileEncoder,
232
+ renderDocumentAudio,
233
+ supportedAudioFileFormats,
234
+ } from '@bendyline/squisq-video-react/encoder';
235
+
236
+ const formats = await supportedAudioFileFormats(); // 'm4a' (AAC), 'opus-webm', 'wav' (always)
237
+ const mixed = await renderDocumentAudio(doc, { readMedia: (src) => container.readFile(src) });
238
+ if (mixed) {
239
+ const encoder = await createAudioFileEncoder({
240
+ format: formats[0],
241
+ sampleRate: mixed.sampleRate,
242
+ channels: 2,
243
+ });
244
+ await encoder.append([mixed.getChannelData(0), mixed.getChannelData(1)]);
245
+ const file = await encoder.finish(); // encoder.mimeType / encoder.extension
246
+ }
247
+ ```
248
+
249
+ M4A is an audio-only MP4; input at rates the codec rejects (24 kHz TTS for
250
+ AAC) is resampled in-stream. See the
251
+ [host guide](https://github.com/bendyline/squisq/blob/main/docs/narration-and-audio-export.md).
252
+
223
253
  ## Full API Reference
224
254
 
225
255
  See [docs/API.md](https://github.com/bendyline/squisq/blob/main/docs/API.md)
@@ -0,0 +1,51 @@
1
+ // src/audioFile/shared.ts
2
+ var LOSSY_FORMATS = {
3
+ m4a: {
4
+ format: "m4a",
5
+ codec: "aac",
6
+ label: "AAC (M4A)",
7
+ mimeType: "audio/mp4",
8
+ extension: "m4a",
9
+ bitrate: { 1: 96e3, 2: 128e3 }
10
+ },
11
+ "opus-webm": {
12
+ format: "opus-webm",
13
+ codec: "opus",
14
+ label: "Opus (WebM)",
15
+ mimeType: "audio/webm",
16
+ extension: "webm",
17
+ bitrate: { 1: 64e3, 2: 96e3 }
18
+ }
19
+ };
20
+ function blockFrames(block, channels) {
21
+ if (block.length !== channels) {
22
+ throw new RangeError(`Expected ${channels} channel(s) of PCM, got ${block.length}.`);
23
+ }
24
+ const frames = block[0].length;
25
+ for (const channel of block) {
26
+ if (!(channel instanceof Float32Array)) {
27
+ throw new TypeError("PCM channels must be Float32Arrays.");
28
+ }
29
+ if (channel.length !== frames) throw new RangeError("Every channel must have the same length.");
30
+ }
31
+ return frames;
32
+ }
33
+ function createSerialQueue() {
34
+ let tail = Promise.resolve();
35
+ return (task) => {
36
+ const run = tail.then(task);
37
+ tail = run.catch(() => void 0);
38
+ return run;
39
+ };
40
+ }
41
+ function assertOpen(state) {
42
+ if (state === "finished") throw new Error("The audio file encoder is already finished.");
43
+ if (state === "cancelled") throw new Error("The audio file encoder was cancelled.");
44
+ }
45
+
46
+ export {
47
+ LOSSY_FORMATS,
48
+ blockFrames,
49
+ createSerialQueue,
50
+ assertOpen
51
+ };
@@ -1,19 +1,21 @@
1
1
  import {
2
2
  useFrameCapture
3
- } from "./chunk-QJWEZRZS.js";
3
+ } from "./chunk-A52VG7RA.js";
4
4
  import {
5
5
  EXPORT_AUDIO_CHANNELS,
6
6
  EXPORT_AUDIO_SAMPLE_RATE,
7
7
  audioBufferToWav,
8
8
  createEncoder,
9
+ documentAudioTimeline,
9
10
  encodeAacTrack,
11
+ loadAudioTimelineSources,
12
+ mixAudioTimeline,
10
13
  muxAudioWithFfmpegWasm,
11
- renderAudioTimeline,
12
14
  selectAudioTier,
13
15
  supportsWebCodecs,
14
16
  supportsWebCodecsAac,
15
17
  supportsWebCodecsH264
16
- } from "./chunk-GBIYYDMY.js";
18
+ } from "./chunk-ISYJJJXN.js";
17
19
 
18
20
  // src/hooks/useVideoExport.ts
19
21
  import { useState, useRef, useCallback, useEffect } from "react";
@@ -26,12 +28,7 @@ import {
26
28
  MAX_COVER_SLIDE_DURATION_SECONDS,
27
29
  resolveCoverSlideSettings
28
30
  } from "@bendyline/squisq/doc";
29
- import {
30
- resolveDimensions,
31
- computeAudioTimeline,
32
- resolveFfmpegWasmLoad as resolveFfmpegWasmLoad2,
33
- QUALITY_PRESETS
34
- } from "@bendyline/squisq-video";
31
+ import { resolveDimensions, resolveFfmpegWasmLoad as resolveFfmpegWasmLoad2, QUALITY_PRESETS } from "@bendyline/squisq-video";
35
32
 
36
33
  // src/workerEncoder.ts
37
34
  import { validateVideoExportOptions } from "@bendyline/squisq-video";
@@ -420,24 +417,19 @@ function collectDocumentMediaReferences(doc) {
420
417
  visit(doc);
421
418
  return references;
422
419
  }
423
- async function resolveAudioBuffers(clips, sources) {
424
- const srcs = new Set(clips.map((c) => c.src));
425
- const out = /* @__PURE__ */ new Map();
426
- for (const src of srcs) {
427
- let data = sources.audio?.get(src) ?? sources.images?.get(src);
428
- if (!data && sources.mediaProvider) {
429
- try {
430
- const url = await sources.mediaProvider.resolveUrl(src);
431
- const resource = await fetchResourceBytes(url, {
432
- policy: sources.resourcePolicy
433
- });
434
- data = toArrayBuffer(resource.bytes);
435
- } catch {
436
- }
420
+ function exportMediaReader(sources) {
421
+ return async (src) => {
422
+ const data = sources.audio?.get(src) ?? sources.images?.get(src);
423
+ if (data) return data;
424
+ if (!sources.mediaProvider) return null;
425
+ try {
426
+ const url = await sources.mediaProvider.resolveUrl(src);
427
+ const resource = await fetchResourceBytes(url, { policy: sources.resourcePolicy });
428
+ return toArrayBuffer(resource.bytes);
429
+ } catch {
430
+ return null;
437
431
  }
438
- if (data) out.set(src, data);
439
- }
440
- return out;
432
+ };
441
433
  }
442
434
  function resolveVideoExportCover(doc, config = {}) {
443
435
  const legacyPreRollOverride = config.coverPreRoll;
@@ -689,9 +681,10 @@ function useVideoExport(options = {}) {
689
681
  ).catch(() => false);
690
682
  if (cancelled()) return;
691
683
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
692
- const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? config.processedAudio ? computeAudioTimeline(doc, coverPlan.audioOffset, {
693
- processedAudio: config.processedAudio
694
- }) : computeAudioTimeline(doc, coverPlan.audioOffset) : [];
684
+ const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? documentAudioTimeline(doc, {
685
+ offsetSec: coverPlan.audioOffset,
686
+ ...config.processedAudio ? { processedAudio: config.processedAudio } : {}
687
+ }) : [];
695
688
  const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
696
689
  if (cancelled()) return;
697
690
  const tierDecision = selectAudioTier({
@@ -709,17 +702,17 @@ function useVideoExport(options = {}) {
709
702
  if (tierDecision.tier === 1 || tierDecision.tier === 2) {
710
703
  setPhase("Preparing audio\u2026");
711
704
  try {
712
- const buffers = await resolveAudioBuffers(timeline, {
713
- audio: config.audio,
714
- images,
715
- mediaProvider: config.mediaProvider,
716
- resourcePolicy: config.resourcePolicy
717
- });
705
+ const { buffers, missing: missingSources } = await loadAudioTimelineSources(
706
+ timeline,
707
+ exportMediaReader({
708
+ audio: config.audio,
709
+ images,
710
+ mediaProvider: config.mediaProvider,
711
+ resourcePolicy: config.resourcePolicy
712
+ })
713
+ );
718
714
  try {
719
715
  if (cancelled()) return;
720
- const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
721
- (src) => !buffers.has(src)
722
- );
723
716
  if (missingSources.length > 0) {
724
717
  audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
725
718
  if (audioPolicy === "require") throw new Error(audioReasonLocal);
@@ -727,14 +720,10 @@ function useVideoExport(options = {}) {
727
720
  if (buffers.size === 0) {
728
721
  audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
729
722
  } else {
730
- const totalAudioDur = timeline.reduce(
731
- (max, c) => Math.max(max, c.startSec + c.durationSec),
732
- exportDuration
733
- );
734
- renderedAudio = await renderAudioTimeline(
723
+ renderedAudio = await mixAudioTimeline(
735
724
  timeline,
736
725
  buffers,
737
- totalAudioDur,
726
+ exportDuration,
738
727
  EXPORT_AUDIO_SAMPLE_RATE
739
728
  );
740
729
  if (cancelled()) return;
@@ -6,7 +6,7 @@ import {
6
6
  QTFF,
7
7
  VideoSampleSink,
8
8
  WEBM
9
- } from "./chunk-4GFZ3GUE.js";
9
+ } from "./chunk-C7NEHV5X.js";
10
10
 
11
11
  // src/hooks/useFrameCapture.ts
12
12
  import { createElement } from "react";
@@ -18,7 +18,7 @@ import {
18
18
  WavOutputFormat,
19
19
  WebMOutputFormat,
20
20
  canEncodeAudio
21
- } from "./chunk-4GFZ3GUE.js";
21
+ } from "./chunk-C7NEHV5X.js";
22
22
 
23
23
  // src/mediaEdit/mediaEditEngine.ts
24
24
  import {