@bendyline/squisq-video-react 2.2.9 → 2.2.10

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
@@ -45,7 +45,10 @@ Both components also accept `colorScheme="light" | "dark"` so their portaled
45
45
  modal can match the host application; the default remains `light`. Hosts with
46
46
  their own theme tokens can pass `uiPalette?: Partial<VideoExportPalette>` to
47
47
  override dialog surfaces, controls, status colors, and the shared primary color
48
- used by the export action and progress bar.
48
+ used by the export action and progress bar. By default the completed action is
49
+ labelled **Save MP4/GIF to Downloads** and uses the browser download directory.
50
+ Hosts with a native or File System Access picker can provide `saveOutput` and
51
+ `saveActionLabel` to offer a **Save … as...** flow instead.
49
52
 
50
53
  ### Full Export Modal
51
54
 
@@ -112,6 +115,7 @@ function CustomExport({ doc, images, audio }) {
112
115
  elapsed,
113
116
  estimatedRemaining,
114
117
  downloadUrl,
118
+ outputBlob, // completed Blob for host-provided save flows
115
119
  fileSize,
116
120
  audioIncluded, // whether an audio track was muxed in
117
121
  audioSkippedReason, // null when the doc had no audio; a string explains a shortfall
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-NX34XGLL.js";
3
+ } from "./chunk-GLOLS2CQ.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
6
  import { useState, useCallback, useId, useRef } from "react";
@@ -18,6 +18,9 @@ function formatProcessingFps(framesPerSecond) {
18
18
  function formatRealtimeMultiplier(processingFps, outputFps) {
19
19
  return `${(processingFps / outputFps).toFixed(2)}\xD7 realtime`;
20
20
  }
21
+ function resolveVideoSaveActionLabel(format, formatter) {
22
+ return formatter?.(format) ?? `Save ${format.toUpperCase()} to Downloads`;
23
+ }
21
24
  var FRAME_PREVIEW_INTERVAL = 15;
22
25
  var FRAME_PREVIEW_WIDTH = 480;
23
26
  var FRAME_PREVIEW_HEIGHT = 270;
@@ -160,6 +163,8 @@ function VideoExportModal({
160
163
  defaultConfig,
161
164
  colorScheme = "light",
162
165
  uiPalette,
166
+ saveOutput,
167
+ saveActionLabel,
163
168
  onClose
164
169
  }) {
165
170
  const overlayRef = useRef(null);
@@ -228,6 +233,7 @@ function VideoExportModal({
228
233
  processingFps,
229
234
  outputFormat: completedOutputFormat,
230
235
  downloadUrl,
236
+ outputBlob,
231
237
  fileSize,
232
238
  audioIncluded,
233
239
  audioSkippedReason,
@@ -290,16 +296,31 @@ function VideoExportModal({
290
296
  defaultConfig,
291
297
  startExport
292
298
  ]);
293
- const handleDownload = useCallback(() => {
294
- if (!downloadUrl) return;
295
- const a = document.createElement("a");
296
- a.href = downloadUrl;
299
+ const [saving, setSaving] = useState(false);
300
+ const [saveError, setSaveError] = useState(null);
301
+ const handleSave = useCallback(async () => {
302
+ if (!downloadUrl || !outputBlob) return;
297
303
  const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
298
- a.download = `document-${ts}.${completedOutputFormat}`;
299
- document.body.appendChild(a);
300
- a.click();
301
- document.body.removeChild(a);
302
- }, [downloadUrl, completedOutputFormat]);
304
+ const filename = `document-${ts}.${completedOutputFormat}`;
305
+ setSaveError(null);
306
+ if (!saveOutput) {
307
+ const a = document.createElement("a");
308
+ a.href = downloadUrl;
309
+ a.download = filename;
310
+ document.body.appendChild(a);
311
+ a.click();
312
+ document.body.removeChild(a);
313
+ return;
314
+ }
315
+ setSaving(true);
316
+ try {
317
+ await saveOutput(outputBlob, filename);
318
+ } catch (caught) {
319
+ setSaveError(caught instanceof Error ? caught.message : "The export could not be saved.");
320
+ } finally {
321
+ setSaving(false);
322
+ }
323
+ }, [completedOutputFormat, downloadUrl, outputBlob, saveOutput]);
303
324
  const handleClose = useCallback(() => {
304
325
  if (state === "capturing" || state === "encoding" || state === "preparing") {
305
326
  cancelExport();
@@ -591,11 +612,17 @@ function VideoExportModal({
591
612
  ] }),
592
613
  /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
593
614
  /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
594
- /* @__PURE__ */ jsxs("button", { style: themedPrimaryButtonStyle, onClick: handleDownload, children: [
595
- "Download ",
596
- completedOutputFormat.toUpperCase()
597
- ] })
598
- ] })
615
+ /* @__PURE__ */ jsx(
616
+ "button",
617
+ {
618
+ style: themedPrimaryButtonStyle,
619
+ onClick: () => void handleSave(),
620
+ disabled: saving,
621
+ children: saving ? "Saving..." : resolveVideoSaveActionLabel(completedOutputFormat, saveActionLabel)
622
+ }
623
+ )
624
+ ] }),
625
+ saveError && /* @__PURE__ */ jsx("p", { role: "alert", style: { fontSize: 12, color: palette.danger, margin: "8px 0 0 0" }, children: saveError })
599
626
  ] }),
