@bendyline/squisq-video-react 2.4.11 → 2.4.12

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.
@@ -1,10 +1,256 @@
1
+ import {
2
+ BlobSource,
3
+ Input,
4
+ MATROSKA,
5
+ MP4,
6
+ QTFF,
7
+ VideoSampleSink,
8
+ WEBM
9
+ } from "./chunk-4GFZ3GUE.js";
10
+
1
11
  // src/hooks/useFrameCapture.ts
2
12
  import { createElement } from "react";
3
13
  import { createRoot } from "react-dom/client";
4
14
  import { useRef, useCallback, useMemo } from "react";
15
+ import { parseMediaCrop } from "@bendyline/squisq/mediaEdit";
5
16
  import { resolveDashboardStyleId } from "@bendyline/squisq/doc";
6
17
  import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
7
18
  import html2canvas from "html2canvas";
19
+
20
+ // src/hooks/captureVideoFrames.ts
21
+ var FRAME_TIME_EPSILON_SECONDS = 1e-4;
22
+ var MAX_SEQUENTIAL_DECODE_GAP_SECONDS = 3;
23
+ var DECODABLE_SOURCE_URL = /^(?:blob|data):/i;
24
+ var INPUT_FORMATS = [MP4, QTFF, WEBM, MATROSKA];
25
+ var decodedFrames = /* @__PURE__ */ new WeakMap();
26
+ function getDecodedCaptureFrame(video) {
27
+ const frame = decodedFrames.get(video);
28
+ if (!frame || frame.timestamp === null) return null;
29
+ return {
30
+ canvas: frame.canvas,
31
+ width: frame.width,
32
+ height: frame.height,
33
+ timestamp: frame.timestamp
34
+ };
35
+ }
36
+ function resolveCaptureVideoImage(video) {
37
+ const decoded = getDecodedCaptureFrame(video);
38
+ if (decoded) return { source: decoded.canvas, width: decoded.width, height: decoded.height };
39
+ return { source: video, width: video.videoWidth, height: video.videoHeight };
40
+ }
41
+ function captureVideoSource(video) {
42
+ return video.currentSrc || video.src;
43
+ }
44
+ var DecodedVideoCursor = class _DecodedVideoCursor {
45
+ /**
46
+ * @param source Sequential sample iterator factory for one video track.
47
+ * @param releaseSource Frees the demuxer behind `source`.
48
+ */
49
+ constructor(source, releaseSource, canvas, width, height) {
50
+ this.source = source;
51
+ this.releaseSource = releaseSource;
52
+ this.iterator = null;
53
+ this.lookahead = null;
54
+ /** True while showing a track's first frame for a target before it. */
55
+ this.showingLeadingFrame = false;
56
+ this.queue = Promise.resolve();
57
+ this.disposed = false;
58
+ canvas.width = width;
59
+ canvas.height = height;
60
+ this.frame = { canvas, width, height, timestamp: null };
61
+ }
62
+ /** Open a local source, or null when it has no decodable video track. */
63
+ static async open(blob, ownerDocument) {
64
+ const input = new Input({ source: new BlobSource(blob), formats: INPUT_FORMATS });
65
+ try {
66
+ const track = await input.getPrimaryVideoTrack();
67
+ if (!track || !await track.canDecode()) {
68
+ input.dispose();
69
+ return null;
70
+ }
71
+ const width = track.displayWidth;
72
+ const height = track.displayHeight;
73
+ if (width <= 0 || height <= 0) {
74
+ input.dispose();
75
+ return null;
76
+ }
77
+ return new _DecodedVideoCursor(
78
+ new VideoSampleSink(track),
79
+ () => input.dispose(),
80
+ ownerDocument.createElement("canvas"),
81
+ width,
82
+ height
83
+ );
84
+ } catch {
85
+ input.dispose();
86
+ return null;
87
+ }
88
+ }
89
+ /** Draw the frame displayed at `targetTime` (seconds). Calls run in order. */
90
+ selectFrame(targetTime) {
91
+ const selection = this.queue.then(() => this.advanceTo(targetTime));
92
+ this.queue = selection.catch(() => void 0);
93
+ return selection;
94
+ }
95
+ dispose() {
96
+ if (this.disposed) return;
97
+ this.disposed = true;
98
+ this.lookahead?.close();
99
+ this.lookahead = null;
100
+ void this.iterator?.return();
101
+ this.iterator = null;
102
+ this.releaseSource();
103
+ this.frame.canvas.width = 0;
104
+ this.frame.canvas.height = 0;
105
+ this.frame.timestamp = null;
106
+ }
107
+ async advanceTo(targetTime) {
108
+ if (this.disposed) throw new Error("Decoded capture video was released.");
109
+ const target = targetTime + FRAME_TIME_EPSILON_SECONDS;
110
+ const shown = this.frame.timestamp;
111
+ const continues = this.iterator !== null && shown !== null && target - shown <= MAX_SEQUENTIAL_DECODE_GAP_SECONDS && (target >= shown || this.showingLeadingFrame);
112
+ if (continues && target < shown) return;
113
+ let candidate = null;
114
+ try {
115
+ if (!continues) {
116
+ await this.restartAt(target);
117
+ candidate = await this.pull();
118
+ if (!candidate) return;
119
+ }
120
+ for (; ; ) {
121
+ const next = this.lookahead ?? await this.pull();
122
+ this.lookahead = null;
123
+ if (!next) break;
124
+ if (next.timestamp > target) {
125
+ this.lookahead = next;
126
+ break;
127
+ }
128
+ candidate?.close();
129
+ candidate = next;
130
+ }
131
+ if (candidate && !this.disposed) {
132
+ this.present(candidate);
133
+ this.showingLeadingFrame = candidate.timestamp > target;
134
+ }
135
+ } finally {
136
+ candidate?.close();
137
+ }
138
+ }
139
+ async restartAt(target) {
140
+ this.lookahead?.close();
141
+ this.lookahead = null;
142
+ await this.iterator?.return();
143
+ this.iterator = this.source.samples(target);
144
+ }
145
+ async pull() {
146
+ const result = await this.iterator?.next();
147
+ return result && !result.done ? result.value : null;
148
+ }
149
+ present(sample) {
150
+ const { frame } = this;
151
+ if (sample.displayWidth !== frame.width || sample.displayHeight !== frame.height) {
152
+ frame.width = sample.displayWidth;
153
+ frame.height = sample.displayHeight;
154
+ frame.canvas.width = frame.width;
155
+ frame.canvas.height = frame.height;
156
+ }
157
+ const context = frame.canvas.getContext("2d");
158
+ if (!context) throw new Error("Could not create the decoded video frame canvas context");
159
+ sample.draw(context, 0, 0, frame.width, frame.height);
160
+ frame.timestamp = sample.timestamp;
161
+ }
162
+ };
163
+ var CaptureVideoFrameRegistry = class {
164
+ constructor() {
165
+ this.bindings = /* @__PURE__ */ new Map();
166
+ this.blobs = /* @__PURE__ */ new Map();
167
+ /** Sources that failed to open or decode; they keep the element path. */
168
+ this.undecodableSources = /* @__PURE__ */ new Set();
169
+ this.opening = /* @__PURE__ */ new Map();
170
+ this.disposed = false;
171
+ this.selectFrame = async (video, targetTime) => {
172
+ const binding = this.bindings.get(video);
173
+ if (!binding || binding.src !== captureVideoSource(video)) return false;
174
+ try {
175
+ await binding.cursor.selectFrame(targetTime);
176
+ return true;
177
+ } catch {
178
+ this.undecodableSources.add(binding.src);
179
+ this.release(video);
180
+ return false;
181
+ }
182
+ };
183
+ }
184
+ /**
185
+ * Bind decoders to every video under `root` that lacks one for its current
186
+ * source, and release bindings whose element left the DOM or changed
187
+ * source. Returns how many elements were newly bound: their pixels now
188
+ * come from the decoder, so a caller must re-run its seek barrier.
189
+ */
190
+ async attach(root) {
191
+ for (const [video, binding] of this.bindings) {
192
+ if (!video.isConnected || captureVideoSource(video) !== binding.src) this.release(video);
193
+ }
194
+ const results = await Promise.all(
195
+ Array.from(root.querySelectorAll("video")).filter((video) => !this.bindings.has(video)).map((video) => this.bind(video))
196
+ );
197
+ return results.filter(Boolean).length;
198
+ }
199
+ /** Whether capture draws this element from decoded frames. */
200
+ isDecoded(video) {
201
+ return this.bindings.has(video);
202
+ }
203
+ dispose() {
204
+ this.disposed = true;
205
+ for (const video of [...this.bindings.keys()]) this.release(video);
206
+ this.blobs.clear();
207
+ this.opening.clear();
208
+ }
209
+ bind(video) {
210
+ const pending = this.opening.get(video);
211
+ if (pending) return pending;
212
+ const opening = this.open(video).finally(() => this.opening.delete(video));
213
+ this.opening.set(video, opening);
214
+ return opening;
215
+ }
216
+ async open(video) {
217
+ const src = captureVideoSource(video);
218
+ if (!src || !DECODABLE_SOURCE_URL.test(src) || this.undecodableSources.has(src)) return false;
219
+ const blob = await this.loadBlob(src);
220
+ const cursor = blob ? await DecodedVideoCursor.open(blob, video.ownerDocument) : null;
221
+ if (!cursor) {
222
+ this.undecodableSources.add(src);
223
+ return false;
224
+ }
225
+ if (this.disposed || !video.isConnected || captureVideoSource(video) !== src) {
226
+ cursor.dispose();
227
+ return false;
228
+ }
229
+ this.bindings.set(video, { src, cursor });
230
+ decodedFrames.set(video, cursor.frame);
231
+ video.pause();
232
+ video.dataset.captureDecoded = "true";
233
+ return true;
234
+ }
235
+ loadBlob(src) {
236
+ let blob = this.blobs.get(src);
237
+ if (!blob) {
238
+ blob = fetch(src).then((response) => response.ok ? response.blob() : null).catch(() => null);
239
+ this.blobs.set(src, blob);
240
+ }
241
+ return blob;
242
+ }
243
+ release(video) {
244
+ const binding = this.bindings.get(video);
245
+ if (!binding) return;
246
+ this.bindings.delete(video);
247
+ if (decodedFrames.get(video) === binding.cursor.frame) decodedFrames.delete(video);
248
+ delete video.dataset.captureDecoded;
249
+ binding.cursor.dispose();
250
+ }
251
+ };
252
+
253
+ // src/hooks/useFrameCapture.ts
8
254
  var MIME_MAP = {
9
255
  jpg: "image/jpeg",
10
256
  jpeg: "image/jpeg",
@@ -147,6 +393,7 @@ function waitForCaptureVideoState(video, description, isReady, update) {
147
393
  });
