@bendyline/squisq-video-react 2.4.11 → 2.4.13

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.
@@ -247,6 +247,25 @@ function resolveOfflineAudioContext() {
247
247
  }
248
248
  return ctor;
249
249
  }
250
+ function clipGainEnvelope(clip, when, duration) {
251
+ const level = clip.gainDb ? Math.pow(10, clip.gainDb / 20) : 1;
252
+ let fadeIn = Math.max(0, clip.fadeInSec ?? 0);
253
+ let fadeOut = Math.max(0, clip.fadeOutSec ?? 0);
254
+ if (level === 1 && fadeIn === 0 && fadeOut === 0) return null;
255
+ if (duration <= 0) return [{ time: when, value: level }];
256
+ if (fadeIn + fadeOut > duration) {
257
+ const scale = duration / (fadeIn + fadeOut);
258
+ fadeIn *= scale;
259
+ fadeOut *= scale;
260
+ }
261
+ const points = [{ time: when, value: fadeIn > 0 ? 0 : level }];
262
+ if (fadeIn > 0) points.push({ time: when + fadeIn, value: level });
263
+ if (fadeOut > 0) {
264
+ points.push({ time: when + duration - fadeOut, value: level });
265
+ points.push({ time: when + duration, value: 0 });
266
+ }
267
+ return points;
268
+ }
250
269
  async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate = EXPORT_AUDIO_SAMPLE_RATE) {
251
270
  const Ctor = resolveOfflineAudioContext();
252
271
  const channels = EXPORT_AUDIO_CHANNELS;
@@ -277,10 +296,21 @@ async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate
277
296
  }
278
297
  const node = ctx.createBufferSource();
279
298
  node.buffer = buffer;
280
- node.connect(ctx.destination);
281
299
  const when = Math.max(0, clip.startSec);
282
300
  const offset = Math.max(0, clip.sourceInSec);
283
301
  const duration = Math.max(0, clip.durationSec);
302
+ const envelope = clipGainEnvelope(clip, when, duration);
303
+ if (envelope) {
304
+ const gain = ctx.createGain();
305
+ gain.gain.setValueAtTime(envelope[0].value, envelope[0].time);
306
+ for (const point of envelope.slice(1)) {
307
+ gain.gain.linearRampToValueAtTime(point.value, point.time);
308
+ }
309
+ node.connect(gain);
310
+ gain.connect(ctx.destination);
311
+ } else {
312
+ node.connect(ctx.destination);
313
+ }
284
314
  node.start(when, offset, duration);
285
315
  scheduledNodes.push(node);
286
316
  }
