@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/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import {
2
2
  createMp4Muxer
3
- } from "./chunk-LZYWP4HB.js";
3
+ } from "./chunk-6DKLHXX3.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
6
  import { useState as useState2, useCallback as useCallback3 } from "react";
7
7
 
8
8
  // src/hooks/useVideoExport.ts
9
9
  import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } from "react";
10
- import { resolveDimensions } from "@bendyline/squisq-video";
10
+ import { resolveDimensions, computeAudioTimeline, QUALITY_PRESETS } from "@bendyline/squisq-video";
11
11
 
12
12
  // src/mainThreadEncoder.ts
13
+ import { bitrateForQuality } from "@bendyline/squisq-video";
13
14
  function supportsWebCodecs() {
14
15
  return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
15
16
  }
@@ -28,18 +29,6 @@ async function supportsWebCodecsH264(config) {
28
29
  return false;
29
30
  }
30
31
  }
31
- function bitrateForQuality(quality, width, height) {
32
- const pixels = width * height;
33
- const baseBitrate = pixels * 4;
34
- switch (quality) {
35
- case "draft":
36
- return Math.round(baseBitrate * 0.5);
37
- case "high":
38
- return Math.round(baseBitrate * 2);
39
- default:
40
- return baseBitrate;
41
- }
42
- }
43
32
  function createEncoder(config) {
44
33
  if (!supportsWebCodecs()) {
45
34
  throw new Error(
@@ -54,7 +43,8 @@ function createEncoder(config) {
54
43
  const muxer = createMp4Muxer({
55
44
  width: config.width,
56
45
  height: config.height,
57
- fps: config.fps
46
+ fps: config.fps,
47
+ ...config.audio ? { audio: config.audio } : {}
58
48
  });
59
49
  let closed = false;
60
50
  const frameDuration = 1e6 / config.fps;
@@ -68,6 +58,9 @@ function createEncoder(config) {
68
58
  }
69
59
  });
70
60
  encoder.configure({
61
+ // Deliberate profile split from the fallback worker (avc1.42001f, Baseline):
62
+ // this primary WebCodecs path targets H.264 High@4.0 for better quality up
63
+ // to 1080p; the wasm-fallback worker uses Baseline for max decoder compat.
71
64
  codec: "avc1.640028",
72
65
  // H.264 High profile, level 4.0 (supports up to 1080p)
73
66
  width: config.width,
@@ -88,6 +81,10 @@ function createEncoder(config) {
88
81
  frame.close();
89
82
  bitmap.close();
90
83
  },
84
+ addAudioChunk(chunk, meta) {
85
+ if (closed || !muxer.hasAudioTrack) return;
86
+ muxer.addAudioChunk(chunk, meta);
87
+ },
91
88
  async finalize() {
92
89
  if (closed) throw new Error("Encoder already closed");
93
90
  await encoder.flush();
@@ -198,6 +195,178 @@ function createWorkerEncoder(config) {
198
195
  };
199
196
  }
200
197
 
198
+ // src/audioTrack.ts
199
+ var EXPORT_AUDIO_SAMPLE_RATE = 48e3;
200
+ var EXPORT_AUDIO_CHANNELS = 2;
201
+ var REASON_NO_AAC_NO_SAB = "This browser cannot encode AAC audio (WebCodecs AudioEncoder unavailable) and the ffmpeg.wasm fallback requires cross-origin isolation (SharedArrayBuffer).";
202
+ async function supportsWebCodecsAac(sampleRate = EXPORT_AUDIO_SAMPLE_RATE, channels = EXPORT_AUDIO_CHANNELS) {
203
+ if (typeof AudioEncoder === "undefined") return false;
204
+ try {
205
+ const support = await AudioEncoder.isConfigSupported({
206
+ codec: "mp4a.40.2",
207
+ sampleRate,
208
+ numberOfChannels: channels,
209
+ bitrate: 128e3
210
+ });
211
+ return support.supported === true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+ function selectAudioTier(inputs) {
217
+ if (!inputs.hasClips) {
218
+ return { tier: 3, reason: null };
219
+ }
220
+ if (inputs.aacSupported && inputs.canUseMainThreadWebCodecs) {
221
+ return { tier: 1, reason: null };
222
+ }
223
+ if (inputs.sharedArrayBufferAvailable) {
224
+ return { tier: 2, reason: null };
225
+ }
226
+ return { tier: 3, reason: REASON_NO_AAC_NO_SAB };
227
+ }
228
+ function resolveOfflineAudioContext() {
229
+ const g = globalThis;
230
+ const ctor = g.OfflineAudioContext ?? g.webkitOfflineAudioContext;
231
+ if (!ctor) {
232
+ throw new Error("OfflineAudioContext is not available in this environment.");
233
+ }
234
+ return ctor;
235
+ }
236
+ async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate = EXPORT_AUDIO_SAMPLE_RATE) {
237
+ const Ctor = resolveOfflineAudioContext();
238
+ const channels = EXPORT_AUDIO_CHANNELS;
239
+ const length = Math.max(1, Math.ceil(totalDurationSec * sampleRate));
240
+ const ctx = new Ctor(channels, length, sampleRate);
241
+ const decoded = /* @__PURE__ */ new Map();
242
+ for (const [src, data] of buffers) {
243
+ try {
244
+ decoded.set(src, await ctx.decodeAudioData(data.slice(0)));
245
+ } catch {
246
+ }
247
+ }
248
+ for (const clip of clips) {
249
+ const buffer = decoded.get(clip.src);
250
+ if (!buffer) continue;
251
+ const node = ctx.createBufferSource();
252
+ node.buffer = buffer;
253
+ node.connect(ctx.destination);
254
+ const when = Math.max(0, clip.startSec);
255
+ const offset = Math.max(0, clip.sourceInSec);
256
+ const duration = Math.max(0, clip.durationSec);
257
+ node.start(when, offset, duration);
258
+ }
259
+ return ctx.startRendering();
260
+ }
261
+ async function encodeAacTrack(audioBuffer, sink, bitrate) {
262
+ if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
263
+ throw new Error("WebCodecs AudioEncoder is not available.");
264
+ }
265
+ const sampleRate = audioBuffer.sampleRate;
266
+ const channels = audioBuffer.numberOfChannels;
267
+ let encodeError = null;
268
+ const encoder = new AudioEncoder({
269
+ output: (chunk, meta) => sink.addAudioChunk(chunk, meta ?? void 0),
270
+ error: (err) => {
271
+ encodeError = err instanceof Error ? err : new Error(String(err));
272
+ }
273
+ });
274
+ encoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: channels, bitrate });
275
+ const FRAME = 1024;
276
+ const total = audioBuffer.length;
277
+ const channelData = [];
278
+ for (let ch = 0; ch < channels; ch++) {
279
+ channelData.push(audioBuffer.getChannelData(ch));
280
+ }
281
+ for (let offset = 0; offset < total; offset += FRAME) {
282
+ if (encodeError) break;
283
+ const count = Math.min(FRAME, total - offset);
284
+ const planar = new Float32Array(count * channels);
285
+ for (let ch = 0; ch < channels; ch++) {
286
+ planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
287
+ }
288
+ const timestamp = Math.round(offset / sampleRate * 1e6);
289
+ const audioData = new AudioData({
290
+ format: "f32-planar",
291
+ sampleRate,
292
+ numberOfFrames: count,
293
+ numberOfChannels: channels,
294
+ timestamp,
295
+ data: planar
296
+ });
297
+ encoder.encode(audioData);
298
+ audioData.close();
299
+ }
300
+ await encoder.flush();
301
+ encoder.close();
302
+ if (encodeError) throw encodeError;
303
+ }
304
+ function audioBufferToWav(buffer) {
305
+ const channels = buffer.numberOfChannels;
306
+ const sampleRate = buffer.sampleRate;
307
+ const frames = buffer.length;
308
+ const bytesPerSample = 2;
309
+ const blockAlign = channels * bytesPerSample;
310
+ const dataSize = frames * blockAlign;
311
+ const out = new ArrayBuffer(44 + dataSize);
312
+ const view = new DataView(out);
313
+ const writeStr = (offset, str) => {
314
+ for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
315
+ };
316
+ writeStr(0, "RIFF");
317
+ view.setUint32(4, 36 + dataSize, true);
318
+ writeStr(8, "WAVE");
319
+ writeStr(12, "fmt ");
320
+ view.setUint32(16, 16, true);
321
+ view.setUint16(20, 1, true);
322
+ view.setUint16(22, channels, true);
323
+ view.setUint32(24, sampleRate, true);
324
+ view.setUint32(28, sampleRate * blockAlign, true);
325
+ view.setUint16(32, blockAlign, true);
326
+ view.setUint16(34, bytesPerSample * 8, true);
327
+ writeStr(36, "data");
328
+ view.setUint32(40, dataSize, true);
329
+ const channelData = [];
330
+ for (let ch = 0; ch < channels; ch++) channelData.push(buffer.getChannelData(ch));
331
+ let pos = 44;
332
+ for (let i = 0; i < frames; i++) {
333
+ for (let ch = 0; ch < channels; ch++) {
334
+ let sample = channelData[ch][i];
335
+ sample = Math.max(-1, Math.min(1, sample));
336
+ view.setInt16(pos, sample < 0 ? sample * 32768 : sample * 32767, true);
337
+ pos += 2;
338
+ }
339
+ }
340
+ return new Uint8Array(out);
341
+ }
342
+ async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate) {
343
+ const { FFmpeg } = await import("@ffmpeg/ffmpeg");
344
+ const ffmpeg = new FFmpeg();
345
+ await ffmpeg.load();
346
+ try {
347
+ await ffmpeg.writeFile("video.mp4", videoMp4);
348
+ await ffmpeg.writeFile("audio.wav", wav);
349
+ await ffmpeg.exec([
350
+ "-i",
351
+ "video.mp4",
352
+ "-i",
353
+ "audio.wav",
354
+ "-c:v",
355
+ "copy",
356
+ "-c:a",
357
+ "aac",
358
+ "-b:a",
359
+ String(audioBitrate),
360
+ "-shortest",
361
+ "out.mp4"
362
+ ]);
363
+ const data = await ffmpeg.readFile("out.mp4");
364
+ return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
365
+ } finally {
366
+ ffmpeg.terminate();
367
+ }
368
+ }
369
+
201
370
  // src/hooks/useFrameCapture.ts
202
371
  import { createElement } from "react";
203
372
  import { createRoot } from "react-dom/client";
@@ -289,7 +458,7 @@ function useFrameCapture() {
289
458
  const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
290
459
  const captionStyle = captionMode === "social" ? "social" : "standard";
291
460
  const playerElement = createElement(DocPlayer, {
292
- script: doc,
461
+ doc,
293
462
  basePath: ".",
294
463
  renderMode: true,
295
464
  showControls: false,
@@ -373,6 +542,23 @@ function useFrameCapture() {
373
542
  }
374
543
 
375
544
  // src/hooks/useVideoExport.ts
545
+ async function resolveAudioBuffers(clips, sources) {
546
+ const srcs = new Set(clips.map((c) => c.src));
547
+ const out = /* @__PURE__ */ new Map();
548
+ for (const src of srcs) {
549
+ let data = sources.audio?.get(src) ?? sources.images?.get(src);
550
+ if (!data && sources.mediaProvider) {
551
+ try {
552
+ const url = await sources.mediaProvider.resolveUrl(src);
553
+ const res = await fetch(url);
554
+ if (res.ok) data = await res.arrayBuffer();
555
+ } catch {
556
+ }
557
+ }
558
+ if (data) out.set(src, data);
559
+ }
560
+ return out;
561
+ }
376
562
  function useVideoExport() {
377
563
  const [state, setState] = useState("idle");
378
564
  const [progress, setProgress] = useState(0);
@@ -381,6 +567,8 @@ function useVideoExport() {
381
567
  const [backend, setBackend] = useState(null);
382
568
  const [downloadUrl, setDownloadUrl] = useState(null);
383
569
  const [fileSize, setFileSize] = useState(0);
570
+ const [audioIncluded, setAudioIncluded] = useState(false);
571
+ const [audioSkippedReason, setAudioSkippedReason] = useState(null);
384
572
  const [error, setError] = useState(null);
385
573
  const [elapsed, setElapsed] = useState(0);
386
574
  const [estimatedRemaining, setEstimatedRemaining] = useState(0);
@@ -419,6 +607,8 @@ function useVideoExport() {
419
607
  setBackend(null);
420
608
  setDownloadUrl(null);
421
609
  setFileSize(0);
610
+ setAudioIncluded(false);
611
+ setAudioSkippedReason(null);
422
612
  setError(null);
423
613
  setElapsed(0);
424
614
  setEstimatedRemaining(0);
@@ -446,6 +636,8 @@ function useVideoExport() {
446
636
  }
447
637
  setDownloadUrl(null);
448
638
  setFileSize(0);
639
+ setAudioIncluded(false);
640
+ setAudioSkippedReason(null);
449
641
  setError(null);
450
642
  const quality = config.quality ?? "normal";
451
643
  const fps = config.fps ?? 30;
@@ -495,9 +687,62 @@ function useVideoExport() {
495
687
  setPhase("Starting encoder\u2026");
496
688
  setProgress(5);
497
689
  const canUseWebCodecs = webCodecsAvailable && await supportsWebCodecsH264({ width, height, fps, quality });
690
+ const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
691
+ const timeline = computeAudioTimeline(doc, 0);
692
+ const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
693
+ const tierDecision = selectAudioTier({
694
+ hasClips: timeline.length > 0,
695
+ aacSupported,
696
+ sharedArrayBufferAvailable,
697
+ canUseMainThreadWebCodecs: canUseWebCodecs
698
+ });
699
+ let renderedAudio = null;
700
+ let audioIncludedLocal = false;
701
+ let audioReasonLocal = tierDecision.reason;
702
+ if (tierDecision.tier === 1 || tierDecision.tier === 2) {
703
+ setPhase("Preparing audio\u2026");
704
+ try {
705
+ const buffers = await resolveAudioBuffers(timeline, {
706
+ audio: config.audio,
707
+ images,
708
+ mediaProvider: config.mediaProvider
709
+ });
710
+ if (buffers.size === 0) {
711
+ audioReasonLocal = "Audio files for this document could not be loaded.";
712
+ } else {
713
+ const totalAudioDur = timeline.reduce(
714
+ (max, c) => Math.max(max, c.startSec + c.durationSec),
715
+ docDuration
716
+ );
717
+ renderedAudio = await renderAudioTimeline(
718
+ timeline,
719
+ buffers,
720
+ totalAudioDur,
721
+ EXPORT_AUDIO_SAMPLE_RATE
722
+ );
723
+ }
724
+ } catch (audioErr) {
725
+ renderedAudio = null;
726
+ audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
727
+ }
728
+ }
729
+ const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
730
+ const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
731
+ if (cancelledRef.current) return;
498
732
  let encoder;
499
733
  if (canUseWebCodecs) {
500
- encoder = createEncoder({ width, height, fps, quality });
734
+ encoder = createEncoder({
735
+ width,
736
+ height,
737
+ fps,
738
+ quality,
739
+ ...useInlineAudio && renderedAudio ? {
740
+ audio: {
741
+ numberOfChannels: renderedAudio.numberOfChannels,
742
+ sampleRate: renderedAudio.sampleRate
743
+ }
744
+ } : {}
745
+ });
501
746
  setBackend("webcodecs");
502
747
  } else if (sharedArrayBufferAvailable) {
503
748
  const workerEncoder = createWorkerEncoder({ width, height, fps, quality });
@@ -540,17 +785,48 @@ function useVideoExport() {
540
785
  encoder.encodeFrame(bitmap, i);
541
786
  }
542
787
  if (cancelledRef.current) return;
788
+ if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
789
+ setState("encoding");
790
+ setPhase("Encoding audio\u2026");
791
+ try {
792
+ await encodeAacTrack(
793
+ renderedAudio,
794
+ { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
795
+ audioBitrate
796
+ );
797
+ audioIncludedLocal = true;
798
+ } catch (audioErr) {
799
+ audioIncludedLocal = false;
800
+ audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
801
+ }
802
+ }
543
803
  setState("encoding");
544
804
  setPhase("Finalizing video\u2026");
545
805
  setProgress(95);
546
- const mp4Buffer = await encoder.finalize();
806
+ let outputBytes = await encoder.finalize();
547
807
  encoderRef.current = null;
548
808
  if (cancelledRef.current) return;
549
- const blob = new Blob([mp4Buffer], { type: "video/mp4" });
809
+ if (useFfmpegAudio && renderedAudio) {
810
+ setPhase("Muxing audio\u2026");
811
+ try {
812
+ const wav = audioBufferToWav(renderedAudio);
813
+ const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
814
+ outputBytes = await muxAudioWithFfmpegWasm(videoOnly, wav, audioBitrate);
815
+ audioIncludedLocal = true;
816
+ } catch (audioErr) {
817
+ audioIncludedLocal = false;
818
+ audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
819
+ }
820
+ }
821
+ if (cancelledRef.current) return;
822
+ const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
823
+ const blob = new Blob([finalBytes], { type: "video/mp4" });
550
824
  const url = URL.createObjectURL(blob);
551
825
  downloadUrlRef.current = url;
552
826
  setDownloadUrl(url);
553
- setFileSize(mp4Buffer.byteLength);
827
+ setFileSize(finalBytes.byteLength);
828
+ setAudioIncluded(audioIncludedLocal);
829
+ setAudioSkippedReason(audioIncludedLocal ? null : audioReasonLocal);
554
830
  setState("complete");
555
831
  setProgress(100);
556
832
  setPhase("Export complete");
@@ -581,6 +857,8 @@ function useVideoExport() {
581
857
  backend,
582
858
  downloadUrl,
583
859
  fileSize,
860
+ audioIncluded,
861
+ audioSkippedReason,
584
862
  error,
585
863
  elapsed,
586
864
  estimatedRemaining,
@@ -684,12 +962,15 @@ function VideoExportModal({
684
962
  mediaProvider,
685
963
  images,
686
964
  audio,
965
+ defaultConfig,
687
966
  onClose
688
967
  }) {
689
- const [quality, setQuality] = useState2("normal");
690
- const [fps, setFps] = useState2(24);
691
- const [orientation, setOrientation] = useState2("landscape");
692
- const [captionMode, setCaptionMode] = useState2("off");
968
+ const [quality, setQuality] = useState2(defaultConfig?.quality ?? "normal");
969
+ const [fps, setFps] = useState2(defaultConfig?.fps ?? 24);
970
+ const [orientation, setOrientation] = useState2(
971
+ defaultConfig?.orientation ?? "landscape"
972
+ );
973
+ const [captionMode, setCaptionMode] = useState2(defaultConfig?.captionMode ?? "off");
693
974
  const exportHook = useVideoExport();
694
975
  const {
695
976
  state,
@@ -697,6 +978,8 @@ function VideoExportModal({
697
978
  backend,
698
979
  downloadUrl,
699
980
  fileSize,
981
+ audioIncluded,
982
+ audioSkippedReason,
700
983
  error,
701
984
  elapsed,
702
985
  estimatedRemaining,
@@ -706,6 +989,8 @@ function VideoExportModal({
706
989
  } = exportHook;
707
990
  const handleExport = useCallback3(async () => {
708
991
  const config = {
992
+ // defaultConfig is the base; explicit props/selections win over it.
993
+ ...defaultConfig,
709
994
  quality,
710
995
  fps,
711
996
  orientation,
@@ -713,7 +998,8 @@ function VideoExportModal({
713
998
  images,
714
999
  audio,
715
1000
  mediaProvider,
716
- playerScript
1001
+ // Only thread the bundle through when the host actually supplied one.
1002
+ ...playerScript !== void 0 ? { playerScript } : {}
717
1003
  };
718
1004
  await startExport(doc, config);
719
1005
  }, [
@@ -726,6 +1012,7 @@ function VideoExportModal({
726
1012
  audio,
727
1013
  mediaProvider,
728
1014
  playerScript,
1015
+ defaultConfig,
729
1016
  startExport
730
1017
  ]);
731
1018
  const handleDownload = useCallback3(() => {
@@ -848,6 +1135,10 @@ function VideoExportModal({
848
1135
  (fileSize / (1024 * 1024)).toFixed(1),
849
1136
  " MB"
850
1137
  ] }),
1138
+ audioIncluded ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#2d6a10", margin: "0 0 4px 0" }, children: "Audio included \u2713" }) : /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 4px 0" }, children: [
1139
+ "Video only",
1140
+ audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
1141
+ ] }),
851
1142
  backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 12px 0" }, children: "Encoded with WebCodecs (H.264)" }),
852
1143
  /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
853
1144
  /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
@@ -886,6 +1177,7 @@ function VideoExportButton({
886
1177
  mediaProvider,
887
1178
  images,
888
1179
  audio,
1180
+ defaultConfig,
889
1181
  label = "Export Video",
890
1182
  style,
891
1183
  disabled
@@ -904,6 +1196,7 @@ function VideoExportButton({
904
1196
  mediaProvider,
905
1197
  images,
906
1198
  audio,
1199
+ defaultConfig,
907
1200
  onClose: handleClose
908
1201
  }
909
1202
  ),
@@ -916,6 +1209,8 @@ export {
916
1209
  VideoExportModal,
917
1210
  createEncoder,
918
1211
  supportsWebCodecs,
1212
+ supportsWebCodecsAac,
1213
+ supportsWebCodecsH264,
919
1214
  useFrameCapture,
920
1215
  useVideoExport
921
1216
  };