@bendyline/squisq-video-react 2.2.1 → 2.2.2
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 +8 -4
- package/dist/{chunk-VILZP3GL.js → chunk-IDY64F4K.js} +315 -64
- package/dist/{chunk-VI5AIVOQ.js → chunk-MEPETH5V.js} +31 -2
- package/dist/{chunk-KQG6DZ7G.js → chunk-ULNKIV4A.js} +51 -21
- package/dist/{chunk-YZN7D4IC.js → chunk-ZQJBUX75.js} +28 -11
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +4 -4
- package/dist/encoder/index.d.ts +4 -3
- package/dist/encoder/index.js +2 -2
- package/dist/hooks/index.d.ts +17 -4
- package/dist/hooks/index.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -4
- package/dist/{useVideoExport-NtqZVgaz.d.ts → useVideoExport-DAEglCva.d.ts} +1 -1
- package/dist/workers/encode.worker.js +37 -11
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ To open directly in the compact GIF preset:
|
|
|
33
33
|
<VideoExportButton doc={myDoc} defaultConfig={{ outputFormat: 'gif' }} />
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
Animated GIF export defaults to standard captions when the document has a
|
|
37
|
+
caption track. Pass `captionMode: 'off'` in `defaultConfig` to disable them, or
|
|
38
|
+
choose None in the export modal. MP4 continues to default to captions off.
|
|
39
|
+
|
|
36
40
|
**v1.5:** `playerScript` is now **optional** — the browser export captures frames
|
|
37
41
|
from a live in-page `DocPlayer`, so the standalone bundle is only needed for
|
|
38
42
|
CLI/Playwright-style pipelines. A new `defaultConfig?: Partial<VideoExportConfig>`
|
|
@@ -69,10 +73,10 @@ function App() {
|
|
|
69
73
|
|
|
70
74
|
## Components
|
|
71
75
|
|
|
72
|
-
| Component | Description
|
|
73
|
-
| ------------------- |
|
|
74
|
-
| `VideoExportModal` | Full modal UI — configure MP4/GIF, motion, quality, fps, and orientation |
|
|
75
|
-
| `VideoExportButton` | Drop-in button that opens the export modal via portal
|
|
76
|
+
| Component | Description |
|
|
77
|
+
| ------------------- | ---------------------------------------------------------------------------------- |
|
|
78
|
+
| `VideoExportModal` | Full modal UI — configure MP4/GIF, captions, motion, quality, fps, and orientation |
|
|
79
|
+
| `VideoExportButton` | Drop-in button that opens the export modal via portal |
|
|
76
80
|
|
|
77
81
|
## Hooks
|
|
78
82
|
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
supportsWebCodecs,
|
|
11
11
|
supportsWebCodecsAac,
|
|
12
12
|
supportsWebCodecsH264
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-ZQJBUX75.js";
|
|
14
14
|
|
|
15
15
|
// src/hooks/useFrameCapture.ts
|
|
16
16
|
import { createElement } from "react";
|
|
@@ -28,6 +28,33 @@ var MIME_MAP = {
|
|
|
28
28
|
bmp: "image/bmp",
|
|
29
29
|
avif: "image/avif"
|
|
30
30
|
};
|
|
31
|
+
var VISUAL_UPDATE_FALLBACK_MS = 100;
|
|
32
|
+
var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
|
|
33
|
+
function waitForVisualUpdate(frameCount = 1) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
let settled = false;
|
|
36
|
+
let animationFrame = null;
|
|
37
|
+
const finish = () => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
window.clearTimeout(fallback);
|
|
41
|
+
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
const fallback = window.setTimeout(
|
|
45
|
+
finish,
|
|
46
|
+
document.visibilityState === "visible" ? VISUAL_UPDATE_FALLBACK_MS : 0
|
|
47
|
+
);
|
|
48
|
+
if (document.visibilityState !== "visible") return;
|
|
49
|
+
const waitForFrame = (remaining) => {
|
|
50
|
+
animationFrame = window.requestAnimationFrame(() => {
|
|
51
|
+
if (remaining <= 1) finish();
|
|
52
|
+
else waitForFrame(remaining - 1);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
waitForFrame(Math.max(1, frameCount));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
31
58
|
function createInlineProvider(images) {
|
|
32
59
|
const blobUrls = /* @__PURE__ */ new Map();
|
|
33
60
|
const mimeTypes = /* @__PURE__ */ new Map();
|
|
@@ -60,27 +87,96 @@ function createInlineProvider(images) {
|
|
|
60
87
|
}
|
|
61
88
|
};
|
|
62
89
|
}
|
|
90
|
+
function shouldIgnoreCaptureSibling(element, captureRoot) {
|
|
91
|
+
const { head } = captureRoot.ownerDocument;
|
|
92
|
+
const isInDocumentHead = element === head || head.contains(element);
|
|
93
|
+
const isInCaptureBranch = element === captureRoot || element.contains(captureRoot) || captureRoot.contains(element);
|
|
94
|
+
return !isInDocumentHead && !isInCaptureBranch;
|
|
95
|
+
}
|
|
96
|
+
function finiteMediaTime(value) {
|
|
97
|
+
return Number.isFinite(value) ? value.toFixed(6) : "unknown";
|
|
98
|
+
}
|
|
99
|
+
function getFrameVisualStateKey(captureRoot, timelineTime) {
|
|
100
|
+
const markup = captureRoot.innerHTML;
|
|
101
|
+
let needsTimelineKey = false;
|
|
102
|
+
const animationStates = [];
|
|
103
|
+
if (typeof captureRoot.getAnimations === "function") {
|
|
104
|
+
const animations = captureRoot.getAnimations({ subtree: true });
|
|
105
|
+
animations.forEach((animation, index) => {
|
|
106
|
+
try {
|
|
107
|
+
const timing = animation.effect?.getComputedTiming();
|
|
108
|
+
if (!timing) {
|
|
109
|
+
needsTimelineKey = true;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
animationStates.push(
|
|
113
|
+
`${index}:${animation.playState}:${String(timing.progress)}:${String(
|
|
114
|
+
timing.currentIteration
|
|
115
|
+
)}`
|
|
116
|
+
);
|
|
117
|
+
} catch {
|
|
118
|
+
needsTimelineKey = true;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
} else if (/\b(?:anim-|transition-)|animation(?:-name)?\s*:/i.test(markup)) {
|
|
122
|
+
needsTimelineKey = true;
|
|
123
|
+
}
|
|
124
|
+
const imageStates = Array.from(captureRoot.querySelectorAll("img")).map((image) => {
|
|
125
|
+
const src = image.currentSrc || image.src;
|
|
126
|
+
if (POTENTIALLY_ANIMATED_IMAGE_URL.test(src)) needsTimelineKey = true;
|
|
127
|
+
return `${src}:${image.complete}:${image.naturalWidth}x${image.naturalHeight}`;
|
|
128
|
+
});
|
|
129
|
+
if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
|
|
130
|
+
const videoStates = Array.from(captureRoot.querySelectorAll("video")).map(
|
|
131
|
+
(video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
|
|
132
|
+
);
|
|
133
|
+
if (captureRoot.querySelector(
|
|
134
|
+
"canvas, iframe, object, embed, animate, animateMotion, animateTransform, set"
|
|
135
|
+
)) {
|
|
136
|
+
needsTimelineKey = true;
|
|
137
|
+
}
|
|
138
|
+
const fontStatus = captureRoot.ownerDocument.fonts?.status ?? "unsupported";
|
|
139
|
+
return JSON.stringify({
|
|
140
|
+
markup,
|
|
141
|
+
animationStates,
|
|
142
|
+
imageStates,
|
|
143
|
+
videoStates,
|
|
144
|
+
fontStatus,
|
|
145
|
+
timelineTime: needsTimelineKey ? timelineTime.toFixed(6) : null
|
|
146
|
+
});
|
|
147
|
+
}
|
|
63
148
|
function useFrameCapture() {
|
|
64
149
|
const containerRef = useRef(null);
|
|
65
150
|
const rootRef = useRef(null);
|
|
66
151
|
const renderAPIRef = useRef(null);
|
|
67
152
|
const mediaProviderRef = useRef(null);
|
|
153
|
+
const captureCanvasRef = useRef(null);
|
|
154
|
+
const lastVisualStateKeyRef = useRef(null);
|
|
155
|
+
const hasCapturedFrameRef = useRef(false);
|
|
68
156
|
const dimensionsRef = useRef({ width: 1920, height: 1080 });
|
|
69
157
|
const init = useCallback(
|
|
70
158
|
async (doc, renderOptions, captionMode) => {
|
|
71
|
-
if (rootRef.current || containerRef.current || mediaProviderRef.current) {
|
|
159
|
+
if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current) {
|
|
72
160
|
const oldRoot = rootRef.current;
|
|
73
161
|
const oldContainer = containerRef.current;
|
|
74
162
|
const oldMediaProvider = mediaProviderRef.current;
|
|
163
|
+
const oldCaptureCanvas = captureCanvasRef.current;
|
|
75
164
|
rootRef.current = null;
|
|
76
165
|
containerRef.current = null;
|
|
77
166
|
renderAPIRef.current = null;
|
|
78
167
|
mediaProviderRef.current = null;
|
|
168
|
+
captureCanvasRef.current = null;
|
|
169
|
+
lastVisualStateKeyRef.current = null;
|
|
170
|
+
hasCapturedFrameRef.current = false;
|
|
79
171
|
await new Promise((resolve) => {
|
|
80
172
|
setTimeout(() => {
|
|
81
173
|
if (oldRoot) oldRoot.unmount();
|
|
82
174
|
if (oldContainer) oldContainer.remove();
|
|
83
175
|
oldMediaProvider?.dispose();
|
|
176
|
+
if (oldCaptureCanvas) {
|
|
177
|
+
oldCaptureCanvas.width = 0;
|
|
178
|
+
oldCaptureCanvas.height = 0;
|
|
179
|
+
}
|
|
84
180
|
resolve();
|
|
85
181
|
}, 0);
|
|
86
182
|
});
|
|
@@ -89,6 +185,14 @@ function useFrameCapture() {
|
|
|
89
185
|
const height = renderOptions.height ?? 1080;
|
|
90
186
|
const animationsEnabled = renderOptions.animationsEnabled ?? true;
|
|
91
187
|
dimensionsRef.current = { width, height };
|
|
188
|
+
const captureCanvas = document.createElement("canvas");
|
|
189
|
+
captureCanvas.width = width;
|
|
190
|
+
captureCanvas.height = height;
|
|
191
|
+
captureCanvas.style.width = `${width}px`;
|
|
192
|
+
captureCanvas.style.height = `${height}px`;
|
|
193
|
+
captureCanvasRef.current = captureCanvas;
|
|
194
|
+
lastVisualStateKeyRef.current = null;
|
|
195
|
+
hasCapturedFrameRef.current = false;
|
|
92
196
|
const container = document.createElement("div");
|
|
93
197
|
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;`;
|
|
94
198
|
document.body.appendChild(container);
|
|
@@ -150,33 +254,56 @@ function useFrameCapture() {
|
|
|
150
254
|
},
|
|
151
255
|
[]
|
|
152
256
|
);
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
257
|
+
const captureCanvasFrame = useCallback(
|
|
258
|
+
async (time, options = {}) => {
|
|
259
|
+
const container = containerRef.current;
|
|
260
|
+
const api = renderAPIRef.current;
|
|
261
|
+
const captureCanvas = captureCanvasRef.current;
|
|
262
|
+
if (!container || !api || !captureCanvas) {
|
|
263
|
+
throw new Error("Frame capture not initialized \u2014 call init() first");
|
|
264
|
+
}
|
|
265
|
+
const { width, height } = dimensionsRef.current;
|
|
266
|
+
await api.seekTo(time);
|
|
267
|
+
await waitForVisualUpdate(2);
|
|
268
|
+
const root = container.querySelector("#squisq-capture-root");
|
|
269
|
+
if (!root) {
|
|
270
|
+
throw new Error("Capture root element not found");
|
|
271
|
+
}
|
|
272
|
+
const visualStateKey = options.reuseIfUnchanged ? getFrameVisualStateKey(root, time) : null;
|
|
273
|
+
if (visualStateKey !== null && hasCapturedFrameRef.current && lastVisualStateKeyRef.current === visualStateKey) {
|
|
274
|
+
return captureCanvas;
|
|
275
|
+
}
|
|
276
|
+
const captureContext = captureCanvas.getContext("2d");
|
|
277
|
+
if (!captureContext) throw new Error("Could not create the frame capture canvas context");
|
|
278
|
+
captureContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
279
|
+
captureContext.clearRect(0, 0, width, height);
|
|
280
|
+
const canvas = await html2canvas(root, {
|
|
281
|
+
canvas: captureCanvas,
|
|
282
|
+
width,
|
|
283
|
+
height,
|
|
284
|
+
scale: 1,
|
|
285
|
+
useCORS: true,
|
|
286
|
+
allowTaint: true,
|
|
287
|
+
backgroundColor: "#000000",
|
|
288
|
+
logging: false,
|
|
289
|
+
// html2canvas starts cloning at documentElement. Do not clone the rest
|
|
290
|
+
// of the editor/site UI on every frame; only the capture root, its
|
|
291
|
+
// ancestors, descendants, and document styles can affect this render.
|
|
292
|
+
ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
|
|
293
|
+
});
|
|
294
|
+
hasCapturedFrameRef.current = true;
|
|
295
|
+
lastVisualStateKeyRef.current = visualStateKey;
|
|
296
|
+
return canvas;
|
|
297
|
+
},
|
|
298
|
+
[]
|
|
299
|
+
);
|
|
300
|
+
const captureFrame = useCallback(
|
|
301
|
+
async (time, options = {}) => {
|
|
302
|
+
const canvas = await captureCanvasFrame(time, options);
|
|
303
|
+
return createImageBitmap(canvas);
|
|
304
|
+
},
|
|
305
|
+
[captureCanvasFrame]
|
|
306
|
+
);
|
|
180
307
|
const destroy = useCallback(() => {
|
|
181
308
|
if (rootRef.current) {
|
|
182
309
|
rootRef.current.unmount();
|
|
@@ -188,9 +315,19 @@ function useFrameCapture() {
|
|
|
188
315
|
}
|
|
189
316
|
mediaProviderRef.current?.dispose();
|
|
190
317
|
mediaProviderRef.current = null;
|
|
318
|
+
if (captureCanvasRef.current) {
|
|
319
|
+
captureCanvasRef.current.width = 0;
|
|
320
|
+
captureCanvasRef.current.height = 0;
|
|
321
|
+
captureCanvasRef.current = null;
|
|
322
|
+
}
|
|
323
|
+
lastVisualStateKeyRef.current = null;
|
|
324
|
+
hasCapturedFrameRef.current = false;
|
|
191
325
|
renderAPIRef.current = null;
|
|
192
326
|
}, []);
|
|
193
|
-
return useMemo(
|
|
327
|
+
return useMemo(
|
|
328
|
+
() => ({ init, captureFrame, captureCanvasFrame, destroy }),
|
|
329
|
+
[init, captureFrame, captureCanvasFrame, destroy]
|
|
330
|
+
);
|
|
194
331
|
}
|
|
195
332
|
|
|
196
333
|
// src/hooks/useVideoExport.ts
|
|
@@ -290,7 +427,11 @@ function createWorkerEncoder(config) {
|
|
|
290
427
|
});
|
|
291
428
|
return {
|
|
292
429
|
ready,
|
|
293
|
-
encodeFrame(
|
|
430
|
+
encodeFrame(frame, frameIndex) {
|
|
431
|
+
if (typeof HTMLCanvasElement !== "undefined" && frame instanceof HTMLCanvasElement) {
|
|
432
|
+
return Promise.reject(new Error("Worker encoding requires a transferable ImageBitmap"));
|
|
433
|
+
}
|
|
434
|
+
const bitmap = frame;
|
|
294
435
|
if (state !== "open" || fatalError) {
|
|
295
436
|
bitmap.close();
|
|
296
437
|
return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
|
|
@@ -345,11 +486,37 @@ function createWorkerEncoder(config) {
|
|
|
345
486
|
|
|
346
487
|
// src/gifTranscode.ts
|
|
347
488
|
import {
|
|
348
|
-
|
|
489
|
+
ffmpegGifPaletteApplicationArgs,
|
|
490
|
+
ffmpegGifPaletteGenerationFilter,
|
|
349
491
|
resolveFfmpegWasmLoad
|
|
350
492
|
} from "@bendyline/squisq-video";
|
|
493
|
+
function buildGifPaletteFfmpegArgs(options) {
|
|
494
|
+
return [
|
|
495
|
+
"-y",
|
|
496
|
+
"-i",
|
|
497
|
+
"video.mp4",
|
|
498
|
+
"-vf",
|
|
499
|
+
ffmpegGifPaletteGenerationFilter(options),
|
|
500
|
+
"-frames:v",
|
|
501
|
+
"1",
|
|
502
|
+
"palette.png"
|
|
503
|
+
];
|
|
504
|
+
}
|
|
351
505
|
function buildGifFfmpegArgs(options) {
|
|
352
|
-
return [
|
|
506
|
+
return [
|
|
507
|
+
"-y",
|
|
508
|
+
"-i",
|
|
509
|
+
"video.mp4",
|
|
510
|
+
"-i",
|
|
511
|
+
"palette.png",
|
|
512
|
+
...ffmpegGifPaletteApplicationArgs(options),
|
|
513
|
+
"out.gif"
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
var FFMPEG_ERRORISH = /error|invalid|failed|out of memory|memory access|abort|unable to/i;
|
|
517
|
+
function ffmpegFailureDetail(logs) {
|
|
518
|
+
const lines = logs.map((line) => line.trim()).filter(Boolean);
|
|
519
|
+
return lines.find((line) => FFMPEG_ERRORISH.test(line)) ?? lines.at(-1) ?? null;
|
|
353
520
|
}
|
|
354
521
|
async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
|
|
355
522
|
if (videoMp4.byteLength === 0) {
|
|
@@ -361,9 +528,16 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
|
|
|
361
528
|
const load = resolveFfmpegWasmLoad(loadConfig, "Animated GIF export", {
|
|
362
529
|
classWorkerURL: new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
|
|
363
530
|
});
|
|
364
|
-
const
|
|
531
|
+
const paletteArgs = buildGifPaletteFfmpegArgs(options);
|
|
532
|
+
const gifArgs = buildGifFfmpegArgs(options);
|
|
365
533
|
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
|
|
366
534
|
const ffmpeg = new FFmpeg();
|
|
535
|
+
const recentLogs = [];
|
|
536
|
+
const handleLog = ({ message }) => {
|
|
537
|
+
recentLogs.push(message);
|
|
538
|
+
if (recentLogs.length > 40) recentLogs.shift();
|
|
539
|
+
};
|
|
540
|
+
ffmpeg.on("log", handleLog);
|
|
367
541
|
let terminated = false;
|
|
368
542
|
const terminate = () => {
|
|
369
543
|
if (terminated) return;
|
|
@@ -381,17 +555,38 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
|
|
|
381
555
|
if (signal?.aborted) {
|
|
382
556
|
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
383
557
|
}
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
558
|
+
const execPhase = async (args, phase) => {
|
|
559
|
+
recentLogs.length = 0;
|
|
560
|
+
let exitCode;
|
|
561
|
+
try {
|
|
562
|
+
exitCode = await ffmpeg.exec(args);
|
|
563
|
+
} catch (caught) {
|
|
564
|
+
if (signal?.aborted) {
|
|
565
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
566
|
+
}
|
|
567
|
+
const detail = ffmpegFailureDetail(recentLogs);
|
|
568
|
+
const fallback = caught instanceof Error ? caught.message : String(caught);
|
|
569
|
+
throw new Error(`ffmpeg.wasm GIF transcode failed during ${phase}: ${detail ?? fallback}`);
|
|
570
|
+
}
|
|
571
|
+
if (signal?.aborted) {
|
|
572
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
573
|
+
}
|
|
574
|
+
if (exitCode !== 0) {
|
|
575
|
+
const detail = ffmpegFailureDetail(recentLogs);
|
|
576
|
+
throw new Error(
|
|
577
|
+
`ffmpeg.wasm GIF transcode failed during ${phase} with exit code ${exitCode}` + (detail ? `: ${detail}` : "")
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
await execPhase(paletteArgs, "palette generation");
|
|
582
|
+
await execPhase(gifArgs, "palette application");
|
|
583
|
+
await ffmpeg.deleteFile("video.mp4").catch(() => false);
|
|
584
|
+
await ffmpeg.deleteFile("palette.png").catch(() => false);
|
|
391
585
|
const data = await ffmpeg.readFile("out.gif");
|
|
392
586
|
return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
393
587
|
} finally {
|
|
394
588
|
signal?.removeEventListener("abort", handleAbort);
|
|
589
|
+
ffmpeg.off("log", handleLog);
|
|
395
590
|
terminate();
|
|
396
591
|
}
|
|
397
592
|
}
|
|
@@ -400,6 +595,41 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
|
|
|
400
595
|
var MAX_EXPORT_MEDIA_FILES = 256;
|
|
401
596
|
var MAX_EXPORT_MEDIA_FILE_BYTES = 64 * 1024 * 1024;
|
|
402
597
|
var MAX_EXPORT_MEDIA_TOTAL_BYTES = 256 * 1024 * 1024;
|
|
598
|
+
var ENCODER_PROBE_TIMEOUT_MS = 5e3;
|
|
599
|
+
var ENCODER_START_TIMEOUT_MS = 6e4;
|
|
600
|
+
var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
|
|
601
|
+
var FRAME_ENCODE_TIMEOUT_MS = 6e4;
|
|
602
|
+
var CAPTURE_PROGRESS_START = 7;
|
|
603
|
+
var CAPTURE_PROGRESS_END = 95;
|
|
604
|
+
function releaseEncoderFrame(frame) {
|
|
605
|
+
if ("close" in frame) frame.close();
|
|
606
|
+
}
|
|
607
|
+
function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
|
|
608
|
+
return new Promise((resolve, reject) => {
|
|
609
|
+
let settled = false;
|
|
610
|
+
const timeout = globalThis.setTimeout(() => {
|
|
611
|
+
settled = true;
|
|
612
|
+
reject(new Error(timeoutMessage));
|
|
613
|
+
}, timeoutMs);
|
|
614
|
+
void operation.then(
|
|
615
|
+
(value) => {
|
|
616
|
+
if (settled) {
|
|
617
|
+
onLateResult?.(value);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
settled = true;
|
|
621
|
+
globalThis.clearTimeout(timeout);
|
|
622
|
+
resolve(value);
|
|
623
|
+
},
|
|
624
|
+
(caught) => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
globalThis.clearTimeout(timeout);
|
|
628
|
+
reject(caught);
|
|
629
|
+
}
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
403
633
|
function toArrayBuffer(bytes) {
|
|
404
634
|
return bytes.slice().buffer;
|
|
405
635
|
}
|
|
@@ -535,6 +765,7 @@ function useVideoExport() {
|
|
|
535
765
|
const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
|
|
536
766
|
const orientation = config.orientation ?? "landscape";
|
|
537
767
|
const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
|
|
768
|
+
const captionMode = config.captionMode ?? (effectiveOutputFormat === "gif" ? "standard" : "off");
|
|
538
769
|
const audioPolicy = config.audioPolicy ?? "require";
|
|
539
770
|
setOutputFormat(effectiveOutputFormat);
|
|
540
771
|
try {
|
|
@@ -612,7 +843,7 @@ function useVideoExport() {
|
|
|
612
843
|
const docDuration = await frameCapture.init(
|
|
613
844
|
doc,
|
|
614
845
|
{ images, audio: config.audio, width, height, animationsEnabled },
|
|
615
|
-
|
|
846
|
+
captionMode
|
|
616
847
|
);
|
|
617
848
|
if (cancelledRef.current) return;
|
|
618
849
|
setDuration(docDuration);
|
|
@@ -620,9 +851,13 @@ function useVideoExport() {
|
|
|
620
851
|
throw new Error("Document has zero duration \u2014 nothing to export");
|
|
621
852
|
}
|
|
622
853
|
const totalFrames = Math.ceil(docDuration * fps);
|
|
623
|
-
setPhase("
|
|
854
|
+
setPhase("Checking video encoder\u2026");
|
|
624
855
|
setProgress(5);
|
|
625
|
-
const canUseWebCodecs = webCodecsAvailable && await
|
|
856
|
+
const canUseWebCodecs = webCodecsAvailable && await settleWithin(
|
|
857
|
+
supportsWebCodecsH264({ width, height, fps, quality }),
|
|
858
|
+
ENCODER_PROBE_TIMEOUT_MS,
|
|
859
|
+
"The browser did not finish checking WebCodecs support."
|
|
860
|
+
).catch(() => false);
|
|
626
861
|
const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
|
|
627
862
|
const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, 0) : [];
|
|
628
863
|
const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
|
|
@@ -691,8 +926,11 @@ function useVideoExport() {
|
|
|
691
926
|
}
|
|
692
927
|
} : {}
|
|
693
928
|
});
|
|
929
|
+
encoderRef.current = encoder;
|
|
694
930
|
setBackend("webcodecs");
|
|
695
931
|
} else if (sharedArrayBufferAvailable) {
|
|
932
|
+
setProgress(6);
|
|
933
|
+
setPhase("Loading export engine\u2026");
|
|
696
934
|
const workerEncoder = createWorkerEncoder({
|
|
697
935
|
width,
|
|
698
936
|
height,
|
|
@@ -702,41 +940,54 @@ function useVideoExport() {
|
|
|
702
940
|
ffmpegWasm: config.ffmpegWasm
|
|
703
941
|
});
|
|
704
942
|
encoder = workerEncoder;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
943
|
+
encoderRef.current = workerEncoder;
|
|
944
|
+
const selectedBackend = await settleWithin(
|
|
945
|
+
workerEncoder.ready,
|
|
946
|
+
ENCODER_START_TIMEOUT_MS,
|
|
947
|
+
"The browser export engine did not start within 60 seconds."
|
|
709
948
|
);
|
|
949
|
+
setBackend(selectedBackend);
|
|
710
950
|
} else {
|
|
711
951
|
throw new Error(
|
|
712
952
|
"WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
713
953
|
);
|
|
714
954
|
}
|
|
715
|
-
encoderRef.current = encoder;
|
|
716
955
|
if (cancelledRef.current) return;
|
|
956
|
+
setProgress(CAPTURE_PROGRESS_START);
|
|
957
|
+
setPhase(`Capturing frame 1/${totalFrames} (0.0s)`);
|
|
717
958
|
setState("capturing");
|
|
718
959
|
const captureStartTime = performance.now();
|
|
719
|
-
const UI_UPDATE_INTERVAL = 10;
|
|
720
960
|
for (let i = 0; i < totalFrames; i++) {
|
|
721
961
|
if (cancelledRef.current) return;
|
|
722
962
|
const time = i / fps;
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
const remaining = Math.round(avgPerFrame * (totalFrames - i));
|
|
731
|
-
setEstimatedRemaining(remaining);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
const bitmap = await frameCapture.captureFrame(time);
|
|
963
|
+
const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(time, { reuseIfUnchanged: true }) : frameCapture.captureFrame(time, { reuseIfUnchanged: true });
|
|
964
|
+
const frame = await settleWithin(
|
|
965
|
+
captureOperation,
|
|
966
|
+
FRAME_CAPTURE_TIMEOUT_MS,
|
|
967
|
+
`Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
|
|
968
|
+
releaseEncoderFrame
|
|
969
|
+
);
|
|
735
970
|
if (cancelledRef.current) {
|
|
736
|
-
|
|
971
|
+
releaseEncoderFrame(frame);
|
|
737
972
|
return;
|
|
738
973
|
}
|
|
739
|
-
|
|
974
|
+
setPhase(`Encoding frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
|
|
975
|
+
await settleWithin(
|
|
976
|
+
encoder.encodeFrame(frame, i),
|
|
977
|
+
FRAME_ENCODE_TIMEOUT_MS,
|
|
978
|
+
`Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
|
|
979
|
+
);
|
|
980
|
+
const completedFrames = i + 1;
|
|
981
|
+
setPhase(
|
|
982
|
+
completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames} (${(completedFrames / fps).toFixed(1)}s)` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
|
|
983
|
+
);
|
|
984
|
+
const captureRatio = completedFrames / totalFrames;
|
|
985
|
+
const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
|
|
986
|
+
setProgress(Math.round(captureProgress * 10) / 10);
|
|
987
|
+
const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
|
|
988
|
+
const avgPerFrame = elapsedCapture / completedFrames;
|
|
989
|
+
setEstimatedRemaining(Math.round(avgPerFrame * (totalFrames - completedFrames)));
|
|
990
|
+
setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
|
|
740
991
|
}
|
|
741
992
|
if (cancelledRef.current) return;
|
|
742
993
|
if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
|
|
@@ -1923,7 +1923,12 @@ function createMp4Muxer(options) {
|
|
|
1923
1923
|
sampleRate: options.audio.sampleRate
|
|
1924
1924
|
}
|
|
1925
1925
|
} : {},
|
|
1926
|
-
|
|
1926
|
+
// The result is already complete before it is downloaded or passed to the
|
|
1927
|
+
// GIF transcode, so browser-style fast start provides no benefit here.
|
|
1928
|
+
// `in-memory` retains every encoded sample until finalize; regular MP4
|
|
1929
|
+
// layout writes each compressed chunk into the output as it arrives and
|
|
1930
|
+
// releases the sample payload immediately.
|
|
1931
|
+
fastStart: false
|
|
1927
1932
|
});
|
|
1928
1933
|
return {
|
|
1929
1934
|
hasAudioTrack: options.audio !== void 0,
|
|
@@ -1946,6 +1951,30 @@ function createMp4Muxer(options) {
|
|
|
1946
1951
|
};
|
|
1947
1952
|
}
|
|
1948
1953
|
|
|
1954
|
+
// src/encoderMemory.ts
|
|
1955
|
+
var MAX_WEBCODECS_QUEUE_BYTES = 64 * 1024 * 1024;
|
|
1956
|
+
var MAX_WEBCODECS_QUEUE_SECONDS = 1;
|
|
1957
|
+
var MAX_FFMPEG_FRAME_BATCH_BYTES = 64 * 1024 * 1024;
|
|
1958
|
+
var MAX_FFMPEG_FRAME_BATCH_SECONDS = 6;
|
|
1959
|
+
function resolveWebCodecsQueueLimit({ width, height, fps }) {
|
|
1960
|
+
const bytesPerFrame = Math.max(1, width * height * 4);
|
|
1961
|
+
const framesByBytes = Math.max(1, Math.floor(MAX_WEBCODECS_QUEUE_BYTES / bytesPerFrame));
|
|
1962
|
+
const framesByTime = Math.max(1, Math.ceil(fps * MAX_WEBCODECS_QUEUE_SECONDS));
|
|
1963
|
+
return Math.min(framesByBytes, framesByTime);
|
|
1964
|
+
}
|
|
1965
|
+
async function applyWebCodecsBackpressure(encoder, queueLimit, submittedSinceFlush = encoder.encodeQueueSize) {
|
|
1966
|
+
if (encoder.encodeQueueSize < queueLimit && submittedSinceFlush < queueLimit) return false;
|
|
1967
|
+
await encoder.flush();
|
|
1968
|
+
return true;
|
|
1969
|
+
}
|
|
1970
|
+
function shouldEncodeFfmpegBatch(frameCount, byteLength, fps) {
|
|
1971
|
+
const framesByTime = Math.max(1, Math.ceil(fps * MAX_FFMPEG_FRAME_BATCH_SECONDS));
|
|
1972
|
+
return frameCount >= framesByTime || byteLength >= MAX_FFMPEG_FRAME_BATCH_BYTES;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1949
1975
|
export {
|
|
1950
|
-
createMp4Muxer
|
|
1976
|
+
createMp4Muxer,
|
|
1977
|
+
resolveWebCodecsQueueLimit,
|
|
1978
|
+
applyWebCodecsBackpressure,
|
|
1979
|
+
shouldEncodeFfmpegBatch
|
|
1951
1980
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useVideoExport
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-IDY64F4K.js";
|
|
4
4
|
|
|
5
5
|
// src/VideoExportModal.tsx
|
|
6
6
|
import { useState, useCallback, useId, useRef } from "react";
|
|
@@ -12,12 +12,6 @@ function formatDuration(seconds) {
|
|
|
12
12
|
const s = seconds % 60;
|
|
13
13
|
return `${m}m ${s}s`;
|
|
14
14
|
}
|
|
15
|
-
function encoderLabel(outputFormat, backend) {
|
|
16
|
-
if (outputFormat === "gif") {
|
|
17
|
-
return backend === "webcodecs" ? "WebCodecs (H.264) \u2192 ffmpeg.wasm (GIF)" : "ffmpeg.wasm (H.264 \u2192 GIF)";
|
|
18
|
-
}
|
|
19
|
-
return backend === "webcodecs" ? "WebCodecs (H.264)" : "ffmpeg.wasm (H.264)";
|
|
20
|
-
}
|
|
21
15
|
var VIDEO_EXPORT_PALETTES = {
|
|
22
16
|
light: {
|
|
23
17
|
overlay: "rgba(0, 0, 0, 0.5)",
|
|
@@ -61,6 +55,7 @@ var overlayStyle = {
|
|
|
61
55
|
zIndex: 1e4
|
|
62
56
|
};
|
|
63
57
|
var modalStyle = {
|
|
58
|
+
position: "relative",
|
|
64
59
|
borderRadius: 0,
|
|
65
60
|
padding: "24px 28px",
|
|
66
61
|
minWidth: 380,
|
|
@@ -70,9 +65,24 @@ var modalStyle = {
|
|
|
70
65
|
};
|
|
71
66
|
var titleStyle = {
|
|
72
67
|
margin: "0 0 16px 0",
|
|
68
|
+
paddingRight: 32,
|
|
73
69
|
fontSize: 18,
|
|
74
70
|
fontWeight: 600
|
|
75
71
|
};
|
|
72
|
+
var closeButtonStyle = {
|
|
73
|
+
position: "absolute",
|
|
74
|
+
top: 12,
|
|
75
|
+
right: 12,
|
|
76
|
+
width: 32,
|
|
77
|
+
height: 32,
|
|
78
|
+
padding: 0,
|
|
79
|
+
border: 0,
|
|
80
|
+
background: "transparent",
|
|
81
|
+
fontFamily: "inherit",
|
|
82
|
+
fontSize: 24,
|
|
83
|
+
lineHeight: 1,
|
|
84
|
+
cursor: "pointer"
|
|
85
|
+
};
|
|
76
86
|
var labelStyle = {
|
|
77
87
|
display: "block",
|
|
78
88
|
fontSize: 13,
|
|
@@ -137,7 +147,9 @@ function VideoExportModal({
|
|
|
137
147
|
const [orientation, setOrientation] = useState(
|
|
138
148
|
defaultConfig?.orientation ?? "landscape"
|
|
139
149
|
);
|
|
140
|
-
const [captionMode, setCaptionMode] = useState(
|
|
150
|
+
const [captionMode, setCaptionMode] = useState(
|
|
151
|
+
defaultConfig?.captionMode ?? (initialOutputFormat === "gif" ? "standard" : "off")
|
|
152
|
+
);
|
|
141
153
|
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
142
154
|
defaultConfig?.animationsEnabled ?? initialOutputFormat === "mp4"
|
|
143
155
|
);
|
|
@@ -180,7 +192,7 @@ function VideoExportModal({
|
|
|
180
192
|
const {
|
|
181
193
|
state,
|
|
182
194
|
progress,
|
|
183
|
-
|
|
195
|
+
phase,
|
|
184
196
|
outputFormat: completedOutputFormat,
|
|
185
197
|
downloadUrl,
|
|
186
198
|
fileSize,
|
|
@@ -198,9 +210,11 @@ function VideoExportModal({
|
|
|
198
210
|
if (next === "gif") {
|
|
199
211
|
setFps(10);
|
|
200
212
|
setAnimationsEnabled(false);
|
|
213
|
+
setCaptionMode("standard");
|
|
201
214
|
} else {
|
|
202
215
|
setFps(24);
|
|
203
216
|
setAnimationsEnabled(true);
|
|
217
|
+
setCaptionMode("off");
|
|
204
218
|
}
|
|
205
219
|
}, []);
|
|
206
220
|
const handleExport = useCallback(async () => {
|
|
@@ -250,19 +264,25 @@ function VideoExportModal({
|
|
|
250
264
|
const handleClose = useCallback(() => {
|
|
251
265
|
if (state === "capturing" || state === "encoding" || state === "preparing") {
|
|
252
266
|
cancelExport();
|
|
267
|
+
} else {
|
|
268
|
+
resetExport();
|
|
253
269
|
}
|
|
254
|
-
resetExport();
|
|
255
270
|
onClose();
|
|
256
271
|
}, [state, cancelExport, resetExport, onClose]);
|
|
257
272
|
const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
|
|
258
|
-
useModalDialog({
|
|
273
|
+
useModalDialog({
|
|
274
|
+
rootRef: overlayRef,
|
|
275
|
+
dialogRef,
|
|
276
|
+
closeOnEscape: false,
|
|
277
|
+
onClose: handleClose
|
|
278
|
+
});
|
|
259
279
|
return /* @__PURE__ */ jsx(
|
|
260
280
|
"div",
|
|
261
281
|
{
|
|
262
282
|
ref: overlayRef,
|
|
263
283
|
style: { ...overlayStyle, background: palette.overlay },
|
|
264
284
|
"data-color-scheme": colorScheme,
|
|
265
|
-
onClick:
|
|
285
|
+
onClick: (event) => event.stopPropagation(),
|
|
266
286
|
children: /* @__PURE__ */ jsxs(
|
|
267
287
|
"div",
|
|
268
288
|
{
|
|
@@ -276,6 +296,16 @@ function VideoExportModal({
|
|
|
276
296
|
onClick: (e) => e.stopPropagation(),
|
|
277
297
|
children: [
|
|
278
298
|
/* @__PURE__ */ jsx("h2", { id: titleId, style: themedTitleStyle, children: outputFormat === "gif" ? "Export Animated GIF" : "Export Video" }),
|
|
299
|
+
/* @__PURE__ */ jsx(
|
|
300
|
+
"button",
|
|
301
|
+
{
|
|
302
|
+
type: "button",
|
|
303
|
+
"aria-label": "Close export dialog",
|
|
304
|
+
style: { ...closeButtonStyle, color: palette.label },
|
|
305
|
+
onClick: handleClose,
|
|
306
|
+
children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" })
|
|
307
|
+
}
|
|
308
|
+
),
|
|
279
309
|
state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
280
310
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
281
311
|
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Format" }),
|
|
@@ -409,10 +439,6 @@ function VideoExportModal({
|
|
|
409
439
|
] })
|
|
410
440
|
] }),
|
|
411
441
|
isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
412
|
-
backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 8px 0" }, children: [
|
|
413
|
-
"Encoder: ",
|
|
414
|
-
encoderLabel(completedOutputFormat, backend)
|
|
415
|
-
] }),
|
|
416
442
|
/* @__PURE__ */ jsx(
|
|
417
443
|
"div",
|
|
418
444
|
{
|
|
@@ -441,12 +467,20 @@ function VideoExportModal({
|
|
|
441
467
|
progress,
|
|
442
468
|
"% complete"
|
|
443
469
|
] }),
|
|
470
|
+
phase && /* @__PURE__ */ jsx(
|
|
471
|
+
"p",
|
|
472
|
+
{
|
|
473
|
+
style: { fontSize: 12, color: palette.label, margin: "0 0 4px 0" },
|
|
474
|
+
"data-squisq-video-export-phase": true,
|
|
475
|
+
children: phase
|
|
476
|
+
}
|
|
477
|
+
),
|
|
444
478
|
/* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: 0 }, children: [
|
|
445
479
|
formatDuration(elapsed),
|
|
446
480
|
" elapsed",
|
|
447
481
|
estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
|
|
448
482
|
] }),
|
|
449
|
-
/* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick:
|
|
483
|
+
/* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Cancel" }) })
|
|
450
484
|
] }),
|
|
451
485
|
state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
452
486
|
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.success }, children: "Export complete!" }),
|
|
@@ -459,10 +493,6 @@ function VideoExportModal({
|
|
|
459
493
|
"Video only",
|
|
460
494
|
audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
|
|
461
495
|
] }),
|
|
462
|
-
backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 12px 0" }, children: [
|
|
463
|
-
"Encoded with ",
|
|
464
|
-
encoderLabel(completedOutputFormat, backend)
|
|
465
|
-
] }),
|
|
466
496
|
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
467
497
|
/* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
|
|
468
498
|
/* @__PURE__ */ jsxs("button", { style: themedPrimaryButtonStyle, onClick: handleDownload, children: [
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
applyWebCodecsBackpressure,
|
|
3
|
+
createMp4Muxer,
|
|
4
|
+
resolveWebCodecsQueueLimit
|
|
5
|
+
} from "./chunk-MEPETH5V.js";
|
|
4
6
|
|
|
5
7
|
// src/mainThreadEncoder.ts
|
|
6
8
|
import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
|
|
@@ -38,6 +40,8 @@ function createEncoder(config) {
|
|
|
38
40
|
let closed = false;
|
|
39
41
|
let fatalError = null;
|
|
40
42
|
const frameDuration = 1e6 / config.fps;
|
|
43
|
+
const queueLimit = resolveWebCodecsQueueLimit(config);
|
|
44
|
+
let framesSinceFlush = 0;
|
|
41
45
|
function fail(err) {
|
|
42
46
|
closed = true;
|
|
43
47
|
if (encoder.state !== "closed") encoder.close();
|
|
@@ -64,21 +68,34 @@ function createEncoder(config) {
|
|
|
64
68
|
framerate: config.fps
|
|
65
69
|
});
|
|
66
70
|
return {
|
|
67
|
-
async encodeFrame(
|
|
71
|
+
async encodeFrame(source, frameIndex) {
|
|
68
72
|
if (fatalError) {
|
|
69
|
-
|
|
73
|
+
if ("close" in source) source.close();
|
|
70
74
|
throw fail(fatalError);
|
|
71
75
|
}
|
|
72
76
|
if (closed) {
|
|
73
|
-
|
|
77
|
+
if ("close" in source) source.close();
|
|
74
78
|
throw new Error("Encoder already closed");
|
|
75
79
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
try {
|
|
81
|
+
const drained = await applyWebCodecsBackpressure(encoder, queueLimit, framesSinceFlush);
|
|
82
|
+
if (drained) framesSinceFlush = 0;
|
|
83
|
+
if (fatalError) throw fail(fatalError);
|
|
84
|
+
if (closed) throw new Error("Encoder already closed");
|
|
85
|
+
const timestamp = Math.round(frameIndex * frameDuration);
|
|
86
|
+
const frame = new VideoFrame(source, { timestamp });
|
|
87
|
+
try {
|
|
88
|
+
const keyFrame = frameIndex % 30 === 0;
|
|
89
|
+
encoder.encode(frame, { keyFrame });
|
|
90
|
+
framesSinceFlush++;
|
|
91
|
+
} finally {
|
|
92
|
+
frame.close();
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw fail(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
} finally {
|
|
97
|
+
if ("close" in source) source.close();
|
|
98
|
+
}
|
|
82
99
|
},
|
|
83
100
|
addAudioChunk(chunk, meta) {
|
|
84
101
|
if (closed || !muxer.hasAudioTrack) return;
|
|
@@ -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 } from '../useVideoExport-DAEglCva.js';
|
|
4
4
|
import '@bendyline/squisq/markdown';
|
|
5
5
|
import '@bendyline/squisq-video';
|
|
6
6
|
import '@bendyline/squisq-react';
|
package/dist/components/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VideoExportButton,
|
|
3
3
|
VideoExportModal
|
|
4
|
-
} from "../chunk-
|
|
5
|
-
import "../chunk-
|
|
6
|
-
import "../chunk-
|
|
7
|
-
import "../chunk-
|
|
4
|
+
} from "../chunk-ULNKIV4A.js";
|
|
5
|
+
import "../chunk-IDY64F4K.js";
|
|
6
|
+
import "../chunk-ZQJBUX75.js";
|
|
7
|
+
import "../chunk-MEPETH5V.js";
|
|
8
8
|
export {
|
|
9
9
|
VideoExportButton,
|
|
10
10
|
VideoExportModal
|
package/dist/encoder/index.d.ts
CHANGED
|
@@ -34,9 +34,10 @@ interface EncoderConfig {
|
|
|
34
34
|
sampleRate: number;
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
+
type EncoderFrameSource = ImageBitmap | HTMLCanvasElement;
|
|
37
38
|
interface MainThreadEncoder {
|
|
38
|
-
/** Encode a single frame.
|
|
39
|
-
encodeFrame(
|
|
39
|
+
/** Encode a single frame. ImageBitmap sources are closed after encoding. */
|
|
40
|
+
encodeFrame(frame: EncoderFrameSource, frameIndex: number): Promise<void>;
|
|
40
41
|
/**
|
|
41
42
|
* Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
|
|
42
43
|
* Only valid when the encoder was created with an `audio` config; otherwise a
|
|
@@ -92,4 +93,4 @@ declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
|
|
|
92
93
|
*/
|
|
93
94
|
declare function supportsWebCodecsAac(sampleRate?: number, channels?: number): Promise<boolean>;
|
|
94
95
|
|
|
95
|
-
export { type EncoderConfig, type MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 };
|
|
96
|
+
export { type EncoderConfig, type EncoderFrameSource, type MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 };
|
package/dist/encoder/index.js
CHANGED
package/dist/hooks/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from '../useVideoExport-
|
|
1
|
+
export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from '../useVideoExport-DAEglCva.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';
|
|
@@ -13,14 +13,27 @@ import '@bendyline/squisq/markdown';
|
|
|
13
13
|
*
|
|
14
14
|
* Uses React directly — no script injection, no iframes, no eval.
|
|
15
15
|
*
|
|
16
|
-
* Returns
|
|
16
|
+
* Returns either a reusable canvas or an ImageBitmap snapshot for a Worker.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
interface FrameCaptureOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Reuse the previous raster when seeking produced the same visual state.
|
|
22
|
+
* The timeline is still advanced so animations, captions, and media remain
|
|
23
|
+
* correct; only the expensive html2canvas pass is skipped.
|
|
24
|
+
*/
|
|
25
|
+
reuseIfUnchanged?: boolean;
|
|
26
|
+
}
|
|
19
27
|
interface FrameCaptureHandle {
|
|
20
28
|
/** Initialize the hidden player. Returns the video duration in seconds. */
|
|
21
29
|
init: (doc: Doc, renderOptions: Omit<RenderHtmlOptions, 'playerScript'>, captionMode?: CaptionMode) => Promise<number>;
|
|
22
30
|
/** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
|
|
23
|
-
captureFrame: (time: number) => Promise<ImageBitmap>;
|
|
31
|
+
captureFrame: (time: number, options?: FrameCaptureOptions) => Promise<ImageBitmap>;
|
|
32
|
+
/**
|
|
33
|
+
* Render into the hook's reusable canvas without allocating an ImageBitmap.
|
|
34
|
+
* The canvas remains valid until the next capture or destroy call.
|
|
35
|
+
*/
|
|
36
|
+
captureCanvasFrame: (time: number, options?: FrameCaptureOptions) => Promise<HTMLCanvasElement>;
|
|
24
37
|
/** Clean up resources. */
|
|
25
38
|
destroy: () => void;
|
|
26
39
|
}
|
|
@@ -29,4 +42,4 @@ interface FrameCaptureHandle {
|
|
|
29
42
|
*/
|
|
30
43
|
declare function useFrameCapture(): FrameCaptureHandle;
|
|
31
44
|
|
|
32
|
-
export { type FrameCaptureHandle, useFrameCapture };
|
|
45
|
+
export { type FrameCaptureHandle, type FrameCaptureOptions, useFrameCapture };
|
package/dist/hooks/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useFrameCapture,
|
|
3
3
|
useVideoExport
|
|
4
|
-
} from "../chunk-
|
|
5
|
-
import "../chunk-
|
|
6
|
-
import "../chunk-
|
|
4
|
+
} from "../chunk-IDY64F4K.js";
|
|
5
|
+
import "../chunk-ZQJBUX75.js";
|
|
6
|
+
import "../chunk-MEPETH5V.js";
|
|
7
7
|
export {
|
|
8
8
|
useFrameCapture,
|
|
9
9
|
useVideoExport
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { VideoExportButton, VideoExportButtonProps, VideoExportModal, VideoExportModalProps, VideoExportPalette } from './components/index.js';
|
|
2
|
-
export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from './useVideoExport-
|
|
3
|
-
export { FrameCaptureHandle, useFrameCapture } from './hooks/index.js';
|
|
4
|
-
export { EncoderConfig, MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 } from './encoder/index.js';
|
|
2
|
+
export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from './useVideoExport-DAEglCva.js';
|
|
3
|
+
export { FrameCaptureHandle, FrameCaptureOptions, useFrameCapture } from './hooks/index.js';
|
|
4
|
+
export { EncoderConfig, EncoderFrameSource, MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 } from './encoder/index.js';
|
|
5
5
|
export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
|
|
6
6
|
import 'react/jsx-runtime';
|
|
7
7
|
import '@bendyline/squisq/schemas';
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VideoExportButton,
|
|
3
3
|
VideoExportModal
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-ULNKIV4A.js";
|
|
5
5
|
import {
|
|
6
6
|
useFrameCapture,
|
|
7
7
|
useVideoExport
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-IDY64F4K.js";
|
|
9
9
|
import {
|
|
10
10
|
createEncoder,
|
|
11
11
|
supportsWebCodecs,
|
|
12
12
|
supportsWebCodecsAac,
|
|
13
13
|
supportsWebCodecsH264
|
|
14
|
-
} from "./chunk-
|
|
15
|
-
import "./chunk-
|
|
14
|
+
} from "./chunk-ZQJBUX75.js";
|
|
15
|
+
import "./chunk-MEPETH5V.js";
|
|
16
16
|
export {
|
|
17
17
|
VideoExportButton,
|
|
18
18
|
VideoExportModal,
|
|
@@ -60,7 +60,7 @@ interface VideoExportConfig {
|
|
|
60
60
|
mediaProvider?: MediaProvider;
|
|
61
61
|
/** Policy and byte/time limits for MediaProvider URLs. */
|
|
62
62
|
resourcePolicy?: ResourcePolicy;
|
|
63
|
-
/** Caption mode for the exported video (default: 'off') */
|
|
63
|
+
/** Caption mode for the exported video (default: 'off' for MP4, 'standard' for GIF). */
|
|
64
64
|
captionMode?: CaptionMode;
|
|
65
65
|
/** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */
|
|
66
66
|
playerScript?: string;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
applyWebCodecsBackpressure,
|
|
3
|
+
createMp4Muxer,
|
|
4
|
+
resolveWebCodecsQueueLimit,
|
|
5
|
+
shouldEncodeFfmpegBatch
|
|
6
|
+
} from "../chunk-MEPETH5V.js";
|
|
4
7
|
|
|
5
8
|
// src/workers/encode.worker.ts
|
|
6
9
|
import {
|
|
@@ -12,8 +15,11 @@ var backend = null;
|
|
|
12
15
|
var cancelled = false;
|
|
13
16
|
var videoEncoder = null;
|
|
14
17
|
var muxer = null;
|
|
18
|
+
var webCodecsQueueLimit = 1;
|
|
19
|
+
var webCodecsFramesSinceFlush = 0;
|
|
15
20
|
var ffmpegInstance = null;
|
|
16
21
|
var ffmpegFrames = [];
|
|
22
|
+
var ffmpegFrameBytes = 0;
|
|
17
23
|
var ffmpegConfig = null;
|
|
18
24
|
var totalFramesReceived = 0;
|
|
19
25
|
var totalFramesExpected = 0;
|
|
@@ -84,18 +90,34 @@ function initWebCodecs(config) {
|
|
|
84
90
|
bitrate: bitrateForQuality(config.quality, config.width, config.height),
|
|
85
91
|
framerate: config.fps
|
|
86
92
|
});
|
|
93
|
+
webCodecsQueueLimit = resolveWebCodecsQueueLimit(config);
|
|
94
|
+
webCodecsFramesSinceFlush = 0;
|
|
87
95
|
}
|
|
88
96
|
async function encodeFrameWebCodecs(msg) {
|
|
89
97
|
if (!videoEncoder || cancelled) {
|
|
90
98
|
msg.bitmap.close();
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
try {
|
|
102
|
+
const drained = await applyWebCodecsBackpressure(
|
|
103
|
+
videoEncoder,
|
|
104
|
+
webCodecsQueueLimit,
|
|
105
|
+
webCodecsFramesSinceFlush
|
|
106
|
+
);
|
|
107
|
+
if (drained) webCodecsFramesSinceFlush = 0;
|
|
108
|
+
if (cancelled || videoEncoder.state === "closed") return;
|
|
109
|
+
const frame = new VideoFrame(msg.bitmap, { timestamp: msg.timestamp });
|
|
110
|
+
try {
|
|
111
|
+
const keyFrame = msg.frameIndex % 30 === 0;
|
|
112
|
+
videoEncoder.encode(frame, { keyFrame });
|
|
113
|
+
webCodecsFramesSinceFlush++;
|
|
114
|
+
} finally {
|
|
115
|
+
frame.close();
|
|
116
|
+
}
|
|
117
|
+
totalFramesReceived++;
|
|
118
|
+
} finally {
|
|
119
|
+
msg.bitmap.close();
|
|
120
|
+
}
|
|
99
121
|
postFrameProgress(50, frameLabel("Encoding"));
|
|
100
122
|
}
|
|
101
123
|
function frameLabel(verb) {
|
|
@@ -115,6 +137,7 @@ async function finalizeWebCodecs() {
|
|
|
115
137
|
async function initFfmpegWasm(config) {
|
|
116
138
|
ffmpegConfig = config;
|
|
117
139
|
ffmpegFrames = [];
|
|
140
|
+
ffmpegFrameBytes = 0;
|
|
118
141
|
ffmpegSegments.length = 0;
|
|
119
142
|
totalFramesReceived = 0;
|
|
120
143
|
const load = resolveFfmpegWasmLoad(config.ffmpegWasm, "The ffmpeg.wasm encoder fallback", {
|
|
@@ -150,11 +173,12 @@ async function encodeFrameFfmpeg(msg) {
|
|
|
150
173
|
msg.bitmap.close();
|
|
151
174
|
const blob = await canvas.convertToBlob({ type: "image/png" });
|
|
152
175
|
const arrayBuffer = await blob.arrayBuffer();
|
|
153
|
-
|
|
176
|
+
const frameData = new Uint8Array(arrayBuffer);
|
|
177
|
+
ffmpegFrames.push({ data: frameData, index: msg.frameIndex });
|
|
178
|
+
ffmpegFrameBytes += frameData.byteLength;
|
|
154
179
|
totalFramesReceived++;
|
|
155
180
|
postFrameProgress(80, frameLabel("Collecting"));
|
|
156
|
-
|
|
157
|
-
if (ffmpegFrames.length >= batchSize) {
|
|
181
|
+
if (shouldEncodeFfmpegBatch(ffmpegFrames.length, ffmpegFrameBytes, ffmpegConfig?.fps ?? 30)) {
|
|
158
182
|
await encodeFfmpegBatch();
|
|
159
183
|
}
|
|
160
184
|
}
|
|
@@ -212,6 +236,7 @@ async function encodeFfmpegBatch() {
|
|
|
212
236
|
}
|
|
213
237
|
await ffmpeg.deleteFile(segmentName);
|
|
214
238
|
ffmpegFrames = [];
|
|
239
|
+
ffmpegFrameBytes = 0;
|
|
215
240
|
}
|
|
216
241
|
async function finalizeFfmpeg() {
|
|
217
242
|
if (!ffmpegInstance || cancelled) return;
|
|
@@ -305,6 +330,7 @@ async function handleMessage(msg) {
|
|
|
305
330
|
muxer = null;
|
|
306
331
|
disposeFfmpeg();
|
|
307
332
|
ffmpegFrames = [];
|
|
333
|
+
ffmpegFrameBytes = 0;
|
|
308
334
|
ffmpegSegments.length = 0;
|
|
309
335
|
totalFramesExpected = 0;
|
|
310
336
|
backend = null;
|
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.2",
|
|
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.3.
|
|
69
|
-
"@bendyline/squisq-video": "2.2.
|
|
70
|
-
"@bendyline/squisq-react": "2.3.
|
|
68
|
+
"@bendyline/squisq": "2.3.2",
|
|
69
|
+
"@bendyline/squisq-video": "2.2.2",
|
|
70
|
+
"@bendyline/squisq-react": "2.3.2",
|
|
71
71
|
"@ffmpeg/core": "0.12.9",
|
|
72
72
|
"@ffmpeg/ffmpeg": "0.12.15",
|
|
73
73
|
"@ffmpeg/util": "0.12.2",
|