@@ -0,0 +1,321 @@
1
+ import {
2
+ ADTS,
3
+ AudioSample,
4
+ AudioSampleSink,
5
+ AudioSampleSource,
6
+ BlobSource,
7
+ BufferTarget,
8
+ FLAC,
9
+ Input,
10
+ MATROSKA,
11
+ MP3,
12
+ MP4,
13
+ OGG,
14
+ Output,
15
+ QTFF,
16
+ WAVE,
17
+ WEBM,
18
+ WavOutputFormat,
19
+ WebMOutputFormat,
20
+ canEncodeAudio
21
+ } from "./chunk-4GFZ3GUE.js";
22
+
23
+ // src/mediaEdit/mediaEditEngine.ts
24
+ import {
25
+ MEDIA_FX_ENGINE,
26
+ analyzeMediaFx,
27
+ correctedLoudnessAnalysis,
28
+ createBreathAnalyzer,
29
+ detectPauseCuts,
30
+ parseMediaFx,
31
+ renderMediaFx
32
+ } from "@bendyline/squisq/mediaEdit";
33
+
34
+ // src/mediaEdit/audioRenderSink.ts
35
+ var OPUS_BITRATE_PER_CHANNEL = 96e3;
36
+ async function createAudioRenderSink(channels, sampleRate) {
37
+ const bitrate = OPUS_BITRATE_PER_CHANNEL * channels;
38
+ const opus = await canEncodeAudio("opus", { numberOfChannels: channels, sampleRate, bitrate });
39
+ const target = new BufferTarget();
40
+ const output = new Output({
41
+ format: opus ? new WebMOutputFormat() : new WavOutputFormat(),
42
+ target
43
+ });
44
+ const source = new AudioSampleSource(opus ? { codec: "opus", bitrate } : { codec: "pcm-s16" });
45
+ output.addAudioTrack(source);
46
+ await output.start();
47
+ let written = 0;
48
+ return {
49
+ async write(block) {
50
+ const frames = block[0]?.length ?? 0;
51
+ if (frames === 0) return;
52
+ const data = new Float32Array(frames * channels);
53
+ for (let c = 0; c < channels; c++) data.set(block[c], c * frames);
54
+ const sample = new AudioSample({
55
+ data,
56
+ format: "f32-planar",
57
+ numberOfChannels: channels,
58
+ sampleRate,
59
+ timestamp: written / sampleRate
60
+ });
61
+ try {
62
+ await source.add(sample);
63
+ } finally {
64
+ sample.close();
65
+ }
66
+ written += frames;
67
+ },
68
+ async finish() {
69
+ source.close();
70
+ await output.finalize();
71
+ if (!target.buffer) throw new Error("The audio render produced no output.");
72
+ return opus ? { bytes: target.buffer, mimeType: "audio/webm", extension: "webm" } : { bytes: target.buffer, mimeType: "audio/wav", extension: "wav" };
73
+ },
74
+ cancel: () => output.cancel()
75
+ };
76
+ }
77
+
78
+ // src/mediaEdit/mediaAudioSource.ts
79
+ import {
80
+ MEDIA_FX_SAMPLE_RATE,
81
+ createResampler
82
+ } from "@bendyline/squisq/mediaEdit";
83
+ var INPUT_FORMATS = [MP4, QTFF, WEBM, MATROSKA, WAVE, MP3, OGG, FLAC, ADTS];
84
+ async function openMediaAudio(blob, options = {}) {
85
+ const input = new Input({ source: new BlobSource(blob), formats: INPUT_FORMATS });
86
+ const track = await input.getPrimaryAudioTrack();
87
+ if (!track) {
88
+ input.dispose();
89
+ return null;
90
+ }
91
+ const trackDurationSec = await track.computeDuration();
92
+ const sourceRate = track.sampleRate;
93
+ const channels = Math.min(2, Math.max(1, track.numberOfChannels));
94
+ const startSec = Math.max(0, options.startSec ?? 0);
95
+ const endSec = Math.max(startSec, Math.min(options.endSec ?? trackDurationSec, trackDurationSec));
96
+ const durationSec = endSec - startSec;
97
+ const frames = Math.round(durationSec * MEDIA_FX_SAMPLE_RATE);
98
+ const sourceFrames = Math.round(durationSec * sourceRate);
99
+ async function* open() {
100
+ const sink = new AudioSampleSink(track);
101
+ const resamplers = Array.from(
102
+ { length: channels },
103
+ () => createResampler(sourceRate, MEDIA_FX_SAMPLE_RATE)
104
+ );
105
+ let placed = 0;
106
+ let emitted = 0;
107
+ const feed = (planes) => {
108
+ const out = resamplers.map((r, c) => r.process(planes[c]));
109
+ const room2 = frames - emitted;
110
+ const take = Math.min(out[0].length, room2);
111
+ if (take <= 0) return null;
112
+ emitted += take;
113
+ return out.map((ch) => ch.length === take ? ch : ch.subarray(0, take));
114
+ };
115
+ for await (const sample of sink.samples(startSec, endSec)) {
116
+ try {
117
+ const at = Math.round((sample.timestamp - startSec) * sourceRate);
118
+ let planes = Array.from({ length: channels }, (_, c) => {
119
+ const plane = new Float32Array(sample.numberOfFrames);
120
+ sample.copyTo(plane, {
121
+ planeIndex: Math.min(c, sample.numberOfChannels - 1),
122
+ format: "f32-planar"
123
+ });
124
+ return plane;
125
+ });
126
+ if (at > placed) {
127
+ const gap = Math.min(at - placed, sourceFrames - placed);
128
+ if (gap > 0) {
129
+ const out2 = feed(Array.from({ length: channels }, () => new Float32Array(gap)));
130
+ placed += gap;
131
+ if (out2) yield out2;
132
+ }
133
+ }
134
+ const overlap = placed - at;
135
+ if (overlap > 0) planes = planes.map((p) => p.subarray(Math.min(overlap, p.length)));
136
+ const room2 = sourceFrames - placed;
137
+ if (room2 <= 0) break;
138
+ if (planes[0].length > room2) planes = planes.map((p) => p.subarray(0, room2));
139
+ if (planes[0].length === 0) continue;
140
+ placed += planes[0].length;
141
+ const out = feed(planes);
142
+ if (out) yield out;
143
+ } finally {
144
+ sample.close();
145
+ }
146
+ }
147
+ if (placed < sourceFrames) {
148
+ const out = feed(
149
+ Array.from({ length: channels }, () => new Float32Array(sourceFrames - placed))
150
+ );
151
+ if (out) yield out;
152
+ }
153
+ const tail = resamplers.map((r) => r.flush());
154
+ const room = frames - emitted;
155
+ if (room > 0) {
156
+ const padded = tail.map((ch) => {
157
+ const full = new Float32Array(room);
158
+ full.set(ch.subarray(0, room));
159
+ return full;
160
+ });
161
+ emitted += room;
162
+ yield padded;
163
+ }
164
+ }
165
+ return {
166
+ sampleRate: MEDIA_FX_SAMPLE_RATE,
167
+ channels,
168
+ frames,
169
+ durationSec,
170
+ trackDurationSec,
171
+ sourceSampleRate: sourceRate,
172
+ open,
173
+ dispose: () => input.dispose()
174
+ };
175
+ }
176
+
177
+ // src/mediaEdit/mediaEditEngine.ts
178
+ var PREVIEW_PREROLL_SEC = 1;
179
+ var FIRST_PASS_SHARE = 0.67;
180
+ function chainOf(fx) {
181
+ const chain = parseMediaFx(fx);
182
+ if (!chain || chain.ops.length === 0) throw new Error(`Nothing to render in fx "${fx}".`);
183
+ return chain;
184
+ }
185
+ function concatBlocks(blocks, channels) {
186
+ return Array.from({ length: channels }, (_, c) => {
187
+ const total = blocks.reduce((n, b) => n + b[c].length, 0);
188
+ const out = new Float32Array(total);
189
+ let at = 0;
190
+ for (const b of blocks) {
191
+ out.set(b[c], at);
192
+ at += b[c].length;
193
+ }
194
+ return out;
195
+ });
196
+ }
197
+ async function renderMediaEditAudio(request, options = {}) {
198
+ const fx = chainOf(request.fx);
199
+ const source = await openMediaAudio(request.source);
200
+ if (!source) throw new Error("This media has no audio track to process.");
201
+ try {
202
+ let analysis;
203
+ for (let attempt = 0; ; attempt++) {
204
+ const from = attempt === 0 ? 0 : FIRST_PASS_SHARE;
205
+ const span = attempt === 0 ? FIRST_PASS_SHARE : 1 - FIRST_PASS_SHARE;
206
+ const sink = await createAudioRenderSink(source.channels, source.sampleRate);
207
+ try {
208
+ const result = await renderMediaFx({
209
+ fx,
210
+ source,
211
+ denoiser: options.denoiser,
212
+ write: (block) => sink.write(block),
213
+ onProgress: (f) => options.onProgress?.(from + f * span),
214
+ signal: options.signal,
215
+ ...analysis ? { analysis } : {}
216
+ });
217
+ const corrected = attempt === 0 ? correctedLoudnessAnalysis(fx, result) : null;
218
+ if (corrected) {
219
+ await sink.cancel();
220
+ analysis = corrected;
221
+ continue;
222
+ }
223
+ const encoded = await sink.finish();
224
+ options.onProgress?.(1);
225
+ return {
226
+ ...encoded,
227
+ analysis: result.analysis,
228
+ ignoredOps: result.ignoredOps,
229
+ sourceDuration: source.trackDurationSec,
230
+ engine: MEDIA_FX_ENGINE
231
+ };
232
+ } catch (err) {
233
+ await sink.cancel().catch(() => {
234
+ });
235
+ throw err;
236
+ }
237
+ }
238
+ } finally {
239
+ source.dispose();
240
+ }
241
+ }
242
+ async function analyzeMediaEditAudio(request, options = {}) {
243
+ const fx = chainOf(request.fx);
244
+ const source = await openMediaAudio(request.source);
245
+ if (!source) return null;
246
+ try {
247
+ return await analyzeMediaFx({
248
+ fx,
249
+ source,
250
+ denoiser: options.denoiser,
251
+ onProgress: options.onProgress,
252
+ signal: options.signal
253
+ });
254
+ } finally {
255
+ source.dispose();
256
+ }
257
+ }
258
+ async function previewMediaEditAudio(request, options = {}) {
259
+ const fx = chainOf(request.fx);
260
+ const decodeFrom = Math.max(0, request.startSec - PREVIEW_PREROLL_SEC);
261
+ const source = await openMediaAudio(request.source, {
262
+ startSec: decodeFrom,
263
+ endSec: request.endSec
264
+ });
265
+ if (!source) throw new Error("This media has no audio track to process.");
266
+ try {
267
+ const originalBlocks = [];
268
+ for await (const block of source.open()) originalBlocks.push(block.map((ch) => ch.slice()));
269
+ const processedBlocks = [];
270
+ await renderMediaFx({
271
+ fx,
272
+ source,
273
+ startSec: decodeFrom,
274
+ denoiser: options.denoiser,
275
+ write: (block) => {
276
+ processedBlocks.push(block.map((ch) => ch.slice()));
277
+ },
278
+ onProgress: options.onProgress,
279
+ signal: options.signal,
280
+ ...request.analysis ? { analysis: request.analysis } : {}
281
+ });
282
+ const skip = Math.round((request.startSec - decodeFrom) * source.sampleRate);
283
+ const trim = (channels) => channels.map((ch) => ch.slice(skip));
284
+ return {
285
+ sampleRate: source.sampleRate,
286
+ startSec: request.startSec,
287
+ original: trim(concatBlocks(originalBlocks, source.channels)),
288
+ processed: trim(concatBlocks(processedBlocks, source.channels))
289
+ };
290
+ } finally {
291
+ source.dispose();
292
+ }
293
+ }
294
+ async function analyzeMediaEditPauses(request, options = {}) {
295
+ const source = await openMediaAudio(request.source);
296
+ if (!source) throw new Error("This media has no audio track to analyze.");
297
+ try {
298
+ const analyzer = createBreathAnalyzer(source.sampleRate);
299
+ let seen = 0;
300
+ for await (const block of source.open()) {
301
+ options.signal?.throwIfAborted();
302
+ const mono = block.length === 1 ? block[0] : block[0].map((_, i) => block.reduce((sum, ch) => sum + ch[i], 0) / block.length);
303
+ analyzer.push(mono);
304
+ seen += block[0].length;
305
+ options.onProgress?.(Math.min(1, seen / Math.max(1, source.frames)));
306
+ }
307
+ return {
308
+ cuts: detectPauseCuts(analyzer.frames(), request.options),
309
+ durationSec: source.trackDurationSec
310
+ };
311
+ } finally {
312
+ source.dispose();
313
+ }
314
+ }
315
+
316
+ export {
317
+ renderMediaEditAudio,
318
+ analyzeMediaEditAudio,
319
+ previewMediaEditAudio,
320
+ analyzeMediaEditPauses
321
+ };
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-3NJDHK62.js";
8
8
  import {
9
9
  useFrameCapture
10
- } from "./chunk-4NI2QIFE.js";
10
+ } from "./chunk-2C4BEAWT.js";
11
11
 