148
394
  }
149
395
  function captureVideoNeedsPriming(video, primedVideos) {
396
+ if (video.dataset.captureDecoded === "true") return false;
150
397
  if (video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth <= 0 && video.videoHeight <= 0) {
151
398
  return false;
152
399
  }
@@ -350,6 +597,30 @@ function videoFrameRect(sourceWidth, sourceHeight, destinationWidth, destination
350
597
  dh: destinationHeight
351
598
  };
352
599
  }
600
+ function croppedVideoFrameRect(sourceWidth, sourceHeight, crop, destinationWidth, destinationHeight, objectFit) {
601
+ if (!crop) {
602
+ return videoFrameRect(
603
+ sourceWidth,
604
+ sourceHeight,
605
+ destinationWidth,
606
+ destinationHeight,
607
+ objectFit
608
+ );
609
+ }
610
+ const cx = crop.x * sourceWidth;
611
+ const cy = crop.y * sourceHeight;
612
+ const rect = videoFrameRect(
613
+ crop.w * sourceWidth,
614
+ crop.h * sourceHeight,
615
+ destinationWidth,
616
+ destinationHeight,
617
+ objectFit
618
+ );
619
+ return { ...rect, sx: rect.sx + cx, sy: rect.sy + cy };
620
+ }
621
+ function videoCrop(video) {
622
+ return parseMediaCrop(video.getAttribute("data-crop") ?? void 0);
623
+ }
353
624
  function prepareScheduledVideoClones(originalRoot, clonedRoot) {
354
625
  const captureFamilies = [
355
626
  {
@@ -381,15 +652,17 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
381
652
  canvas.dataset.videoCaptureClone = "true";
382
653
  const destinationWidth = Math.round(video.clientWidth || video.offsetWidth);
383
654
  const destinationHeight = Math.round(video.clientHeight || video.offsetHeight);
384
- if (video.videoWidth <= 0 || video.videoHeight <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
655
+ const image = resolveCaptureVideoImage(video);
656
+ if (image.width <= 0 || image.height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
385
657
  return;
386
658
  }
387
659
  try {
388
660
  const view = video.ownerDocument.defaultView;
389
661
  const objectFit = video.style.objectFit || view?.getComputedStyle(video).objectFit || "fill";
390
- const frame = videoFrameRect(
391
- video.videoWidth,
392
- video.videoHeight,
662
+ const frame = croppedVideoFrameRect(
663
+ image.width,
664
+ image.height,
665
+ videoCrop(video),
393
666
  destinationWidth,
394
667
  destinationHeight,
395
668
  objectFit
@@ -399,7 +672,7 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
399
672
  canvas.width = destinationWidth;
400
673
  canvas.height = destinationHeight;
401
674
  context.drawImage(
402
- video,
675
+ image.source,
403
676
  frame.sx,
404
677
  frame.sy,
405
678
  frame.sw,
@@ -650,7 +923,8 @@ function releaseCaptureCloneCanvases(canvases) {
650
923
  });
651
924
  }
652
925
  function scheduledVideoIsVisual(video) {
653
- return video.videoWidth > 0 && video.videoHeight > 0 && video.dataset.active === "true";
926
+ const { width, height } = resolveCaptureVideoImage(video);
927
+ return width > 0 && height > 0 && video.dataset.active === "true";
654
928
  }
655
929
  function scheduledVideoPresentation(video) {
656
930
  return video.closest(SCHEDULED_MEDIA_SELECTOR)?.dataset.presentation;
@@ -786,9 +1060,11 @@ function drawScheduledVideosOnto(destination, captureRoot, videos) {
786
1060
  0,
787
1061
  outerRadius - Math.max(borderLeft * scaleX, borderTop * scaleY)
788
1062
  );
789
- const source = videoFrameRect(
790
- video.videoWidth,
791
- video.videoHeight,
1063
+ const image = resolveCaptureVideoImage(video);
1064
+ const source = croppedVideoFrameRect(
1065
+ image.width,
1066
+ image.height,
1067
+ videoCrop(video),
792
1068
  contentWidth,
793
1069
  contentHeight,
794
1070
  style.objectFit || "fill"
@@ -806,7 +1082,7 @@ function drawScheduledVideosOnto(destination, captureRoot, videos) {
806
1082
  addRoundedRect(context, innerX, innerY, innerWidth, innerHeight, innerRadius);
807
1083
  context.clip();
808
1084
  context.drawImage(
809
- video,
1085
+ image.source,
810
1086
  source.sx,
811
1087
  source.sy,
812
1088
  source.sw,
@@ -852,10 +1128,20 @@ function getFrameVisualStateKey(captureRoot, timelineTime, options = {}) {
852
1128
  });
853
1129
  if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
854
1130
  const videoStates = Array.from(captureRoot.querySelectorAll("video")).filter(
855
- (video) => video.videoWidth > 0 && video.videoHeight > 0 && (!options.ignoreScheduledVideoFrames || !video.closest(SCHEDULED_MEDIA_SELECTOR))
856
- ).map(
857
- (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
858
- );
1131
+ (video) => !options.ignoreScheduledVideoFrames || !video.closest(SCHEDULED_MEDIA_SELECTOR)
1132
+ ).flatMap((video) => {
1133
+ const src = video.currentSrc || video.src;
1134
+ const decoded = getDecodedCaptureFrame(video);
1135
+ if (decoded) {
1136
+ return [
1137
+ `${src}:decoded:${finiteMediaTime(decoded.timestamp)}:${decoded.width}x${decoded.height}`
1138
+ ];
1139
+ }
1140
+ if (video.videoWidth <= 0 || video.videoHeight <= 0) return [];
1141
+ return [
1142
+ `${src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
1143
+ ];
1144
+ });
859
1145
  if (captureRoot.querySelector(
860
1146
  "canvas, iframe, object, embed, animate, animateMotion, animateTransform, set"
861
1147
  )) {
@@ -886,13 +1172,16 @@ function useFrameCapture() {
886
1172
  const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
887
1173
  const captureSvgRasterCacheRef = useRef(createCaptureSvgRasterCache());
888
1174
  const primedCaptureVideosRef = useRef(/* @__PURE__ */ new WeakSet());
1175
+ const videoFramesRef = useRef(null);
889
1176
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
890
1177
  const captionLayerCanvasRef = useRef(null);
891
1178
  const captionLayerKeyRef = useRef(null);
892
1179
  const captionLayerHasContentRef = useRef(false);
893
1180
  const init = useCallback(
894
1181
  async (doc, renderOptions, captionMode) => {
895
- if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current) {
1182
+ if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current || videoFramesRef.current) {
1183
+ const oldVideoFrames = videoFramesRef.current;
1184
+ videoFramesRef.current = null;
896
1185
  const oldRoot = rootRef.current;
897
1186
  const oldContainer = containerRef.current;
898
1187
  const oldMediaProvider = mediaProviderRef.current;
@@ -922,6 +1211,7 @@ function useFrameCapture() {
922
1211
  setTimeout(() => {
923
1212
  if (oldRoot) oldRoot.unmount();
924
1213
  if (oldContainer) oldContainer.remove();
1214
+ oldVideoFrames?.dispose();
925
1215
  if (oldOwnsMediaProvider) oldMediaProvider?.dispose();
926
1216
  if (oldCaptureCanvas) {
927
1217
  oldCaptureCanvas.width = 0;
@@ -979,6 +1269,8 @@ function useFrameCapture() {
979
1269
  ownsMediaProviderRef.current = !!renderOptions.images;
980
1270
  const root = createRoot(renderRoot);
981
1271
  rootRef.current = root;
1272
+ const videoFrames = new CaptureVideoFrameRegistry();
1273
+ videoFramesRef.current = videoFrames;
982
1274
  const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
983
1275
  const captionStyle = captionMode === "social" ? "social" : "standard";
984
1276
  let resolveRenderAPI;
@@ -1013,6 +1305,7 @@ function useFrameCapture() {
1013
1305
  coverSlideTemplate: renderOptions.coverSlideTemplate,
1014
1306
  captionsEnabled,
1015
1307
  captionStyle,
1308
+ renderVideoFrameSelector: videoFrames.selectFrame,
1016
1309
  onRenderAPIReady: (api) => {
1017
1310
  if (containerRef.current !== container) return;
1018
1311
  renderAPIRef.current = api;
@@ -1052,6 +1345,7 @@ function useFrameCapture() {
1052
1345
  }
1053
1346
  await waitForCaptureMediaResolutions(mediaResolutionTrackerRef.current);
1054
1347
  await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
1348
+ await videoFrames.attach(captureRoot);
1055
1349
  await primeIndeterminateCaptureVideos(captureRoot, primedCaptureVideosRef.current);
1056
1350
  clearTimeout(timeout);
1057
1351
  resolve(api.getDuration());
@@ -1085,16 +1379,18 @@ function useFrameCapture() {
1085
1379
  if (!root) {
1086
1380
  throw new Error("Capture root element not found");
1087
1381
  }
1088
- await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current);
1382
+ const videoFrames = videoFramesRef.current;
1383
+ const prepareCaptureVideos = async () => (await videoFrames?.attach(root) ?? 0) + await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current);
1384
+ await prepareCaptureVideos();
1089
1385
  try {
1090
1386
  await api.seekTo(time);
1091
1387
  } catch (seekError) {
1092
- if (!await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
1388
+ if (!await prepareCaptureVideos()) {
1093
1389
  throw seekError;
1094
1390
  }
1095
1391
  await api.seekTo(time);
1096
1392
  }
1097
- if (await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
1393
+ if (await prepareCaptureVideos()) {
1098
1394
  await api.seekTo(time);
1099
1395
  }
1100
1396
  const renderedTime = api.getRenderedTime();
@@ -1280,6 +1576,8 @@ function useFrameCapture() {
1280
1576
  releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
1281
1577
  captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
1282
1578
  primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
1579
+ videoFramesRef.current?.dispose();
1580
+ videoFramesRef.current = null;
1283
1581
  renderAPIRef.current = null;
1284
1582
  }, []);
1285
1583
  return useMemo(
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-BGS2NEYC.js";
3
+ } from "./chunk-ED7QK6VC.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: [
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useFrameCapture
3
- } from "./chunk-4NI2QIFE.js";
3
+ } from "./chunk-7ZMJ25DC.js";
4
4
  import {
5
5
  EXPORT_AUDIO_CHANNELS,
6
6
  EXPORT_AUDIO_SAMPLE_RATE,
@@ -13,7 +13,7 @@ import {
13
13
  supportsWebCodecs,
14
14
  supportsWebCodecsAac,
15
15
  supportsWebCodecsH264
16
- } from "./chunk-32QCPFXE.js";
16
+ } from "./chunk-GBIYYDMY.js";
17
17
 
18
18
  // src/hooks/useVideoExport.ts
19
19
  import { useState, useRef, useCallback, useEffect } from "react";
@@ -677,7 +677,9 @@ function useVideoExport(options = {}) {
677
677
  document
678
678
  ).catch(() => false);
679
679
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
680
- const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverPlan.audioOffset) : [];
680
+ const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? config.processedAudio ? computeAudioTimeline(doc, coverPlan.audioOffset, {
681
+ processedAudio: config.processedAudio
682
+ }) : computeAudioTimeline(doc, coverPlan.audioOffset) : [];
681
683
  const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
682
684
  const tierDecision = selectAudioTier({
683
685
  hasClips: timeline.length > 0,
@@ -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
  }