600
627
  state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
601
628
  /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.danger }, children: "Export failed" }),
@@ -639,6 +666,8 @@ function VideoExportButton({
639
666
  defaultConfig,
640
667
  colorScheme,
641
668
  uiPalette,
669
+ saveOutput,
670
+ saveActionLabel,
642
671
  label,
643
672
  style,
644
673
  disabled
@@ -661,6 +690,8 @@ function VideoExportButton({
661
690
  defaultConfig,
662
691
  colorScheme,
663
692
  uiPalette,
693
+ saveOutput,
694
+ saveActionLabel,
664
695
  onClose: handleClose
665
696
  }
666
697
  ),
@@ -36,6 +36,7 @@ var MIME_MAP = {
36
36
  var CAPTURE_ASSET_TIMEOUT_MS = 15e3;
37
37
  var RENDER_TIME_EPSILON_SECONDS = 1e-6;
38
38
  var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
39
+ var CAPTURE_SVG_SELECTOR = "svg.block-svg";
39
40
  async function waitForImageDecode(image) {
40
41
  const src = image.currentSrc || image.src;
41
42
  if (!src) return;
@@ -201,6 +202,7 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
201
202
  return canvas ? [{ video, canvas }] : [];
202
203
  });
203
204
  });
205
+ const preparedCanvases = pairs.map(({ canvas }) => canvas);
204
206
  pairs.forEach(({ video, canvas }) => {
205
207
  canvas.className = video.className;
206
208
  canvas.style.cssText = video.style.cssText;
@@ -225,13 +227,11 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
225
227
  destinationHeight,
226
228
  objectFit
227
229
  );
228
- const stagingCanvas = canvas.ownerDocument.createElement("canvas");
229
- stagingCanvas.width = destinationWidth;
230
- stagingCanvas.height = destinationHeight;
231
- const stagingContext = stagingCanvas.getContext("2d");
232
230
  const context = canvas.getContext("2d");
233
- if (!stagingContext || !context) return;
234
- stagingContext.drawImage(
231
+ if (!context) return;
232
+ canvas.width = destinationWidth;
233
+ canvas.height = destinationHeight;
234
+ context.drawImage(
235
235
  video,
236
236
  frame.sx,
237
237
  frame.sy,
@@ -242,9 +242,6 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
242
242
  frame.dw,
243
243
  frame.dh
244
244
  );
245
- canvas.width = destinationWidth;
246
- canvas.height = destinationHeight;
247
- context.drawImage(stagingCanvas, 0, 0);
248
245
  const foreignObject = canvas.closest("foreignObject");
249
246
  const svg = canvas.closest("svg");
250
247
  if (foreignObject && svg) {
@@ -275,6 +272,152 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
275
272
  } catch {
276
273
  }
277
274
  });