12
12
  // src/CoverImageExportModal.tsx
13
13
  import { useCallback, useId, useRef, useState } from "react";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-BGS2NEYC.js";
3
+ } from "./chunk-TUIVBMAK.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
- import { useState, useCallback, useId, useRef } from "react";
6
+ import { useState, useCallback, useEffect, useId, useRef } from "react";
7
7
  import { useModalDialog } from "@bendyline/squisq-react";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  function formatDuration(seconds) {
@@ -160,6 +160,7 @@ function VideoExportModal({
160
160
  mediaProvider,
161
161
  images,
162
162
  audio,
163
+ mediaEditRenders,
163
164
  defaultConfig,
164
165
  colorScheme = "light",
165
166
  uiPalette,
@@ -256,7 +257,23 @@ function VideoExportModal({
256
257
  setCaptionMode("off");
257
258
  }
258
259
  }, []);
260
+ const [preparingEdits, setPreparingEdits] = useState(false);
261
+ const editsWaitRef = useRef(null);
262
+ useEffect(() => () => editsWaitRef.current?.abort(), []);
259
263
  const handleExport = useCallback(async () => {
264
+ if (includeAudio && mediaEditRenders) {
265
+ const controller = new AbortController();
266
+ editsWaitRef.current = controller;
267
+ setPreparingEdits(true);
268
+ try {
269
+ await mediaEditRenders.whenReady(doc, controller.signal);
270
+ } catch {
271
+ return;
272
+ } finally {
273
+ editsWaitRef.current = null;
274
+ setPreparingEdits(false);
275
+ }
276
+ }
260
277
  const previewCanvas = previewCanvasRef.current;
261
278
  const previewContext = previewCanvas?.getContext("2d");
262
279
  if (previewCanvas && previewContext) {
@@ -276,6 +293,7 @@ function VideoExportModal({
276
293
  images,
277
294
  audio,
278
295
  mediaProvider,
296
+ ...mediaEditRenders ? { processedAudio: mediaEditRenders.processedAudio } : {},
279
297
  // Only thread the bundle through when the host actually supplied one.
280
298
  ...playerScript !== void 0 ? { playerScript } : {}
281
299
  };
@@ -292,6 +310,7 @@ function VideoExportModal({
292
310
  images,
293
311
  audio,
294
312
  mediaProvider,
313
+ mediaEditRenders,
295
314
  playerScript,
296
315
  defaultConfig,
297
316
  startExport
@@ -505,9 +524,26 @@ function VideoExportModal({
505
524
  ),
506
525
  outputFormat === "gif" && animationsEnabled && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: palette.muted, margin: "-6px 0 12px" }, children: "Disabling motion is recommended for much smaller GIFs." })
507
526
  ] }),
527
+ preparingEdits && /* @__PURE__ */ jsx(
528
+ "p",
529
+ {
530
+ role: "status",
531
+ "data-squisq-video-export-preparing-edits": true,
532
+ style: { fontSize: 12, color: palette.muted, margin: "0 0 12px" },
533
+ children: "Processing audio edits\u2026"
534
+ }
535
+ ),
508
536
  /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
509
537
  /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Cancel" }),
510
- /* @__PURE__ */ jsx("button", { style: themedPrimaryButtonStyle, onClick: handleExport, children: outputFormat === "gif" ? "Export GIF" : "Export Video" })
538
+ /* @__PURE__ */ jsx(
539
+ "button",
540
+ {
541
+ style: themedPrimaryButtonStyle,
542
+ onClick: handleExport,
543
+ disabled: preparingEdits,
544
+ children: outputFormat === "gif" ? "Export GIF" : "Export Video"
545
+ }
546
+ )
511
547
  ] })
512
548
  ] }),
513
549
  isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [