@bendyline/squisq-video-react 2.2.8 → 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 +5 -1
- package/dist/{chunk-SO656KT7.js → chunk-ERG6OLLO.js} +46 -15
- package/dist/{chunk-NX34XGLL.js → chunk-GLOLS2CQ.js} +190 -26
- package/dist/components/index.d.ts +11 -3
- package/dist/components/index.js +2 -2
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/{useVideoExport-DKpdXZ0o.d.ts → useVideoExport-raCbwbwb.d.ts} +2 -0
- package/package.json +4 -4
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-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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__ */
|
|
595
|
-
"
|
|
596
|
-
|
|
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 (!
|
|
234
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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-
|
|
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 };
|
package/dist/components/index.js
CHANGED
package/dist/hooks/index.d.ts
CHANGED
|
@@ -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-
|
|
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';
|
package/dist/hooks/index.js
CHANGED
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
69
|
-
"@bendyline/squisq-video": "2.2.
|
|
70
|
-
"@bendyline/squisq-react": "2.4.
|
|
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",
|