275
+ return preparedCanvases;
276
+ }
277
+ function parseAbsoluteSvgLength(value) {
278
+ if (!value) return 0;
279
+ const match = /^\s*(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
280
+ return match ? Number.parseFloat(match[1]) : 0;
281
+ }
282
+ function svgViewBoxSize(svg) {
283
+ const values = (svg.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/).map(Number);
284
+ if (values.length === 4 && values.every(Number.isFinite)) {
285
+ return { width: Math.max(0, values[2]), height: Math.max(0, values[3]) };
286
+ }
287
+ return { width: 0, height: 0 };
288
+ }
289
+ function captureSvgRasterSize(clonedSvg, originalSvg) {
290
+ const clonedRect = clonedSvg.getBoundingClientRect();
291
+ const originalRect = originalSvg?.getBoundingClientRect();
292
+ const clonedViewBox = svgViewBoxSize(clonedSvg);
293
+ const originalViewBox = originalSvg ? svgViewBoxSize(originalSvg) : { width: 0, height: 0 };
294
+ const width = clonedRect.width || originalRect?.width || parseAbsoluteSvgLength(clonedSvg.getAttribute("width")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("width")) : 0) || clonedViewBox.width || originalViewBox.width;
295
+ const height = clonedRect.height || originalRect?.height || parseAbsoluteSvgLength(clonedSvg.getAttribute("height")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("height")) : 0) || clonedViewBox.height || originalViewBox.height;
296
+ return {
297
+ width: Math.max(1, Math.round(width)),
298
+ height: Math.max(1, Math.round(height))
299
+ };
300
+ }
301
+ function copyCaptureSvgPresentation(svg, canvas) {
302
+ for (const attribute of Array.from(svg.attributes)) {
303
+ if (attribute.name === "id" || attribute.name === "class" || attribute.name === "style" || attribute.name.startsWith("data-") || attribute.name.startsWith("aria-")) {
304
+ canvas.setAttribute(attribute.name, attribute.value);
305
+ }
306
+ }
307
+ canvas.dataset.svgCaptureClone = "true";
308
+ }
309
+ function captureImageMimeType(source, blob) {
310
+ if (blob.type) return blob.type;
311
+ const path = source.split(/[?#]/, 1)[0];
312
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
313
+ return MIME_MAP[ext] ?? "application/octet-stream";
314
+ }
315
+ function blobToDataUrl(blob, source) {
316
+ const typedBlob = blob.type ? blob : blob.slice(0, blob.size, captureImageMimeType(source, blob));
317
+ return new Promise((resolve, reject) => {
318
+ const reader = new FileReader();
319
+ reader.addEventListener(
320
+ "load",
321
+ () => {
322
+ if (typeof reader.result === "string") resolve(reader.result);
323
+ else reject(new Error(`Image could not be embedded for SVG capture: ${source}`));
324
+ },
325
+ { once: true }
326
+ );
327
+ reader.addEventListener(
328
+ "error",
329
+ () => reject(reader.error ?? new Error(`Image could not be read for SVG capture: ${source}`)),
330
+ { once: true }
331
+ );
332
+ reader.readAsDataURL(typedBlob);
333
+ });
334
+ }
335
+ function resolveCaptureImageDataUrl(source, cache) {
336
+ const cached = cache.get(source);
337
+ if (cached) return cached;
338
+ const pending = fetch(source).then(async (response) => {
339
+ if (!response.ok) return null;
340
+ return blobToDataUrl(await response.blob(), source);
341
+ }).catch(() => null);
342
+ cache.set(source, pending);
343
+ return pending;
344
+ }
345
+ function captureImageReference(element) {
346
+ if (element.localName === "img") {
347
+ const source2 = element.getAttribute("src") ?? "";
348
+ return source2 ? {
349
+ source: source2,
350
+ replace: (dataUrl) => element.setAttribute("src", dataUrl)
351
+ } : null;
352
+ }
353
+ const xlinkNamespace = "http://www.w3.org/1999/xlink";
354
+ const source = element.getAttribute("href") ?? element.getAttributeNS(xlinkNamespace, "href") ?? "";
355
+ return source ? {
356
+ source,
357
+ replace: (dataUrl) => {
358
+ if (element.hasAttribute("href")) element.setAttribute("href", dataUrl);
359
+ if (element.hasAttributeNS(xlinkNamespace, "href")) {
360
+ element.setAttributeNS(xlinkNamespace, "href", dataUrl);
361
+ }
362
+ }
363
+ } : null;
364
+ }
365
+ async function embedCaptureSvgImages(svg, cache) {
366
+ const references = Array.from(svg.querySelectorAll("image, img")).map(captureImageReference).filter((reference) => reference !== null);
367
+ for (const reference of references) {
368
+ if (/^data:/i.test(reference.source) || reference.source.startsWith("#")) continue;
369
+ const dataUrl = await resolveCaptureImageDataUrl(reference.source, cache);
370
+ if (!dataUrl) return false;
371
+ reference.replace(dataUrl);
372
+ }
373
+ return true;
374
+ }
375
+ async function rasterizeCaptureSvgClones(originalRoot, clonedRoot, transientCanvases = [], imageDataUrls = /* @__PURE__ */ new Map()) {
376
+ if (typeof createImageBitmap !== "function") return transientCanvases;
377
+ const originalSvgs = Array.from(
378
+ originalRoot.querySelectorAll(CAPTURE_SVG_SELECTOR)
379
+ );
380
+ const clonedSvgs = Array.from(clonedRoot.querySelectorAll(CAPTURE_SVG_SELECTOR));
381
+ for (const [index, svg] of clonedSvgs.entries()) {
382
+ const { width, height } = captureSvgRasterSize(svg, originalSvgs[index]);
383
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
384
+ svg.setAttribute("width", String(width));
385
+ svg.setAttribute("height", String(height));
386
+ let bitmap = null;
387
+ let replacement = null;
388
+ try {
389
+ if (!await embedCaptureSvgImages(svg, imageDataUrls)) continue;
390
+ const serializedSvg = new XMLSerializer().serializeToString(svg);
391
+ bitmap = await createImageBitmap(new Blob([serializedSvg], { type: "image/svg+xml" }));
392
+ replacement = svg.ownerDocument.createElement("canvas");
393
+ replacement.width = width;
394
+ replacement.height = height;
395
+ copyCaptureSvgPresentation(svg, replacement);
396
+ const context = replacement.getContext("2d");
397
+ if (!context) {
398
+ replacement.width = 0;
399
+ replacement.height = 0;
400
+ continue;
401
+ }
402
+ context.drawImage(bitmap, 0, 0, width, height);
403
+ svg.replaceWith(replacement);
404
+ transientCanvases.push(replacement);
405
+ } catch {
406
+ if (replacement && !replacement.isConnected) {
407
+ replacement.width = 0;
408
+ replacement.height = 0;
409
+ }
410
+ } finally {
411
+ bitmap?.close();
412
+ }
413
+ }
414
+ return transientCanvases;
415
+ }
416
+ function releaseCaptureCloneCanvases(canvases) {
417
+ canvases.forEach((canvas) => {
418
+ canvas.width = 0;
419
+ canvas.height = 0;
420
+ });
278
421
  }
279
422
  function getFrameVisualStateKey(captureRoot, timelineTime) {
280
423
  const markup = captureRoot.innerHTML;
@@ -334,6 +477,7 @@ function useFrameCapture() {
334
477
  const lastVisualStateKeyRef = useRef(null);
335
478
  const hasCapturedFrameRef = useRef(false);
336
479
  const decodedImagesRef = useRef(/* @__PURE__ */ new WeakSet());
480
+ const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
337
481
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
338
482
  const init = useCallback(
339
483
  async (doc, renderOptions, captionMode) => {
@@ -350,6 +494,7 @@ function useFrameCapture() {
350
494
  lastVisualStateKeyRef.current = null;
351
495
  hasCapturedFrameRef.current = false;
352
496
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
497
+ captureImageDataUrlsRef.current.clear();
353
498
  await new Promise((resolve) => {
354
499
  setTimeout(() => {
355
500
  if (oldRoot) oldRoot.unmount();
@@ -376,6 +521,7 @@ function useFrameCapture() {
376
521
  lastVisualStateKeyRef.current = null;
377
522
  hasCapturedFrameRef.current = false;
378
523
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
524
+ captureImageDataUrlsRef.current.clear();
379
525
  const container = document.createElement("div");
380
526
  container.style.cssText = `position:fixed;left:0;top:0;width:${width}px;height:${height}px;opacity:0;pointer-events:none;z-index:-1;overflow:hidden;`;
381
527
  document.body.appendChild(container);
@@ -489,23 +635,35 @@ function useFrameCapture() {
489
635
  if (!captureContext) throw new Error("Could not create the frame capture canvas context");
490
636
  captureContext.setTransform(1, 0, 0, 1, 0, 0);
491
637
  captureContext.clearRect(0, 0, width, height);
492
- const canvas = await html2canvas(root, {
493
- canvas: captureCanvas,
494
- width,
495
- height,
496
- scale: 1,
497
- useCORS: true,
498
- allowTaint: true,
499
- backgroundColor: "#000000",
500
- logging: false,
501
- onclone: (_clonedDocument, clonedRoot) => {
502
- prepareScheduledVideoClones(root, clonedRoot);
503
- },
504
- // html2canvas starts cloning at documentElement. Do not clone the rest
505
- // of the editor/site UI on every frame; only the capture root, its
506
- // ancestors, descendants, and document styles can affect this render.
507
- ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
508
- });
638
+ const transientCloneCanvases = [];
639
+ let canvas;
640
+ try {
641
+ canvas = await html2canvas(root, {
642
+ canvas: captureCanvas,
643
+ width,
644
+ height,
645
+ scale: 1,
646
+ useCORS: true,
647
+ allowTaint: true,
648
+ backgroundColor: "#000000",
649
+ logging: false,
650
+ onclone: async (_clonedDocument, clonedRoot) => {
651
+ transientCloneCanvases.push(...prepareScheduledVideoClones(root, clonedRoot));
652
+ await rasterizeCaptureSvgClones(
653
+ root,
654
+ clonedRoot,
655
+ transientCloneCanvases,
656
+ captureImageDataUrlsRef.current
657
+ );
658
+ },
659
+ // html2canvas starts cloning at documentElement. Do not clone the rest
660
+ // of the editor/site UI on every frame; only the capture root, its
661
+ // ancestors, descendants, and document styles can affect this render.
662
+ ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
663
+ });
664
+ } finally {
665
+ releaseCaptureCloneCanvases(transientCloneCanvases);
666
+ }
509
667
  hasCapturedFrameRef.current = true;
510
668
  lastVisualStateKeyRef.current = visualStateKey;
511
669
  return canvas;
@@ -538,6 +696,7 @@ function useFrameCapture() {
538
696
  lastVisualStateKeyRef.current = null;
539
697
  hasCapturedFrameRef.current = false;
540
698
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
699
+ captureImageDataUrlsRef.current.clear();
541
700
  renderAPIRef.current = null;
542
701
  }, []);
543
702
  return useMemo(
@@ -939,6 +1098,7 @@ function useVideoExport(options = {}) {
939
1098
  const [outputFormat, setOutputFormat] = useState("mp4");
940
1099
  const [backend, setBackend] = useState(null);
941
1100
  const [downloadUrl, setDownloadUrl] = useState(null);
1101
+ const [outputBlob, setOutputBlob] = useState(null);
942
1102
  const [fileSize, setFileSize] = useState(0);
943
1103
  const [audioIncluded, setAudioIncluded] = useState(false);
944
1104
  const [audioSkippedReason, setAudioSkippedReason] = useState(null);
@@ -988,6 +1148,7 @@ function useVideoExport(options = {}) {
988
1148
  setOutputFormat("mp4");
989
1149
  setBackend(null);
990
1150
  setDownloadUrl(null);
1151
+ setOutputBlob(null);
991
1152
  setFileSize(0);
992
1153
  setAudioIncluded(false);
993
1154
  setAudioSkippedReason(null);
@@ -1019,6 +1180,7 @@ function useVideoExport(options = {}) {
1019
1180
  downloadUrlRef.current = null;
1020
1181
  }
1021
1182
  setDownloadUrl(null);
1183
+ setOutputBlob(null);
1022
1184
  setFileSize(0);
1023
1185
  setAudioIncluded(false);
1024
1186
  setAudioSkippedReason(null);
@@ -1344,6 +1506,7 @@ function useVideoExport(options = {}) {
1344
1506
  const url = URL.createObjectURL(blob);
1345
1507
  downloadUrlRef.current = url;
1346
1508
  setDownloadUrl(url);
1509
+ setOutputBlob(blob);
1347
1510
  setFileSize(finalBytes.byteLength);
1348
1511
  setAudioIncluded(audioIncludedLocal);
1349
1512
  setAudioSkippedReason(
@@ -1383,6 +1546,7 @@ function useVideoExport(options = {}) {
1383
1546
  outputFormat,
1384
1547
  backend,
1385
1548
  downloadUrl,
1549
+ outputBlob,
1386
1550
  fileSize,
1387
1551
  audioIncluded,
1388
1552
  audioSkippedReason,
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { Doc, MediaProvider } from '@bendyline/squisq/schemas';
3
- import { a as VideoExportConfig } from '../useVideoExport-DKpdXZ0o.js';
3
+ import { a as VideoExportConfig, e as VideoOutputFormat } from '../useVideoExport-raCbwbwb.js';
4
4
  import '@bendyline/squisq/markdown';
5
5
  import '@bendyline/squisq-video';
6
6
  import '@bendyline/squisq-react';
@@ -33,6 +33,10 @@ interface VideoExportModalProps {
33
33
  colorScheme?: 'light' | 'dark';
34
34
  /** Optional host overrides for dialog surfaces, controls, status, and accent colors. */
35
35
  uiPalette?: Partial<VideoExportPalette>;
36
+ /** Optional host save flow. Return false when the user cancels a picker. */
37
+ saveOutput?: (blob: Blob, filename: string) => boolean | void | Promise<boolean | void>;
38
+ /** Host-aware label for the completed export action. */
39
+ saveActionLabel?: (format: VideoOutputFormat) => string;
36
40
  /** Called when the modal should close */
37
41
  onClose: () => void;
38
42
  }
@@ -52,7 +56,7 @@ interface VideoExportPalette {
52
56
  success: string;
53
57
  danger: string;
54
58
  }
55
- declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
59
+ declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, saveOutput, saveActionLabel, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
56
60
 
57
61
  interface VideoExportButtonProps {
58
62
  /** The document to export */
@@ -79,6 +83,10 @@ interface VideoExportButtonProps {
79
83
  colorScheme?: 'light' | 'dark';
80
84
  /** Optional host palette overrides forwarded to the portaled modal. */
81
85
  uiPalette?: Partial<VideoExportPalette>;
86
+ /** Optional host save flow forwarded to the portaled modal. */
87
+ saveOutput?: VideoExportModalProps['saveOutput'];
88
+ /** Host-aware completed-export action label forwarded to the modal. */
89
+ saveActionLabel?: VideoExportModalProps['saveActionLabel'];
82
90
  /** Button label (defaults to "Export Video", or "Export GIF" for a GIF default config) */
83
91
  label?: string;
84
92
  /** Additional inline styles for the button */
@@ -86,6 +94,6 @@ interface VideoExportButtonProps {
86
94
  /** Whether the button is disabled */
87
95
  disabled?: boolean;
88
96
  }
89
- declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
97
+ declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, saveOutput, saveActionLabel, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
90
98
 
91
99
  export { VideoExportButton, type VideoExportButtonProps, VideoExportModal, type VideoExportModalProps, type VideoExportPalette };
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "../chunk-SO656KT7.js";
5
- import "../chunk-NX34XGLL.js";
4
+ } from "../chunk-ERG6OLLO.js";
5
+ import "../chunk-GLOLS2CQ.js";
6
6
  import "../chunk-2XACUF6E.js";
7
7
  import "../chunk-MEPETH5V.js";
8
8
  export {
@@ -1,4 +1,4 @@
1
- export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from '../useVideoExport-DKpdXZ0o.js';
1
+ export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from '../useVideoExport-raCbwbwb.js';
2
2
  import { Doc } from '@bendyline/squisq/schemas';
3
3
  import { RenderHtmlOptions } from '@bendyline/squisq-video';
4
4
  import { CaptionMode } from '@bendyline/squisq-react';
@@ -3,7 +3,7 @@ import {
3
3
  resolveVideoExportCover,
4
4
  useFrameCapture,
5
5
  useVideoExport
6
- } from "../chunk-NX34XGLL.js";
6
+ } from "../chunk-GLOLS2CQ.js";
7
7
  import "../chunk-2XACUF6E.js";
8
8
  import "../chunk-MEPETH5V.js";
9
9
  export {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { VideoExportButton, VideoExportButtonProps, VideoExportModal, VideoExportModalProps, VideoExportPalette } from './components/index.js';
2
- export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from './useVideoExport-DKpdXZ0o.js';
2
+ export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from './useVideoExport-raCbwbwb.js';
3
3
  export { FrameCaptureHandle, FrameCaptureOptions, FrameCaptureRenderOptions, useFrameCapture } from './hooks/index.js';
4
4
  export { E as EncoderConfig, a as EncoderFrameSource, M as MainThreadEncoder, c as createEncoder, s as supportsWebCodecs, b as supportsWebCodecsH264 } from './mainThreadEncoder-BgcFyYvO.js';
5
5
  export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "./chunk-SO656KT7.js";
4
+ } from "./chunk-ERG6OLLO.js";
5
5
  import {
6
6
  DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
7
7
  resolveVideoExportCover,
8
8
  useFrameCapture,
9
9
  useVideoExport
10
- } from "./chunk-NX34XGLL.js";
10
+ } from "./chunk-GLOLS2CQ.js";
11
11
  import {
12
12
  createEncoder,
13
13
  supportsWebCodecs,
@@ -108,6 +108,8 @@ interface VideoExportResult {
108
108
  backend: 'webcodecs' | 'ffmpeg-wasm' | null;
109
109
  /** Blob download URL (populated when state === 'complete') */
110
110
  downloadUrl: string | null;
111
+ /** Completed output Blob for host-provided save flows. */
112
+ outputBlob: Blob | null;
111
113
  /** File size in bytes (populated when state === 'complete') */
112
114
  fileSize: number;
113
115
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-video-react",
3
- "version": "2.2.9",
3
+ "version": "2.2.10",
4
4
  "description": "React components for browser-based MP4 and animated-GIF export of Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -65,9 +65,9 @@
65
65
  "react-dom": "^18.0.0 || ^19.0.0"
66
66
  },
67
67
  "dependencies": {
68
- "@bendyline/squisq": "2.4.3",
69
- "@bendyline/squisq-video": "2.2.7",
70
- "@bendyline/squisq-react": "2.4.5",
68
+ "@bendyline/squisq": "2.4.4",
69
+ "@bendyline/squisq-video": "2.2.8",
70
+ "@bendyline/squisq-react": "2.4.6",
71
71
  "@ffmpeg/core": "0.12.9",
72
72
  "@ffmpeg/ffmpeg": "0.12.15",
73
73
  "@ffmpeg/util": "0.12.2",