@bendyline/squisq-video-react 2.2.11 → 2.3.1
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 +14 -4
- package/dist/{chunk-KJ5RKG67.js → chunk-32QCPFXE.js} +4 -2
- package/dist/{chunk-I4SXMCDF.js → chunk-5MFQMJ5Z.js} +90 -11
- package/dist/{chunk-KW5BBYKP.js → chunk-CRXJOZSH.js} +1 -1
- package/dist/chunk-NTRCMTCF.js +401 -0
- package/dist/chunk-U3RSDWQL.js +978 -0
- package/dist/{chunk-F2XUI32B.js → chunk-YTEDBL6F.js} +69 -945
- package/dist/components/index.d.ts +4 -2
- package/dist/components/index.js +14 -5
- package/dist/cover-image/index.d.ts +23 -0
- package/dist/cover-image/index.js +11 -0
- package/dist/encoder/index.d.ts +1 -1
- package/dist/encoder/index.js +2 -2
- package/dist/hooks/index.d.ts +8 -15
- package/dist/hooks/index.js +8 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +18 -6
- package/dist/{mainThreadEncoder-CiVsL1Bf.d.ts → mainThreadEncoder-BVCLjlfj.d.ts} +7 -0
- package/dist/{useVideoExport-CM5XiM6Z.d.ts → useVideoExport-B2eVCvTX.d.ts} +29 -5
- package/dist/workers/encode.worker.js +1 -1
- package/package.json +8 -4
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
EXPORT_AUDIO_CHANNELS,
|
|
3
|
-
EXPORT_AUDIO_SAMPLE_RATE,
|
|
4
|
-
audioBufferToWav,
|
|
5
|
-
createEncoder,
|
|
6
|
-
encodeAacTrack,
|
|
7
|
-
muxAudioWithFfmpegWasm,
|
|
8
|
-
renderAudioTimeline,
|
|
9
|
-
selectAudioTier,
|
|
10
|
-
supportsWebCodecs,
|
|
11
|
-
supportsWebCodecsAac,
|
|
12
|
-
supportsWebCodecsH264
|
|
13
|
-
} from "./chunk-KJ5RKG67.js";
|
|
14
|
-
|
|
15
1
|
// src/hooks/useFrameCapture.ts
|
|
16
2
|
import { createElement } from "react";
|
|
17
3
|
import { createRoot } from "react-dom/client";
|
|
@@ -131,16 +117,21 @@ function waitForCaptureVideoState(video, description, isReady, update) {
|
|
|
131
117
|
}
|
|
132
118
|
});
|
|
133
119
|
}
|
|
120
|
+
function captureVideoNeedsPriming(video, primedVideos) {
|
|
121
|
+
if (video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth <= 0 && video.videoHeight <= 0) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return !primedVideos.has(video) || !Number.isFinite(video.duration);
|
|
125
|
+
}
|
|
134
126
|
async function primeIndeterminateCaptureVideos(captureRoot, primedVideos = /* @__PURE__ */ new WeakSet()) {
|
|
135
127
|
const videos = Array.from(captureRoot.querySelectorAll("video")).filter(
|
|
136
|
-
(video) =>
|
|
128
|
+
(video) => captureVideoNeedsPriming(video, primedVideos)
|
|
137
129
|
);
|
|
138
130
|
let primedCount = 0;
|
|
139
131
|
await Promise.all(
|
|
140
132
|
videos.map(async (video) => {
|
|
141
133
|
const source = video.currentSrc || video.src;
|
|
142
134
|
if (!source) {
|
|
143
|
-
primedVideos.add(video);
|
|
144
135
|
return;
|
|
145
136
|
}
|
|
146
137
|
await waitForCaptureVideoState(
|
|
@@ -590,11 +581,31 @@ function scheduledVideoIsVisual(video) {
|
|
|
590
581
|
function scheduledVideoPresentation(video) {
|
|
591
582
|
return video.closest(SCHEDULED_MEDIA_SELECTOR)?.dataset.presentation;
|
|
592
583
|
}
|
|
593
|
-
function
|
|
594
|
-
const
|
|
584
|
+
function planScheduledVideoComposite(captureRoot) {
|
|
585
|
+
const activeVideos = Array.from(
|
|
595
586
|
captureRoot.querySelectorAll(SCHEDULED_VIDEO_SELECTOR)
|
|
596
587
|
).filter(scheduledVideoIsVisual);
|
|
597
|
-
|
|
588
|
+
if (activeVideos.length === 0) return null;
|
|
589
|
+
const underlays = [];
|
|
590
|
+
const overlays = [];
|
|
591
|
+
for (const video of activeVideos) {
|
|
592
|
+
const presentation = scheduledVideoPresentation(video);
|
|
593
|
+
if (presentation === "background") underlays.push(video);
|
|
594
|
+
else if (presentation === "picture-in-picture") overlays.push(video);
|
|
595
|
+
else return null;
|
|
596
|
+
}
|
|
597
|
+
return { underlays, overlays };
|
|
598
|
+
}
|
|
599
|
+
function clearScheduledUnderlayBackdrops(clonedRoot) {
|
|
600
|
+
const groups = clonedRoot.querySelectorAll(
|
|
601
|
+
`${SCHEDULED_MEDIA_SELECTOR}[data-presentation="background"]`
|
|
602
|
+
);
|
|
603
|
+
for (const group of Array.from(groups)) {
|
|
604
|
+
for (let element = group.parentElement; element; element = element === clonedRoot ? null : element.parentElement) {
|
|
605
|
+
element.style.backgroundColor = "transparent";
|
|
606
|
+
element.style.backgroundImage = "none";
|
|
607
|
+
}
|
|
608
|
+
}
|
|
598
609
|
}
|
|
599
610
|
function cssPixelValue(value) {
|
|
600
611
|
const parsed = Number.parseFloat(value);
|
|
@@ -627,18 +638,13 @@ function addRoundedRect(context, x, y, width, height, radius) {
|
|
|
627
638
|
context.rect(x, y, width, height);
|
|
628
639
|
}
|
|
629
640
|
}
|
|
630
|
-
function
|
|
641
|
+
function drawScheduledVideosOnto(destination, captureRoot, videos) {
|
|
631
642
|
const context = destination.getContext("2d");
|
|
632
|
-
if (!context) throw new Error("Could not create the
|
|
643
|
+
if (!context) throw new Error("Could not create the scheduled-video compositor canvas context");
|
|
633
644
|
const rootRect = captureRoot.getBoundingClientRect();
|
|
634
645
|
if (rootRect.width <= 0 || rootRect.height <= 0) return 0;
|
|
635
646
|
const scaleX = destination.width / rootRect.width;
|
|
636
647
|
const scaleY = destination.height / rootRect.height;
|
|
637
|
-
const videos = Array.from(
|
|
638
|
-
captureRoot.querySelectorAll(SCHEDULED_VIDEO_SELECTOR)
|
|
639
|
-
).filter(
|
|
640
|
-
(video) => scheduledVideoIsVisual(video) && scheduledVideoPresentation(video) === "picture-in-picture"
|
|
641
|
-
);
|
|
642
648
|
for (const video of videos) {
|
|
643
649
|
const rect = video.getBoundingClientRect();
|
|
644
650
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
@@ -757,6 +763,7 @@ function useFrameCapture() {
|
|
|
757
763
|
const rootRef = useRef(null);
|
|
758
764
|
const renderAPIRef = useRef(null);
|
|
759
765
|
const mediaProviderRef = useRef(null);
|
|
766
|
+
const ownsMediaProviderRef = useRef(false);
|
|
760
767
|
const captureCanvasRef = useRef(null);
|
|
761
768
|
const captureBaseCanvasRef = useRef(null);
|
|
762
769
|
const lastVisualStateKeyRef = useRef(null);
|
|
@@ -772,12 +779,14 @@ function useFrameCapture() {
|
|
|
772
779
|
const oldRoot = rootRef.current;
|
|
773
780
|
const oldContainer = containerRef.current;
|
|
774
781
|
const oldMediaProvider = mediaProviderRef.current;
|
|
782
|
+
const oldOwnsMediaProvider = ownsMediaProviderRef.current;
|
|
775
783
|
const oldCaptureCanvas = captureCanvasRef.current;
|
|
776
784
|
const oldCaptureBaseCanvas = captureBaseCanvasRef.current;
|
|
777
785
|
rootRef.current = null;
|
|
778
786
|
containerRef.current = null;
|
|
779
787
|
renderAPIRef.current = null;
|
|
780
788
|
mediaProviderRef.current = null;
|
|
789
|
+
ownsMediaProviderRef.current = false;
|
|
781
790
|
captureCanvasRef.current = null;
|
|
782
791
|
captureBaseCanvasRef.current = null;
|
|
783
792
|
lastVisualStateKeyRef.current = null;
|
|
@@ -791,7 +800,7 @@ function useFrameCapture() {
|
|
|
791
800
|
setTimeout(() => {
|
|
792
801
|
if (oldRoot) oldRoot.unmount();
|
|
793
802
|
if (oldContainer) oldContainer.remove();
|
|
794
|
-
oldMediaProvider?.dispose();
|
|
803
|
+
if (oldOwnsMediaProvider) oldMediaProvider?.dispose();
|
|
795
804
|
if (oldCaptureCanvas) {
|
|
796
805
|
oldCaptureCanvas.width = 0;
|
|
797
806
|
oldCaptureCanvas.height = 0;
|
|
@@ -835,8 +844,9 @@ function useFrameCapture() {
|
|
|
835
844
|
renderRoot.id = "squisq-capture-root";
|
|
836
845
|
renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
|
|
837
846
|
container.appendChild(renderRoot);
|
|
838
|
-
const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : null;
|
|
847
|
+
const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : renderOptions.mediaProvider ?? null;
|
|
839
848
|
mediaProviderRef.current = mediaProvider;
|
|
849
|
+
ownsMediaProviderRef.current = !!renderOptions.images;
|
|
840
850
|
const root = createRoot(renderRoot);
|
|
841
851
|
rootRef.current = root;
|
|
842
852
|
const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
|
|
@@ -859,6 +869,7 @@ function useFrameCapture() {
|
|
|
859
869
|
pipShape: renderOptions.pipShape,
|
|
860
870
|
pipPosition: renderOptions.pipPosition,
|
|
861
871
|
showCoverSlide: renderOptions.showCoverSlide,
|
|
872
|
+
coverSlideTemplate: renderOptions.coverSlideTemplate,
|
|
862
873
|
captionsEnabled,
|
|
863
874
|
captionStyle,
|
|
864
875
|
onRenderAPIReady: (api) => {
|
|
@@ -922,11 +933,19 @@ function useFrameCapture() {
|
|
|
922
933
|
throw new Error("Frame capture not initialized \u2014 call init() first");
|
|
923
934
|
}
|
|
924
935
|
const { width, height } = dimensionsRef.current;
|
|
925
|
-
await api.seekTo(time);
|
|
926
936
|
const root = container.querySelector("#squisq-capture-root");
|
|
927
937
|
if (!root) {
|
|
928
938
|
throw new Error("Capture root element not found");
|
|
929
939
|
}
|
|
940
|
+
await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current);
|
|
941
|
+
try {
|
|
942
|
+
await api.seekTo(time);
|
|
943
|
+
} catch (seekError) {
|
|
944
|
+
if (!await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
|
|
945
|
+
throw seekError;
|
|
946
|
+
}
|
|
947
|
+
await api.seekTo(time);
|
|
948
|
+
}
|
|
930
949
|
if (await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
|
|
931
950
|
await api.seekTo(time);
|
|
932
951
|
}
|
|
@@ -937,13 +956,15 @@ function useFrameCapture() {
|
|
|
937
956
|
);
|
|
938
957
|
}
|
|
939
958
|
await waitForCaptureAssets(root, decodedImagesRef.current);
|
|
940
|
-
const
|
|
941
|
-
const
|
|
942
|
-
|
|
959
|
+
const compositePlan = planScheduledVideoComposite(root);
|
|
960
|
+
const hasUnderlays = compositePlan !== null && compositePlan.underlays.length > 0;
|
|
961
|
+
const rasterMode = compositePlan ? hasUnderlays ? "base-underlay" : "base" : "full";
|
|
962
|
+
const visualStateKey = options.reuseIfUnchanged ? `${rasterMode}:${getFrameVisualStateKey(root, time, {
|
|
963
|
+
ignoreScheduledVideoFrames: compositePlan !== null
|
|
943
964
|
})}` : null;
|
|
944
965
|
const shouldRasterize = visualStateKey === null || !hasCapturedFrameRef.current || lastVisualStateKeyRef.current !== visualStateKey;
|
|
945
|
-
if (!shouldRasterize && !
|
|
946
|
-
const rasterCanvas =
|
|
966
|
+
if (!shouldRasterize && !compositePlan) return captureCanvas;
|
|
967
|
+
const rasterCanvas = compositePlan ? captureBaseCanvas : captureCanvas;
|
|
947
968
|
const captureContext = rasterCanvas.getContext("2d");
|
|
948
969
|
if (!captureContext) throw new Error("Could not create the frame capture canvas context");
|
|
949
970
|
if (shouldRasterize) {
|
|
@@ -960,10 +981,14 @@ function useFrameCapture() {
|
|
|
960
981
|
scale: 1,
|
|
961
982
|
useCORS: true,
|
|
962
983
|
allowTaint: true,
|
|
963
|
-
|
|
984
|
+
// An underlay base must stay transparent so the background video
|
|
985
|
+
// composited beneath it shows through everything the player does
|
|
986
|
+
// not paint. The compositor restores the opaque black backdrop.
|
|
987
|
+
backgroundColor: hasUnderlays ? null : "#000000",
|
|
964
988
|
logging: false,
|
|
965
989
|
onclone: async (_clonedDocument, clonedRoot) => {
|
|
966
|
-
if (
|
|
990
|
+
if (compositePlan) {
|
|
991
|
+
if (hasUnderlays) clearScheduledUnderlayBackdrops(clonedRoot);
|
|
967
992
|
clonedRoot.querySelectorAll(SCHEDULED_MEDIA_SELECTOR).forEach((element) => element.remove());
|
|
968
993
|
}
|
|
969
994
|
transientCloneCanvases.push(...prepareScheduledVideoClones(root, clonedRoot));
|
|
@@ -986,13 +1011,16 @@ function useFrameCapture() {
|
|
|
986
1011
|
hasCapturedFrameRef.current = true;
|
|
987
1012
|
lastVisualStateKeyRef.current = visualStateKey;
|
|
988
1013
|
}
|
|
989
|
-
if (
|
|
1014
|
+
if (compositePlan) {
|
|
990
1015
|
const outputContext = captureCanvas.getContext("2d");
|
|
991
1016
|
if (!outputContext) throw new Error("Could not create the frame output canvas context");
|
|
992
1017
|
outputContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
993
1018
|
outputContext.clearRect(0, 0, width, height);
|
|
1019
|
+
outputContext.fillStyle = "#000000";
|
|
1020
|
+
outputContext.fillRect(0, 0, width, height);
|
|
1021
|
+
drawScheduledVideosOnto(captureCanvas, root, compositePlan.underlays);
|
|
994
1022
|
outputContext.drawImage(captureBaseCanvas, 0, 0);
|
|
995
|
-
|
|
1023
|
+
drawScheduledVideosOnto(captureCanvas, root, compositePlan.overlays);
|
|
996
1024
|
}
|
|
997
1025
|
return captureCanvas;
|
|
998
1026
|
},
|
|
@@ -1014,8 +1042,9 @@ function useFrameCapture() {
|
|
|
1014
1042
|
containerRef.current.remove();
|
|
1015
1043
|
containerRef.current = null;
|
|
1016
1044
|
}
|
|
1017
|
-
mediaProviderRef.current?.dispose();
|
|
1045
|
+
if (ownsMediaProviderRef.current) mediaProviderRef.current?.dispose();
|
|
1018
1046
|
mediaProviderRef.current = null;
|
|
1047
|
+
ownsMediaProviderRef.current = false;
|
|
1019
1048
|
if (captureCanvasRef.current) {
|
|
1020
1049
|
captureCanvasRef.current.width = 0;
|
|
1021
1050
|
captureCanvasRef.current.height = 0;
|
|
@@ -1041,911 +1070,6 @@ function useFrameCapture() {
|
|
|
1041
1070
|
);
|
|
1042
1071
|
}
|
|
1043
1072
|
|
|
1044
|
-
// src/hooks/useVideoExport.ts
|
|
1045
|
-
import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } from "react";
|
|
1046
|
-
import {
|
|
1047
|
-
DEFAULT_INTERACTIVE_RESOURCE_POLICY,
|
|
1048
|
-
fetchResourceBytes
|
|
1049
|
-
} from "@bendyline/squisq/markdown";
|
|
1050
|
-
import {
|
|
1051
|
-
resolveDimensions,
|
|
1052
|
-
computeAudioTimeline,
|
|
1053
|
-
resolveFfmpegWasmLoad as resolveFfmpegWasmLoad2,
|
|
1054
|
-
QUALITY_PRESETS
|
|
1055
|
-
} from "@bendyline/squisq-video";
|
|
1056
|
-
|
|
1057
|
-
// src/workerEncoder.ts
|
|
1058
|
-
import { validateVideoExportOptions } from "@bendyline/squisq-video";
|
|
1059
|
-
function createWorkerEncoder(config) {
|
|
1060
|
-
validateVideoExportOptions(config);
|
|
1061
|
-
const worker = new Worker(new URL("./workers/encode.worker.js", import.meta.url), {
|
|
1062
|
-
type: "module"
|
|
1063
|
-
});
|
|
1064
|
-
let state = "open";
|
|
1065
|
-
let fatalError = null;
|
|
1066
|
-
let finalizeResolve = null;
|
|
1067
|
-
let finalizeReject = null;
|
|
1068
|
-
let readyResolve = null;
|
|
1069
|
-
let readyReject = null;
|
|
1070
|
-
let readySettled = false;
|
|
1071
|
-
const frameWaiters = /* @__PURE__ */ new Map();
|
|
1072
|
-
const ready = new Promise((resolve, reject) => {
|
|
1073
|
-
readyResolve = resolve;
|
|
1074
|
-
readyReject = reject;
|
|
1075
|
-
});
|
|
1076
|
-
const frameDuration = 1e6 / config.fps;
|
|
1077
|
-
function post(msg, transfer) {
|
|
1078
|
-
worker.postMessage(msg, transfer ?? []);
|
|
1079
|
-
}
|
|
1080
|
-
const currentState = () => state;
|
|
1081
|
-
worker.onmessage = (event) => {
|
|
1082
|
-
const msg = event.data;
|
|
1083
|
-
switch (msg.type) {
|
|
1084
|
-
case "capabilities":
|
|
1085
|
-
readySettled = true;
|
|
1086
|
-
readyResolve?.(msg.backend);
|
|
1087
|
-
readyResolve = readyReject = null;
|
|
1088
|
-
break;
|
|
1089
|
-
case "frame-complete": {
|
|
1090
|
-
const waiter = frameWaiters.get(msg.frameIndex);
|
|
1091
|
-
waiter?.resolve();
|
|
1092
|
-
frameWaiters.delete(msg.frameIndex);
|
|
1093
|
-
break;
|
|
1094
|
-
}
|
|
1095
|
-
case "complete":
|
|
1096
|
-
state = "closed";
|
|
1097
|
-
finalizeResolve?.(msg.data);
|
|
1098
|
-
finalizeResolve = finalizeReject = null;
|
|
1099
|
-
worker.terminate();
|
|
1100
|
-
break;
|
|
1101
|
-
case "error": {
|
|
1102
|
-
const err = new Error(msg.message);
|
|
1103
|
-
fatalError = err;
|
|
1104
|
-
state = "closed";
|
|
1105
|
-
readySettled = true;
|
|
1106
|
-
readyReject?.(err);
|
|
1107
|
-
finalizeReject?.(err);
|
|
1108
|
-
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
1109
|
-
frameWaiters.clear();
|
|
1110
|
-
readyResolve = readyReject = null;
|
|
1111
|
-
finalizeResolve = finalizeReject = null;
|
|
1112
|
-
worker.terminate();
|
|
1113
|
-
break;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
};
|
|
1117
|
-
worker.onerror = (event) => {
|
|
1118
|
-
const err = new Error(event.message || "Worker error");
|
|
1119
|
-
fatalError = err;
|
|
1120
|
-
state = "closed";
|
|
1121
|
-
readySettled = true;
|
|
1122
|
-
readyReject?.(err);
|
|
1123
|
-
finalizeReject?.(err);
|
|
1124
|
-
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
1125
|
-
frameWaiters.clear();
|
|
1126
|
-
readyResolve = readyReject = null;
|
|
1127
|
-
finalizeResolve = finalizeReject = null;
|
|
1128
|
-
worker.terminate();
|
|
1129
|
-
};
|
|
1130
|
-
post({
|
|
1131
|
-
type: "init",
|
|
1132
|
-
width: config.width,
|
|
1133
|
-
height: config.height,
|
|
1134
|
-
fps: config.fps,
|
|
1135
|
-
quality: config.quality,
|
|
1136
|
-
...config.totalFrames !== void 0 ? { totalFrames: config.totalFrames } : {},
|
|
1137
|
-
...config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}
|
|
1138
|
-
});
|
|
1139
|
-
return {
|
|
1140
|
-
ready,
|
|
1141
|
-
encodeFrame(frame, frameIndex) {
|
|
1142
|
-
if (typeof HTMLCanvasElement !== "undefined" && frame instanceof HTMLCanvasElement) {
|
|
1143
|
-
return Promise.reject(new Error("Worker encoding requires a transferable ImageBitmap"));
|
|
1144
|
-
}
|
|
1145
|
-
const bitmap = frame;
|
|
1146
|
-
if (state !== "open" || fatalError) {
|
|
1147
|
-
bitmap.close();
|
|
1148
|
-
return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
|
|
1149
|
-
}
|
|
1150
|
-
if (frameWaiters.has(frameIndex)) {
|
|
1151
|
-
bitmap.close();
|
|
1152
|
-
return Promise.reject(new Error(`Frame ${frameIndex} was submitted more than once`));
|
|
1153
|
-
}
|
|
1154
|
-
let resolveFrame;
|
|
1155
|
-
let rejectFrame;
|
|
1156
|
-
const promise = new Promise((resolve, reject) => {
|
|
1157
|
-
resolveFrame = resolve;
|
|
1158
|
-
rejectFrame = reject;
|
|
1159
|
-
});
|
|
1160
|
-
frameWaiters.set(frameIndex, { promise, resolve: resolveFrame, reject: rejectFrame });
|
|
1161
|
-
const timestamp = Math.round(frameIndex * frameDuration);
|
|
1162
|
-
post({ type: "frame", bitmap, frameIndex, timestamp }, [bitmap]);
|
|
1163
|
-
return promise;
|
|
1164
|
-
},
|
|
1165
|
-
async finalize() {
|
|
1166
|
-
if (state !== "open") throw new Error("Encoder already closed or finalizing");
|
|
1167
|
-
if (fatalError) throw fatalError;
|
|
1168
|
-
state = "finalizing";
|
|
1169
|
-
await Promise.all(Array.from(frameWaiters.values(), (waiter) => waiter.promise));
|
|
1170
|
-
if (currentState() === "closed") {
|
|
1171
|
-
throw fatalError ?? new Error("Encoder closed during finalization");
|
|
1172
|
-
}
|
|
1173
|
-
return new Promise((resolve, reject) => {
|
|
1174
|
-
finalizeResolve = resolve;
|
|
1175
|
-
finalizeReject = reject;
|
|
1176
|
-
post({ type: "finalize" });
|
|
1177
|
-
});
|
|
1178
|
-
},
|
|
1179
|
-
close() {
|
|
1180
|
-
if (state === "closed") return;
|
|
1181
|
-
state = "closed";
|
|
1182
|
-
const err = new Error("Encoder closed");
|
|
1183
|
-
if (!readySettled) {
|
|
1184
|
-
readySettled = true;
|
|
1185
|
-
readyReject?.(err);
|
|
1186
|
-
}
|
|
1187
|
-
finalizeReject?.(err);
|
|
1188
|
-
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
1189
|
-
frameWaiters.clear();
|
|
1190
|
-
readyResolve = readyReject = null;
|
|
1191
|
-
finalizeResolve = finalizeReject = null;
|
|
1192
|
-
post({ type: "cancel" });
|
|
1193
|
-
worker.terminate();
|
|
1194
|
-
}
|
|
1195
|
-
};
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// src/gifTranscode.ts
|
|
1199
|
-
import {
|
|
1200
|
-
ffmpegGifPaletteApplicationArgs,
|
|
1201
|
-
ffmpegGifPaletteGenerationFilter,
|
|
1202
|
-
resolveFfmpegWasmLoad
|
|
1203
|
-
} from "@bendyline/squisq-video";
|
|
1204
|
-
function buildGifPaletteFfmpegArgs(options) {
|
|
1205
|
-
return [
|
|
1206
|
-
"-y",
|
|
1207
|
-
"-i",
|
|
1208
|
-
"video.mp4",
|
|
1209
|
-
"-vf",
|
|
1210
|
-
ffmpegGifPaletteGenerationFilter(options),
|
|
1211
|
-
"-frames:v",
|
|
1212
|
-
"1",
|
|
1213
|
-
"palette.png"
|
|
1214
|
-
];
|
|
1215
|
-
}
|
|
1216
|
-
function buildGifFfmpegArgs(options) {
|
|
1217
|
-
return [
|
|
1218
|
-
"-y",
|
|
1219
|
-
"-i",
|
|
1220
|
-
"video.mp4",
|
|
1221
|
-
"-i",
|
|
1222
|
-
"palette.png",
|
|
1223
|
-
...ffmpegGifPaletteApplicationArgs(options),
|
|
1224
|
-
"out.gif"
|
|
1225
|
-
];
|
|
1226
|
-
}
|
|
1227
|
-
var FFMPEG_ERRORISH = /error|invalid|failed|out of memory|memory access|abort|unable to/i;
|
|
1228
|
-
function ffmpegFailureDetail(logs) {
|
|
1229
|
-
const lines = logs.map((line) => line.trim()).filter(Boolean);
|
|
1230
|
-
return lines.find((line) => FFMPEG_ERRORISH.test(line)) ?? lines.at(-1) ?? null;
|
|
1231
|
-
}
|
|
1232
|
-
async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
|
|
1233
|
-
if (videoMp4.byteLength === 0) {
|
|
1234
|
-
throw new Error("Cannot create an animated GIF from an empty MP4.");
|
|
1235
|
-
}
|
|
1236
|
-
if (signal?.aborted) {
|
|
1237
|
-
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
1238
|
-
}
|
|
1239
|
-
const load = resolveFfmpegWasmLoad(loadConfig, "Animated GIF export", {
|
|
1240
|
-
classWorkerURL: new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
|
|
1241
|
-
});
|
|
1242
|
-
const paletteArgs = buildGifPaletteFfmpegArgs(options);
|
|
1243
|
-
const gifArgs = buildGifFfmpegArgs(options);
|
|
1244
|
-
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
|
|
1245
|
-
const ffmpeg = new FFmpeg();
|
|
1246
|
-
const recentLogs = [];
|
|
1247
|
-
const handleLog = ({ message }) => {
|
|
1248
|
-
recentLogs.push(message);
|
|
1249
|
-
if (recentLogs.length > 40) recentLogs.shift();
|
|
1250
|
-
};
|
|
1251
|
-
ffmpeg.on("log", handleLog);
|
|
1252
|
-
let terminated = false;
|
|
1253
|
-
const terminate = () => {
|
|
1254
|
-
if (terminated) return;
|
|
1255
|
-
terminated = true;
|
|
1256
|
-
ffmpeg.terminate();
|
|
1257
|
-
};
|
|
1258
|
-
const handleAbort = () => terminate();
|
|
1259
|
-
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
1260
|
-
try {
|
|
1261
|
-
await ffmpeg.load(load);
|
|
1262
|
-
if (signal?.aborted) {
|
|
1263
|
-
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
1264
|
-
}
|
|
1265
|
-
await ffmpeg.writeFile("video.mp4", videoMp4);
|
|
1266
|
-
if (signal?.aborted) {
|
|
1267
|
-
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
1268
|
-
}
|
|
1269
|
-
const execPhase = async (args, phase) => {
|
|
1270
|
-
recentLogs.length = 0;
|
|
1271
|
-
let exitCode;
|
|
1272
|
-
try {
|
|
1273
|
-
exitCode = await ffmpeg.exec(args);
|
|
1274
|
-
} catch (caught) {
|
|
1275
|
-
if (signal?.aborted) {
|
|
1276
|
-
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
1277
|
-
}
|
|
1278
|
-
const detail = ffmpegFailureDetail(recentLogs);
|
|
1279
|
-
const fallback = caught instanceof Error ? caught.message : String(caught);
|
|
1280
|
-
throw new Error(`ffmpeg.wasm GIF transcode failed during ${phase}: ${detail ?? fallback}`);
|
|
1281
|
-
}
|
|
1282
|
-
if (signal?.aborted) {
|
|
1283
|
-
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
1284
|
-
}
|
|
1285
|
-
if (exitCode !== 0) {
|
|
1286
|
-
const detail = ffmpegFailureDetail(recentLogs);
|
|
1287
|
-
throw new Error(
|
|
1288
|
-
`ffmpeg.wasm GIF transcode failed during ${phase} with exit code ${exitCode}` + (detail ? `: ${detail}` : "")
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
};
|
|
1292
|
-
await execPhase(paletteArgs, "palette generation");
|
|
1293
|
-
await execPhase(gifArgs, "palette application");
|
|
1294
|
-
await ffmpeg.deleteFile("video.mp4").catch(() => false);
|
|
1295
|
-
await ffmpeg.deleteFile("palette.png").catch(() => false);
|
|
1296
|
-
const data = await ffmpeg.readFile("out.gif");
|
|
1297
|
-
return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
1298
|
-
} finally {
|
|
1299
|
-
signal?.removeEventListener("abort", handleAbort);
|
|
1300
|
-
ffmpeg.off("log", handleLog);
|
|
1301
|
-
terminate();
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
// src/hooks/useVideoExport.ts
|
|
1306
|
-
var MAX_EXPORT_MEDIA_FILES = 256;
|
|
1307
|
-
var ENCODER_PROBE_TIMEOUT_MS = 5e3;
|
|
1308
|
-
var ENCODER_START_TIMEOUT_MS = 6e4;
|
|
1309
|
-
var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
|
|
1310
|
-
var FRAME_ENCODE_TIMEOUT_MS = 6e4;
|
|
1311
|
-
var CAPTURE_PROGRESS_START = 7;
|
|
1312
|
-
var CAPTURE_PROGRESS_END = 95;
|
|
1313
|
-
var FRAME_RATE_WINDOW_SIZE = 30;
|
|
1314
|
-
var DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS = 2;
|
|
1315
|
-
function calculateRollingFramesPerSecond(frameBoundaryTimes) {
|
|
1316
|
-
if (frameBoundaryTimes.length < 2) return null;
|
|
1317
|
-
const firstIndex = Math.max(0, frameBoundaryTimes.length - (FRAME_RATE_WINDOW_SIZE + 1));
|
|
1318
|
-
const elapsedMs = frameBoundaryTimes[frameBoundaryTimes.length - 1] - frameBoundaryTimes[firstIndex];
|
|
1319
|
-
const completedFrames = frameBoundaryTimes.length - 1 - firstIndex;
|
|
1320
|
-
if (elapsedMs <= 0 || completedFrames <= 0) return null;
|
|
1321
|
-
return completedFrames * 1e3 / elapsedMs;
|
|
1322
|
-
}
|
|
1323
|
-
function releaseEncoderFrame(frame) {
|
|
1324
|
-
if ("close" in frame) frame.close();
|
|
1325
|
-
}
|
|
1326
|
-
function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult, activityDocument) {
|
|
1327
|
-
return new Promise((resolve, reject) => {
|
|
1328
|
-
let settled = false;
|
|
1329
|
-
let timeout = null;
|
|
1330
|
-
const isInactive = () => activityDocument !== void 0 && activityDocument.visibilityState !== "visible";
|
|
1331
|
-
const clearDeadline = () => {
|
|
1332
|
-
if (timeout === null) return;
|
|
1333
|
-
globalThis.clearTimeout(timeout);
|
|
1334
|
-
timeout = null;
|
|
1335
|
-
};
|
|
1336
|
-
const cleanup = () => {
|
|
1337
|
-
clearDeadline();
|
|
1338
|
-
activityDocument?.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1339
|
-
};
|
|
1340
|
-
const fail = () => {
|
|
1341
|
-
timeout = null;
|
|
1342
|
-
if (settled || isInactive()) return;
|
|
1343
|
-
settled = true;
|
|
1344
|
-
cleanup();
|
|
1345
|
-
reject(new Error(timeoutMessage));
|
|
1346
|
-
};
|
|
1347
|
-
const armDeadline = () => {
|
|
1348
|
-
clearDeadline();
|
|
1349
|
-
if (settled || isInactive()) return;
|
|
1350
|
-
timeout = globalThis.setTimeout(fail, timeoutMs);
|
|
1351
|
-
};
|
|
1352
|
-
function handleVisibilityChange() {
|
|
1353
|
-
if (activityDocument?.visibilityState !== "visible") {
|
|
1354
|
-
clearDeadline();
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
armDeadline();
|
|
1358
|
-
}
|
|
1359
|
-
activityDocument?.addEventListener("visibilitychange", handleVisibilityChange);
|
|
1360
|
-
armDeadline();
|
|
1361
|
-
void operation.then(
|
|
1362
|
-
(value) => {
|
|
1363
|
-
if (settled) {
|
|
1364
|
-
onLateResult?.(value);
|
|
1365
|
-
return;
|
|
1366
|
-
}
|
|
1367
|
-
settled = true;
|
|
1368
|
-
cleanup();
|
|
1369
|
-
resolve(value);
|
|
1370
|
-
},
|
|
1371
|
-
(caught) => {
|
|
1372
|
-
if (settled) return;
|
|
1373
|
-
settled = true;
|
|
1374
|
-
cleanup();
|
|
1375
|
-
reject(caught);
|
|
1376
|
-
}
|
|
1377
|
-
);
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
function toArrayBuffer(bytes) {
|
|
1381
|
-
return bytes.slice().buffer;
|
|
1382
|
-
}
|
|
1383
|
-
function resolveExportMediaResourcePolicy(declaredSize, policy) {
|
|
1384
|
-
const knownSize = Number.isFinite(declaredSize) ? Math.max(0, declaredSize) : 0;
|
|
1385
|
-
return {
|
|
1386
|
-
...DEFAULT_INTERACTIVE_RESOURCE_POLICY,
|
|
1387
|
-
...policy,
|
|
1388
|
-
maxBytes: policy?.maxBytes ?? Math.max(DEFAULT_INTERACTIVE_RESOURCE_POLICY.maxBytes, knownSize)
|
|
1389
|
-
};
|
|
1390
|
-
}
|
|
1391
|
-
function collectDocumentMediaReferences(doc) {
|
|
1392
|
-
const references = /* @__PURE__ */ new Set();
|
|
1393
|
-
const seen = /* @__PURE__ */ new WeakSet();
|
|
1394
|
-
const visit = (value) => {
|
|
1395
|
-
if (typeof value === "string") {
|
|
1396
|
-
references.add(value);
|
|
1397
|
-
if (value.startsWith("./")) references.add(value.slice(2));
|
|
1398
|
-
return;
|
|
1399
|
-
}
|
|
1400
|
-
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
1401
|
-
seen.add(value);
|
|
1402
|
-
if (Array.isArray(value)) {
|
|
1403
|
-
value.forEach(visit);
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
Object.values(value).forEach(visit);
|
|
1407
|
-
};
|
|
1408
|
-
visit(doc);
|
|
1409
|
-
return references;
|
|
1410
|
-
}
|
|
1411
|
-
async function resolveAudioBuffers(clips, sources) {
|
|
1412
|
-
const srcs = new Set(clips.map((c) => c.src));
|
|
1413
|
-
const out = /* @__PURE__ */ new Map();
|
|
1414
|
-
for (const src of srcs) {
|
|
1415
|
-
let data = sources.audio?.get(src) ?? sources.images?.get(src);
|
|
1416
|
-
if (!data && sources.mediaProvider) {
|
|
1417
|
-
try {
|
|
1418
|
-
const url = await sources.mediaProvider.resolveUrl(src);
|
|
1419
|
-
const resource = await fetchResourceBytes(url, {
|
|
1420
|
-
policy: sources.resourcePolicy
|
|
1421
|
-
});
|
|
1422
|
-
data = toArrayBuffer(resource.bytes);
|
|
1423
|
-
} catch {
|
|
1424
|
-
}
|
|
1425
|
-
}
|
|
1426
|
-
if (data) out.set(src, data);
|
|
1427
|
-
}
|
|
1428
|
-
return out;
|
|
1429
|
-
}
|
|
1430
|
-
function resolveFrontmatterBoolean(value) {
|
|
1431
|
-
if (typeof value === "boolean") return value;
|
|
1432
|
-
if (typeof value !== "string") return void 0;
|
|
1433
|
-
const normalized = value.trim().toLowerCase();
|
|
1434
|
-
if (normalized === "true" || normalized === "yes" || normalized === "on" || normalized === "show" || normalized === "visible") {
|
|
1435
|
-
return true;
|
|
1436
|
-
}
|
|
1437
|
-
if (normalized === "false" || normalized === "no" || normalized === "off" || normalized === "hide" || normalized === "hidden") {
|
|
1438
|
-
return false;
|
|
1439
|
-
}
|
|
1440
|
-
return void 0;
|
|
1441
|
-
}
|
|
1442
|
-
function resolveVideoExportCover(doc, config = {}) {
|
|
1443
|
-
const frontmatter = doc.frontmatter;
|
|
1444
|
-
const frontmatterValue = frontmatter ? Object.prototype.hasOwnProperty.call(frontmatter, "squisq-cover-slide") ? frontmatter["squisq-cover-slide"] : frontmatter["cover-slide"] : void 0;
|
|
1445
|
-
const showCoverSlide = config.showCoverSlide ?? resolveFrontmatterBoolean(frontmatterValue) ?? true;
|
|
1446
|
-
const requestedPreRoll = config.coverPreRoll ?? DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS;
|
|
1447
|
-
if (!Number.isFinite(requestedPreRoll) || requestedPreRoll < 0) {
|
|
1448
|
-
throw new Error("Cover pre-roll must be a finite number of seconds greater than or equal to 0");
|
|
1449
|
-
}
|
|
1450
|
-
return {
|
|
1451
|
-
showCoverSlide,
|
|
1452
|
-
coverPreRoll: showCoverSlide && !!doc.startBlock ? requestedPreRoll : 0
|
|
1453
|
-
};
|
|
1454
|
-
}
|
|
1455
|
-
function useVideoExport(options = {}) {
|
|
1456
|
-
const [state, setState] = useState("idle");
|
|
1457
|
-
const [progress, setProgress] = useState(0);
|
|
1458
|
-
const [phase, setPhase] = useState("");
|
|
1459
|
-
const [currentFrameTime, setCurrentFrameTime] = useState(null);
|
|
1460
|
-
const [processingFps, setProcessingFps] = useState(null);
|
|
1461
|
-
const [duration, setDuration] = useState(0);
|
|
1462
|
-
const [outputFormat, setOutputFormat] = useState("mp4");
|
|
1463
|
-
const [backend, setBackend] = useState(null);
|
|
1464
|
-
const [downloadUrl, setDownloadUrl] = useState(null);
|
|
1465
|
-
const [outputBlob, setOutputBlob] = useState(null);
|
|
1466
|
-
const [fileSize, setFileSize] = useState(0);
|
|
1467
|
-
const [audioIncluded, setAudioIncluded] = useState(false);
|
|
1468
|
-
const [audioSkippedReason, setAudioSkippedReason] = useState(null);
|
|
1469
|
-
const [error, setError] = useState(null);
|
|
1470
|
-
const [elapsed, setElapsed] = useState(0);
|
|
1471
|
-
const [estimatedRemaining, setEstimatedRemaining] = useState(0);
|
|
1472
|
-
const encoderRef = useRef2(null);
|
|
1473
|
-
const gifAbortRef = useRef2(null);
|
|
1474
|
-
const cancelledRef = useRef2(false);
|
|
1475
|
-
const downloadUrlRef = useRef2(null);
|
|
1476
|
-
const startTimeRef = useRef2(0);
|
|
1477
|
-
const elapsedTimerRef = useRef2(null);
|
|
1478
|
-
const previewOptionsRef = useRef2(options);
|
|
1479
|
-
previewOptionsRef.current = options;
|
|
1480
|
-
const frameCapture = useFrameCapture();
|
|
1481
|
-
useEffect(() => {
|
|
1482
|
-
return () => {
|
|
1483
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1484
|
-
if (downloadUrlRef.current) {
|
|
1485
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
1486
|
-
}
|
|
1487
|
-
if (encoderRef.current) {
|
|
1488
|
-
encoderRef.current.close();
|
|
1489
|
-
}
|
|
1490
|
-
gifAbortRef.current?.abort();
|
|
1491
|
-
frameCapture.destroy();
|
|
1492
|
-
};
|
|
1493
|
-
}, [frameCapture]);
|
|
1494
|
-
const reset = useCallback2(() => {
|
|
1495
|
-
if (downloadUrlRef.current) {
|
|
1496
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
1497
|
-
downloadUrlRef.current = null;
|
|
1498
|
-
}
|
|
1499
|
-
if (encoderRef.current) {
|
|
1500
|
-
encoderRef.current.close();
|
|
1501
|
-
encoderRef.current = null;
|
|
1502
|
-
}
|
|
1503
|
-
gifAbortRef.current?.abort();
|
|
1504
|
-
gifAbortRef.current = null;
|
|
1505
|
-
frameCapture.destroy();
|
|
1506
|
-
setState("idle");
|
|
1507
|
-
setProgress(0);
|
|
1508
|
-
setPhase("");
|
|
1509
|
-
setCurrentFrameTime(null);
|
|
1510
|
-
setProcessingFps(null);
|
|
1511
|
-
setDuration(0);
|
|
1512
|
-
setOutputFormat("mp4");
|
|
1513
|
-
setBackend(null);
|
|
1514
|
-
setDownloadUrl(null);
|
|
1515
|
-
setOutputBlob(null);
|
|
1516
|
-
setFileSize(0);
|
|
1517
|
-
setAudioIncluded(false);
|
|
1518
|
-
setAudioSkippedReason(null);
|
|
1519
|
-
setError(null);
|
|
1520
|
-
setElapsed(0);
|
|
1521
|
-
setEstimatedRemaining(0);
|
|
1522
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1523
|
-
cancelledRef.current = false;
|
|
1524
|
-
}, [frameCapture]);
|
|
1525
|
-
const cancel = useCallback2(() => {
|
|
1526
|
-
cancelledRef.current = true;
|
|
1527
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1528
|
-
if (encoderRef.current) {
|
|
1529
|
-
encoderRef.current.close();
|
|
1530
|
-
encoderRef.current = null;
|
|
1531
|
-
}
|
|
1532
|
-
gifAbortRef.current?.abort();
|
|
1533
|
-
gifAbortRef.current = null;
|
|
1534
|
-
frameCapture.destroy();
|
|
1535
|
-
setState("idle");
|
|
1536
|
-
setProgress(0);
|
|
1537
|
-
setPhase("Cancelled");
|
|
1538
|
-
}, [frameCapture]);
|
|
1539
|
-
const startExport = useCallback2(
|
|
1540
|
-
async (doc, config) => {
|
|
1541
|
-
cancelledRef.current = false;
|
|
1542
|
-
if (downloadUrlRef.current) {
|
|
1543
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
1544
|
-
downloadUrlRef.current = null;
|
|
1545
|
-
}
|
|
1546
|
-
setDownloadUrl(null);
|
|
1547
|
-
setOutputBlob(null);
|
|
1548
|
-
setFileSize(0);
|
|
1549
|
-
setAudioIncluded(false);
|
|
1550
|
-
setAudioSkippedReason(null);
|
|
1551
|
-
setError(null);
|
|
1552
|
-
setCurrentFrameTime(null);
|
|
1553
|
-
setProcessingFps(null);
|
|
1554
|
-
const quality = config.quality ?? "normal";
|
|
1555
|
-
const effectiveOutputFormat = config.outputFormat ?? "mp4";
|
|
1556
|
-
const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
|
|
1557
|
-
const orientation = config.orientation ?? "landscape";
|
|
1558
|
-
const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
|
|
1559
|
-
const captionMode = config.captionMode ?? (effectiveOutputFormat === "gif" ? "standard" : "off");
|
|
1560
|
-
const audioPolicy = config.audioPolicy ?? "require";
|
|
1561
|
-
setOutputFormat(effectiveOutputFormat);
|
|
1562
|
-
try {
|
|
1563
|
-
const cover = resolveVideoExportCover(doc, config);
|
|
1564
|
-
const gifDefaults = orientation === "portrait" ? { width: 540, height: 960 } : { width: 960, height: 540 };
|
|
1565
|
-
const { width, height } = resolveDimensions({
|
|
1566
|
-
orientation,
|
|
1567
|
-
fps,
|
|
1568
|
-
quality,
|
|
1569
|
-
...config.width !== void 0 ? { width: config.width } : effectiveOutputFormat === "gif" ? { width: gifDefaults.width } : {},
|
|
1570
|
-
...config.height !== void 0 ? { height: config.height } : effectiveOutputFormat === "gif" ? { height: gifDefaults.height } : {}
|
|
1571
|
-
});
|
|
1572
|
-
const webCodecsAvailable = supportsWebCodecs();
|
|
1573
|
-
const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== "undefined";
|
|
1574
|
-
if (effectiveOutputFormat === "gif" && !sharedArrayBufferAvailable) {
|
|
1575
|
-
throw new Error(
|
|
1576
|
-
"Animated GIF export requires ffmpeg.wasm and SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
1577
|
-
);
|
|
1578
|
-
}
|
|
1579
|
-
if (effectiveOutputFormat === "gif") {
|
|
1580
|
-
resolveFfmpegWasmLoad2(config.ffmpegWasm, "Animated GIF export");
|
|
1581
|
-
}
|
|
1582
|
-
if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
|
|
1583
|
-
throw new Error(
|
|
1584
|
-
"No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
1585
|
-
);
|
|
1586
|
-
}
|
|
1587
|
-
setState("preparing");
|
|
1588
|
-
setPhase("Loading document\u2026");
|
|
1589
|
-
setProgress(0);
|
|
1590
|
-
setElapsed(0);
|
|
1591
|
-
setEstimatedRemaining(0);
|
|
1592
|
-
startTimeRef.current = performance.now();
|
|
1593
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1594
|
-
elapsedTimerRef.current = setInterval(() => {
|
|
1595
|
-
setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
|
|
1596
|
-
}, 1e3);
|
|
1597
|
-
let images = config.images;
|
|
1598
|
-
let ownsLoadedImages = false;
|
|
1599
|
-
if (!images && config.mediaProvider) {
|
|
1600
|
-
images = /* @__PURE__ */ new Map();
|
|
1601
|
-
ownsLoadedImages = true;
|
|
1602
|
-
const entries = await config.mediaProvider.listMedia();
|
|
1603
|
-
const references = collectDocumentMediaReferences(doc);
|
|
1604
|
-
const neededEntries = entries.filter(
|
|
1605
|
-
(entry) => references.has(entry.name) || references.has(`./${entry.name}`)
|
|
1606
|
-
);
|
|
1607
|
-
if (neededEntries.length > MAX_EXPORT_MEDIA_FILES) {
|
|
1608
|
-
throw new Error(
|
|
1609
|
-
`Document references ${neededEntries.length} media files; browser export supports at most ${MAX_EXPORT_MEDIA_FILES}.`
|
|
1610
|
-
);
|
|
1611
|
-
}
|
|
1612
|
-
for (const entry of neededEntries) {
|
|
1613
|
-
if (cancelledRef.current) return;
|
|
1614
|
-
const url2 = await config.mediaProvider.resolveUrl(entry.name);
|
|
1615
|
-
const resource = await fetchResourceBytes(url2, {
|
|
1616
|
-
policy: resolveExportMediaResourcePolicy(entry.size, config.resourcePolicy)
|
|
1617
|
-
});
|
|
1618
|
-
const data = toArrayBuffer(resource.bytes);
|
|
1619
|
-
images.set(entry.name, data);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
const docDuration = await frameCapture.init(
|
|
1623
|
-
doc,
|
|
1624
|
-
{
|
|
1625
|
-
images,
|
|
1626
|
-
audio: config.audio,
|
|
1627
|
-
width,
|
|
1628
|
-
height,
|
|
1629
|
-
animationsEnabled,
|
|
1630
|
-
theme: config.theme,
|
|
1631
|
-
videoPresentation: config.videoPresentation,
|
|
1632
|
-
pipSize: config.pipSize,
|
|
1633
|
-
pipShape: config.pipShape,
|
|
1634
|
-
pipPosition: config.pipPosition,
|
|
1635
|
-
showCoverSlide: cover.showCoverSlide
|
|
1636
|
-
},
|
|
1637
|
-
captionMode
|
|
1638
|
-
);
|
|
1639
|
-
if (cancelledRef.current) return;
|
|
1640
|
-
if (docDuration <= 0) {
|
|
1641
|
-
throw new Error("Document has zero duration \u2014 nothing to export");
|
|
1642
|
-
}
|
|
1643
|
-
const coverFrameCount = Math.ceil(cover.coverPreRoll * fps);
|
|
1644
|
-
const storyFrameCount = Math.ceil(docDuration * fps);
|
|
1645
|
-
const totalFrames = coverFrameCount + storyFrameCount;
|
|
1646
|
-
const exportDuration = totalFrames / fps;
|
|
1647
|
-
setDuration(exportDuration);
|
|
1648
|
-
setPhase("Checking video encoder\u2026");
|
|
1649
|
-
setProgress(5);
|
|
1650
|
-
const canUseWebCodecs = webCodecsAvailable && await settleWithin(
|
|
1651
|
-
supportsWebCodecsH264({ width, height, fps, quality }),
|
|
1652
|
-
ENCODER_PROBE_TIMEOUT_MS,
|
|
1653
|
-
"The browser did not finish checking WebCodecs support.",
|
|
1654
|
-
void 0,
|
|
1655
|
-
document
|
|
1656
|
-
).catch(() => false);
|
|
1657
|
-
const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
|
|
1658
|
-
const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverFrameCount / fps) : [];
|
|
1659
|
-
const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
|
|
1660
|
-
const tierDecision = selectAudioTier({
|
|
1661
|
-
hasClips: timeline.length > 0,
|
|
1662
|
-
aacSupported,
|
|
1663
|
-
sharedArrayBufferAvailable,
|
|
1664
|
-
canUseMainThreadWebCodecs: canUseWebCodecs
|
|
1665
|
-
});
|
|
1666
|
-
let renderedAudio = null;
|
|
1667
|
-
let audioIncludedLocal = false;
|
|
1668
|
-
let audioReasonLocal = tierDecision.reason;
|
|
1669
|
-
if (timeline.length > 0 && tierDecision.tier === 3 && audioPolicy === "require") {
|
|
1670
|
-
throw new Error(tierDecision.reason ?? "This browser cannot include the document audio.");
|
|
1671
|
-
}
|
|
1672
|
-
if (tierDecision.tier === 1 || tierDecision.tier === 2) {
|
|
1673
|
-
setPhase("Preparing audio\u2026");
|
|
1674
|
-
try {
|
|
1675
|
-
const buffers = await resolveAudioBuffers(timeline, {
|
|
1676
|
-
audio: config.audio,
|
|
1677
|
-
images,
|
|
1678
|
-
mediaProvider: config.mediaProvider,
|
|
1679
|
-
resourcePolicy: config.resourcePolicy
|
|
1680
|
-
});
|
|
1681
|
-
try {
|
|
1682
|
-
const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
|
|
1683
|
-
(src) => !buffers.has(src)
|
|
1684
|
-
);
|
|
1685
|
-
if (missingSources.length > 0) {
|
|
1686
|
-
audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
|
|
1687
|
-
if (audioPolicy === "require") throw new Error(audioReasonLocal);
|
|
1688
|
-
}
|
|
1689
|
-
if (buffers.size === 0) {
|
|
1690
|
-
audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
|
|
1691
|
-
} else {
|
|
1692
|
-
const totalAudioDur = timeline.reduce(
|
|
1693
|
-
(max, c) => Math.max(max, c.startSec + c.durationSec),
|
|
1694
|
-
exportDuration
|
|
1695
|
-
);
|
|
1696
|
-
renderedAudio = await renderAudioTimeline(
|
|
1697
|
-
timeline,
|
|
1698
|
-
buffers,
|
|
1699
|
-
totalAudioDur,
|
|
1700
|
-
EXPORT_AUDIO_SAMPLE_RATE
|
|
1701
|
-
);
|
|
1702
|
-
if (!renderedAudio) {
|
|
1703
|
-
audioReasonLocal = "No included video source contained a decodable audio track.";
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
} finally {
|
|
1707
|
-
buffers.clear();
|
|
1708
|
-
}
|
|
1709
|
-
} catch (audioErr) {
|
|
1710
|
-
renderedAudio = null;
|
|
1711
|
-
audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
1712
|
-
if (audioPolicy === "require") throw new Error(audioReasonLocal);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
|
|
1716
|
-
const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
|
|
1717
|
-
if (cancelledRef.current) return;
|
|
1718
|
-
let encoder;
|
|
1719
|
-
if (canUseWebCodecs) {
|
|
1720
|
-
encoder = createEncoder({
|
|
1721
|
-
width,
|
|
1722
|
-
height,
|
|
1723
|
-
fps,
|
|
1724
|
-
quality,
|
|
1725
|
-
...useInlineAudio && renderedAudio ? {
|
|
1726
|
-
audio: {
|
|
1727
|
-
numberOfChannels: renderedAudio.numberOfChannels,
|
|
1728
|
-
sampleRate: renderedAudio.sampleRate
|
|
1729
|
-
}
|
|
1730
|
-
} : {}
|
|
1731
|
-
});
|
|
1732
|
-
encoderRef.current = encoder;
|
|
1733
|
-
setBackend("webcodecs");
|
|
1734
|
-
} else if (sharedArrayBufferAvailable) {
|
|
1735
|
-
setProgress(6);
|
|
1736
|
-
setPhase("Loading export engine\u2026");
|
|
1737
|
-
const workerEncoder = createWorkerEncoder({
|
|
1738
|
-
width,
|
|
1739
|
-
height,
|
|
1740
|
-
fps,
|
|
1741
|
-
quality,
|
|
1742
|
-
totalFrames,
|
|
1743
|
-
ffmpegWasm: config.ffmpegWasm
|
|
1744
|
-
});
|
|
1745
|
-
encoder = workerEncoder;
|
|
1746
|
-
encoderRef.current = workerEncoder;
|
|
1747
|
-
const selectedBackend = await settleWithin(
|
|
1748
|
-
workerEncoder.ready,
|
|
1749
|
-
ENCODER_START_TIMEOUT_MS,
|
|
1750
|
-
"The browser export engine did not start within 60 seconds.",
|
|
1751
|
-
void 0,
|
|
1752
|
-
document
|
|
1753
|
-
);
|
|
1754
|
-
setBackend(selectedBackend);
|
|
1755
|
-
} else {
|
|
1756
|
-
throw new Error(
|
|
1757
|
-
"WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
1758
|
-
);
|
|
1759
|
-
}
|
|
1760
|
-
if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
|
|
1761
|
-
setPhase("Encoding audio\u2026");
|
|
1762
|
-
try {
|
|
1763
|
-
await encodeAacTrack(
|
|
1764
|
-
renderedAudio,
|
|
1765
|
-
{ addAudioChunk: encoder.addAudioChunk.bind(encoder) },
|
|
1766
|
-
audioBitrate
|
|
1767
|
-
);
|
|
1768
|
-
audioIncludedLocal = true;
|
|
1769
|
-
} catch (audioErr) {
|
|
1770
|
-
audioIncludedLocal = false;
|
|
1771
|
-
audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
1772
|
-
if (audioPolicy === "require") throw new Error(audioReasonLocal);
|
|
1773
|
-
} finally {
|
|
1774
|
-
renderedAudio = null;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
if (ownsLoadedImages) images?.clear();
|
|
1778
|
-
images = void 0;
|
|
1779
|
-
if (cancelledRef.current) return;
|
|
1780
|
-
setProgress(CAPTURE_PROGRESS_START);
|
|
1781
|
-
setPhase(`Capturing frame 1/${totalFrames}`);
|
|
1782
|
-
setCurrentFrameTime(0);
|
|
1783
|
-
setState("capturing");
|
|
1784
|
-
const captureStartTime = performance.now();
|
|
1785
|
-
const frameBoundaryTimes = [captureStartTime];
|
|
1786
|
-
if (coverFrameCount > 0) await frameCapture.setCoverVisible(true);
|
|
1787
|
-
for (let i = 0; i < totalFrames; i++) {
|
|
1788
|
-
if (cancelledRef.current) return;
|
|
1789
|
-
if (coverFrameCount > 0 && i === coverFrameCount) {
|
|
1790
|
-
await frameCapture.setCoverVisible(false);
|
|
1791
|
-
}
|
|
1792
|
-
const time = i / fps;
|
|
1793
|
-
const captureTime = i < coverFrameCount ? 0 : (i - coverFrameCount) / fps;
|
|
1794
|
-
const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(captureTime, { reuseIfUnchanged: true }) : frameCapture.captureFrame(captureTime, { reuseIfUnchanged: true });
|
|
1795
|
-
const frame = await settleWithin(
|
|
1796
|
-
captureOperation,
|
|
1797
|
-
FRAME_CAPTURE_TIMEOUT_MS,
|
|
1798
|
-
`Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
|
|
1799
|
-
releaseEncoderFrame,
|
|
1800
|
-
document
|
|
1801
|
-
);
|
|
1802
|
-
if (cancelledRef.current) {
|
|
1803
|
-
releaseEncoderFrame(frame);
|
|
1804
|
-
return;
|
|
1805
|
-
}
|
|
1806
|
-
const previewOptions = previewOptionsRef.current;
|
|
1807
|
-
const previewInterval = Math.max(1, Math.floor(previewOptions.previewEveryNFrames ?? 1));
|
|
1808
|
-
if (previewOptions.onFramePreview && (i === 0 || i === totalFrames - 1 || i % previewInterval === 0)) {
|
|
1809
|
-
try {
|
|
1810
|
-
previewOptions.onFramePreview({ source: frame, frameIndex: i, totalFrames, time });
|
|
1811
|
-
} catch {
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
setPhase(`Encoding frame ${i + 1}/${totalFrames}`);
|
|
1815
|
-
setCurrentFrameTime(time);
|
|
1816
|
-
await settleWithin(
|
|
1817
|
-
encoder.encodeFrame(frame, i),
|
|
1818
|
-
FRAME_ENCODE_TIMEOUT_MS,
|
|
1819
|
-
`Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`,
|
|
1820
|
-
void 0,
|
|
1821
|
-
document
|
|
1822
|
-
);
|
|
1823
|
-
const completedFrames = i + 1;
|
|
1824
|
-
const completedAt = performance.now();
|
|
1825
|
-
frameBoundaryTimes.push(completedAt);
|
|
1826
|
-
if (frameBoundaryTimes.length > FRAME_RATE_WINDOW_SIZE + 1) {
|
|
1827
|
-
frameBoundaryTimes.shift();
|
|
1828
|
-
}
|
|
1829
|
-
setProcessingFps(calculateRollingFramesPerSecond(frameBoundaryTimes));
|
|
1830
|
-
setCurrentFrameTime(Math.min(completedFrames / fps, exportDuration));
|
|
1831
|
-
setPhase(
|
|
1832
|
-
completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames}` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
|
|
1833
|
-
);
|
|
1834
|
-
const captureRatio = completedFrames / totalFrames;
|
|
1835
|
-
const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
|
|
1836
|
-
setProgress(Math.round(captureProgress * 10) / 10);
|
|
1837
|
-
const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
|
|
1838
|
-
const avgPerFrame = elapsedCapture / completedFrames;
|
|
1839
|
-
setEstimatedRemaining(Math.round(avgPerFrame * (totalFrames - completedFrames)));
|
|
1840
|
-
setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
|
|
1841
|
-
}
|
|
1842
|
-
if (cancelledRef.current) return;
|
|
1843
|
-
setState("encoding");
|
|
1844
|
-
setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
|
|
1845
|
-
setProgress(95);
|
|
1846
|
-
let outputBytes = effectiveOutputFormat === "mp4" && !useFfmpegAudio && encoder.finalizeBlob ? await encoder.finalizeBlob() : await encoder.finalize();
|
|
1847
|
-
encoderRef.current = null;
|
|
1848
|
-
if (cancelledRef.current) return;
|
|
1849
|
-
if (effectiveOutputFormat === "gif") {
|
|
1850
|
-
setPhase("Generating GIF palette\u2026");
|
|
1851
|
-
const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
1852
|
-
const gifAbort = new AbortController();
|
|
1853
|
-
gifAbortRef.current = gifAbort;
|
|
1854
|
-
try {
|
|
1855
|
-
outputBytes = await transcodeMp4ToGifWithFfmpegWasm(
|
|
1856
|
-
videoOnly,
|
|
1857
|
-
{ width, height, loop: 0 },
|
|
1858
|
-
config.ffmpegWasm,
|
|
1859
|
-
gifAbort.signal
|
|
1860
|
-
);
|
|
1861
|
-
} finally {
|
|
1862
|
-
if (gifAbortRef.current === gifAbort) gifAbortRef.current = null;
|
|
1863
|
-
}
|
|
1864
|
-
} else if (useFfmpegAudio && renderedAudio) {
|
|
1865
|
-
setPhase("Muxing audio\u2026");
|
|
1866
|
-
try {
|
|
1867
|
-
const wav = audioBufferToWav(renderedAudio);
|
|
1868
|
-
const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
1869
|
-
outputBytes = await muxAudioWithFfmpegWasm(
|
|
1870
|
-
videoOnly,
|
|
1871
|
-
wav,
|
|
1872
|
-
audioBitrate,
|
|
1873
|
-
config.ffmpegWasm
|
|
1874
|
-
);
|
|
1875
|
-
audioIncludedLocal = true;
|
|
1876
|
-
} catch (audioErr) {
|
|
1877
|
-
audioIncludedLocal = false;
|
|
1878
|
-
audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
1879
|
-
if (audioPolicy === "require") throw new Error(audioReasonLocal);
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
if (cancelledRef.current) return;
|
|
1883
|
-
const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
|
|
1884
|
-
const blob = outputBytes instanceof Blob ? outputBytes : new Blob(
|
|
1885
|
-
[
|
|
1886
|
-
outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes)
|
|
1887
|
-
],
|
|
1888
|
-
{ type: mimeType }
|
|
1889
|
-
);
|
|
1890
|
-
const url = URL.createObjectURL(blob);
|
|
1891
|
-
downloadUrlRef.current = url;
|
|
1892
|
-
setDownloadUrl(url);
|
|
1893
|
-
setOutputBlob(blob);
|
|
1894
|
-
setFileSize(blob.size);
|
|
1895
|
-
setAudioIncluded(audioIncludedLocal);
|
|
1896
|
-
setAudioSkippedReason(
|
|
1897
|
-
effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
|
|
1898
|
-
);
|
|
1899
|
-
setState("complete");
|
|
1900
|
-
setProgress(100);
|
|
1901
|
-
setPhase("Export complete");
|
|
1902
|
-
setEstimatedRemaining(0);
|
|
1903
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1904
|
-
frameCapture.destroy();
|
|
1905
|
-
} catch (err) {
|
|
1906
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
1907
|
-
if (cancelledRef.current) return;
|
|
1908
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1909
|
-
setState("error");
|
|
1910
|
-
setError(message);
|
|
1911
|
-
setPhase("Export failed");
|
|
1912
|
-
if (encoderRef.current) {
|
|
1913
|
-
encoderRef.current.close();
|
|
1914
|
-
encoderRef.current = null;
|
|
1915
|
-
}
|
|
1916
|
-
gifAbortRef.current?.abort();
|
|
1917
|
-
gifAbortRef.current = null;
|
|
1918
|
-
frameCapture.destroy();
|
|
1919
|
-
}
|
|
1920
|
-
},
|
|
1921
|
-
[frameCapture]
|
|
1922
|
-
);
|
|
1923
|
-
return {
|
|
1924
|
-
state,
|
|
1925
|
-
progress,
|
|
1926
|
-
phase,
|
|
1927
|
-
currentFrameTime,
|
|
1928
|
-
processingFps,
|
|
1929
|
-
duration,
|
|
1930
|
-
outputFormat,
|
|
1931
|
-
backend,
|
|
1932
|
-
downloadUrl,
|
|
1933
|
-
outputBlob,
|
|
1934
|
-
fileSize,
|
|
1935
|
-
audioIncluded,
|
|
1936
|
-
audioSkippedReason,
|
|
1937
|
-
error,
|
|
1938
|
-
elapsed,
|
|
1939
|
-
estimatedRemaining,
|
|
1940
|
-
startExport,
|
|
1941
|
-
cancel,
|
|
1942
|
-
reset
|
|
1943
|
-
};
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
1073
|
export {
|
|
1947
|
-
useFrameCapture
|
|
1948
|
-
DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
|
|
1949
|
-
resolveVideoExportCover,
|
|
1950
|
-
useVideoExport
|
|
1074
|
+
useFrameCapture
|
|
1951
1075
|
};
|