@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.
@@ -0,0 +1,978 @@
1
+ import {
2
+ useFrameCapture
3
+ } from "./chunk-YTEDBL6F.js";
4
+ import {
5
+ EXPORT_AUDIO_CHANNELS,
6
+ EXPORT_AUDIO_SAMPLE_RATE,
7
+ audioBufferToWav,
8
+ createEncoder,
9
+ encodeAacTrack,
10
+ muxAudioWithFfmpegWasm,
11
+ renderAudioTimeline,
12
+ selectAudioTier,
13
+ supportsWebCodecs,
14
+ supportsWebCodecsAac,
15
+ supportsWebCodecsH264
16
+ } from "./chunk-32QCPFXE.js";
17
+
18
+ // src/hooks/useVideoExport.ts
19
+ import { useState, useRef, useCallback, useEffect } from "react";
20
+ import {
21
+ DEFAULT_INTERACTIVE_RESOURCE_POLICY,
22
+ fetchResourceBytes
23
+ } from "@bendyline/squisq/markdown";
24
+ import {
25
+ DEFAULT_COVER_SLIDE_SETTINGS,
26
+ MAX_COVER_SLIDE_DURATION_SECONDS,
27
+ resolveCoverSlideSettings
28
+ } from "@bendyline/squisq/doc";
29
+ import {
30
+ resolveDimensions,
31
+ computeAudioTimeline,
32
+ resolveFfmpegWasmLoad as resolveFfmpegWasmLoad2,
33
+ QUALITY_PRESETS
34
+ } from "@bendyline/squisq-video";
35
+
36
+ // src/workerEncoder.ts
37
+ import { validateVideoExportOptions } from "@bendyline/squisq-video";
38
+ function createWorkerEncoder(config) {
39
+ validateVideoExportOptions(config);
40
+ const worker = new Worker(new URL("./workers/encode.worker.js", import.meta.url), {
41
+ type: "module"
42
+ });
43
+ let state = "open";
44
+ let fatalError = null;
45
+ let finalizeResolve = null;
46
+ let finalizeReject = null;
47
+ let readyResolve = null;
48
+ let readyReject = null;
49
+ let readySettled = false;
50
+ const frameWaiters = /* @__PURE__ */ new Map();
51
+ const ready = new Promise((resolve, reject) => {
52
+ readyResolve = resolve;
53
+ readyReject = reject;
54
+ });
55
+ const frameDuration = 1e6 / config.fps;
56
+ function post(msg, transfer) {
57
+ worker.postMessage(msg, transfer ?? []);
58
+ }
59
+ const currentState = () => state;
60
+ worker.onmessage = (event) => {
61
+ const msg = event.data;
62
+ switch (msg.type) {
63
+ case "capabilities":
64
+ readySettled = true;
65
+ readyResolve?.(msg.backend);
66
+ readyResolve = readyReject = null;
67
+ break;
68
+ case "frame-complete": {
69
+ const waiter = frameWaiters.get(msg.frameIndex);
70
+ waiter?.resolve();
71
+ frameWaiters.delete(msg.frameIndex);
72
+ break;
73
+ }
74
+ case "complete":
75
+ state = "closed";
76
+ finalizeResolve?.(msg.data);
77
+ finalizeResolve = finalizeReject = null;
78
+ worker.terminate();
79
+ break;
80
+ case "error": {
81
+ const err = new Error(msg.message);
82
+ fatalError = err;
83
+ state = "closed";
84
+ readySettled = true;
85
+ readyReject?.(err);
86
+ finalizeReject?.(err);
87
+ for (const waiter of frameWaiters.values()) waiter.reject(err);
88
+ frameWaiters.clear();
89
+ readyResolve = readyReject = null;
90
+ finalizeResolve = finalizeReject = null;
91
+ worker.terminate();
92
+ break;
93
+ }
94
+ }
95
+ };
96
+ worker.onerror = (event) => {
97
+ const err = new Error(event.message || "Worker error");
98
+ fatalError = err;
99
+ state = "closed";
100
+ readySettled = true;
101
+ readyReject?.(err);
102
+ finalizeReject?.(err);
103
+ for (const waiter of frameWaiters.values()) waiter.reject(err);
104
+ frameWaiters.clear();
105
+ readyResolve = readyReject = null;
106
+ finalizeResolve = finalizeReject = null;
107
+ worker.terminate();
108
+ };
109
+ post({
110
+ type: "init",
111
+ width: config.width,
112
+ height: config.height,
113
+ fps: config.fps,
114
+ quality: config.quality,
115
+ ...config.totalFrames !== void 0 ? { totalFrames: config.totalFrames } : {},
116
+ ...config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}
117
+ });
118
+ return {
119
+ ready,
120
+ encodeFrame(frame, frameIndex) {
121
+ if (typeof HTMLCanvasElement !== "undefined" && frame instanceof HTMLCanvasElement) {
122
+ return Promise.reject(new Error("Worker encoding requires a transferable ImageBitmap"));
123
+ }
124
+ const bitmap = frame;
125
+ if (state !== "open" || fatalError) {
126
+ bitmap.close();
127
+ return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
128
+ }
129
+ if (frameWaiters.has(frameIndex)) {
130
+ bitmap.close();
131
+ return Promise.reject(new Error(`Frame ${frameIndex} was submitted more than once`));
132
+ }
133
+ let resolveFrame;
134
+ let rejectFrame;
135
+ const promise = new Promise((resolve, reject) => {
136
+ resolveFrame = resolve;
137
+ rejectFrame = reject;
138
+ });
139
+ frameWaiters.set(frameIndex, { promise, resolve: resolveFrame, reject: rejectFrame });
140
+ const timestamp = Math.round(frameIndex * frameDuration);
141
+ post({ type: "frame", bitmap, frameIndex, timestamp }, [bitmap]);
142
+ return promise;
143
+ },
144
+ async finalize() {
145
+ if (state !== "open") throw new Error("Encoder already closed or finalizing");
146
+ if (fatalError) throw fatalError;
147
+ state = "finalizing";
148
+ await Promise.all(Array.from(frameWaiters.values(), (waiter) => waiter.promise));
149
+ if (currentState() === "closed") {
150
+ throw fatalError ?? new Error("Encoder closed during finalization");
151
+ }
152
+ return new Promise((resolve, reject) => {
153
+ finalizeResolve = resolve;
154
+ finalizeReject = reject;
155
+ post({ type: "finalize" });
156
+ });
157
+ },
158
+ close() {
159
+ if (state === "closed") return;
160
+ state = "closed";
161
+ const err = new Error("Encoder closed");
162
+ if (!readySettled) {
163
+ readySettled = true;
164
+ readyReject?.(err);
165
+ }
166
+ finalizeReject?.(err);
167
+ for (const waiter of frameWaiters.values()) waiter.reject(err);
168
+ frameWaiters.clear();
169
+ readyResolve = readyReject = null;
170
+ finalizeResolve = finalizeReject = null;
171
+ post({ type: "cancel" });
172
+ worker.terminate();
173
+ }
174
+ };
175
+ }
176
+
177
+ // src/gifTranscode.ts
178
+ import {
179
+ ffmpegGifPaletteApplicationArgs,
180
+ ffmpegGifPaletteGenerationFilter,
181
+ resolveFfmpegWasmLoad
182
+ } from "@bendyline/squisq-video";
183
+ function buildGifPaletteFfmpegArgs(options) {
184
+ return [
185
+ "-y",
186
+ "-i",
187
+ "video.mp4",
188
+ "-vf",
189
+ ffmpegGifPaletteGenerationFilter(options),
190
+ "-frames:v",
191
+ "1",
192
+ "palette.png"
193
+ ];
194
+ }
195
+ function buildGifFfmpegArgs(options) {
196
+ return [
197
+ "-y",
198
+ "-i",
199
+ "video.mp4",
200
+ "-i",
201
+ "palette.png",
202
+ ...ffmpegGifPaletteApplicationArgs(options),
203
+ "out.gif"
204
+ ];
205
+ }
206
+ var FFMPEG_ERRORISH = /error|invalid|failed|out of memory|memory access|abort|unable to/i;
207
+ function ffmpegFailureDetail(logs) {
208
+ const lines = logs.map((line) => line.trim()).filter(Boolean);
209
+ return lines.find((line) => FFMPEG_ERRORISH.test(line)) ?? lines.at(-1) ?? null;
210
+ }
211
+ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
212
+ if (videoMp4.byteLength === 0) {
213
+ throw new Error("Cannot create an animated GIF from an empty MP4.");
214
+ }
215
+ if (signal?.aborted) {
216
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
217
+ }
218
+ const load = resolveFfmpegWasmLoad(loadConfig, "Animated GIF export", {
219
+ classWorkerURL: new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
220
+ });
221
+ const paletteArgs = buildGifPaletteFfmpegArgs(options);
222
+ const gifArgs = buildGifFfmpegArgs(options);
223
+ const { FFmpeg } = await import("@ffmpeg/ffmpeg");
224
+ const ffmpeg = new FFmpeg();
225
+ const recentLogs = [];
226
+ const handleLog = ({ message }) => {
227
+ recentLogs.push(message);
228
+ if (recentLogs.length > 40) recentLogs.shift();
229
+ };
230
+ ffmpeg.on("log", handleLog);
231
+ let terminated = false;
232
+ const terminate = () => {
233
+ if (terminated) return;
234
+ terminated = true;
235
+ ffmpeg.terminate();
236
+ };
237
+ const handleAbort = () => terminate();
238
+ signal?.addEventListener("abort", handleAbort, { once: true });
239
+ try {
240
+ await ffmpeg.load(load);
241
+ if (signal?.aborted) {
242
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
243
+ }
244
+ await ffmpeg.writeFile("video.mp4", videoMp4);
245
+ if (signal?.aborted) {
246
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
247
+ }
248
+ const execPhase = async (args, phase) => {
249
+ recentLogs.length = 0;
250
+ let exitCode;
251
+ try {
252
+ exitCode = await ffmpeg.exec(args);
253
+ } catch (caught) {
254
+ if (signal?.aborted) {
255
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
256
+ }
257
+ const detail = ffmpegFailureDetail(recentLogs);
258
+ const fallback = caught instanceof Error ? caught.message : String(caught);
259
+ throw new Error(`ffmpeg.wasm GIF transcode failed during ${phase}: ${detail ?? fallback}`);
260
+ }
261
+ if (signal?.aborted) {
262
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
263
+ }
264
+ if (exitCode !== 0) {
265
+ const detail = ffmpegFailureDetail(recentLogs);
266
+ throw new Error(
267
+ `ffmpeg.wasm GIF transcode failed during ${phase} with exit code ${exitCode}` + (detail ? `: ${detail}` : "")
268
+ );
269
+ }
270
+ };
271
+ await execPhase(paletteArgs, "palette generation");
272
+ await execPhase(gifArgs, "palette application");
273
+ await ffmpeg.deleteFile("video.mp4").catch(() => false);
274
+ await ffmpeg.deleteFile("palette.png").catch(() => false);
275
+ const data = await ffmpeg.readFile("out.gif");
276
+ return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
277
+ } finally {
278
+ signal?.removeEventListener("abort", handleAbort);
279
+ ffmpeg.off("log", handleLog);
280
+ terminate();
281
+ }
282
+ }
283
+
284
+ // src/hooks/useVideoExport.ts
285
+ var MAX_EXPORT_MEDIA_FILES = 256;
286
+ var ENCODER_PROBE_TIMEOUT_MS = 5e3;
287
+ var ENCODER_START_TIMEOUT_MS = 6e4;
288
+ var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
289
+ var FRAME_CAPTURE_RECOVERY_TIMEOUT_MS = 12e4;
290
+ var FRAME_ENCODE_TIMEOUT_MS = 6e4;
291
+ var CAPTURE_PROGRESS_START = 7;
292
+ var CAPTURE_PROGRESS_END = 95;
293
+ var FRAME_RATE_WINDOW_SIZE = 30;
294
+ var DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS = DEFAULT_COVER_SLIDE_SETTINGS.duration;
295
+ function calculateRollingFramesPerSecond(frameBoundaryTimes) {
296
+ if (frameBoundaryTimes.length < 2) return null;
297
+ const firstIndex = Math.max(0, frameBoundaryTimes.length - (FRAME_RATE_WINDOW_SIZE + 1));
298
+ const elapsedMs = frameBoundaryTimes[frameBoundaryTimes.length - 1] - frameBoundaryTimes[firstIndex];
299
+ const completedFrames = frameBoundaryTimes.length - 1 - firstIndex;
300
+ if (elapsedMs <= 0 || completedFrames <= 0) return null;
301
+ return completedFrames * 1e3 / elapsedMs;
302
+ }
303
+ function releaseEncoderFrame(frame) {
304
+ if ("close" in frame) frame.close();
305
+ }
306
+ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult, activityDocument) {
307
+ return new Promise((resolve, reject) => {
308
+ let settled = false;
309
+ let timeout = null;
310
+ const isInactive = () => activityDocument !== void 0 && activityDocument.visibilityState !== "visible";
311
+ const clearDeadline = () => {
312
+ if (timeout === null) return;
313
+ globalThis.clearTimeout(timeout);
314
+ timeout = null;
315
+ };
316
+ const cleanup = () => {
317
+ clearDeadline();
318
+ activityDocument?.removeEventListener("visibilitychange", handleVisibilityChange);
319
+ };
320
+ const fail = () => {
321
+ timeout = null;
322
+ if (settled || isInactive()) return;
323
+ settled = true;
324
+ cleanup();
325
+ reject(new Error(timeoutMessage));
326
+ };
327
+ const armDeadline = () => {
328
+ clearDeadline();
329
+ if (settled || isInactive()) return;
330
+ timeout = globalThis.setTimeout(fail, timeoutMs);
331
+ };
332
+ function handleVisibilityChange() {
333
+ if (activityDocument?.visibilityState !== "visible") {
334
+ clearDeadline();
335
+ return;
336
+ }
337
+ armDeadline();
338
+ }
339
+ activityDocument?.addEventListener("visibilitychange", handleVisibilityChange);
340
+ armDeadline();
341
+ void operation.then(
342
+ (value) => {
343
+ if (settled) {
344
+ onLateResult?.(value);
345
+ return;
346
+ }
347
+ settled = true;
348
+ cleanup();
349
+ resolve(value);
350
+ },
351
+ (caught) => {
352
+ if (settled) return;
353
+ settled = true;
354
+ cleanup();
355
+ reject(caught);
356
+ }
357
+ );
358
+ });
359
+ }
360
+ async function settleFrameCaptureWithRecovery({
361
+ operation,
362
+ frameNumber,
363
+ totalFrames,
364
+ onLateResult,
365
+ activityDocument,
366
+ onRecoveryWait,
367
+ primaryTimeoutMs = FRAME_CAPTURE_TIMEOUT_MS,
368
+ graceTimeoutMs = FRAME_CAPTURE_RECOVERY_TIMEOUT_MS
369
+ }) {
370
+ const primaryMessage = `Frame capture stopped responding at frame ${frameNumber}/${totalFrames}.`;
371
+ try {
372
+ return await settleWithin(
373
+ operation,
374
+ primaryTimeoutMs,
375
+ primaryMessage,
376
+ void 0,
377
+ activityDocument
378
+ );
379
+ } catch (error) {
380
+ if (!(error instanceof Error) || error.message !== primaryMessage) throw error;
381
+ onRecoveryWait?.();
382
+ const totalSeconds = Math.round((primaryTimeoutMs + graceTimeoutMs) / 1e3);
383
+ return await settleWithin(
384
+ operation,
385
+ graceTimeoutMs,
386
+ `Frame capture stopped responding at frame ${frameNumber}/${totalFrames} (no frame after ${totalSeconds}s). The browser is likely under memory pressure \u2014 try a lower export quality, close the preview panes, and keep this tab visible.`,
387
+ onLateResult,
388
+ activityDocument
389
+ );
390
+ }
391
+ }
392
+ function toArrayBuffer(bytes) {
393
+ return bytes.slice().buffer;
394
+ }
395
+ function resolveExportMediaResourcePolicy(declaredSize, policy) {
396
+ const knownSize = Number.isFinite(declaredSize) ? Math.max(0, declaredSize) : 0;
397
+ return {
398
+ ...DEFAULT_INTERACTIVE_RESOURCE_POLICY,
399
+ ...policy,
400
+ maxBytes: policy?.maxBytes ?? Math.max(DEFAULT_INTERACTIVE_RESOURCE_POLICY.maxBytes, knownSize)
401
+ };
402
+ }
403
+ function collectDocumentMediaReferences(doc) {
404
+ const references = /* @__PURE__ */ new Set();
405
+ const seen = /* @__PURE__ */ new WeakSet();
406
+ const visit = (value) => {
407
+ if (typeof value === "string") {
408
+ references.add(value);
409
+ if (value.startsWith("./")) references.add(value.slice(2));
410
+ return;
411
+ }
412
+ if (!value || typeof value !== "object" || seen.has(value)) return;
413
+ seen.add(value);
414
+ if (Array.isArray(value)) {
415
+ value.forEach(visit);
416
+ return;
417
+ }
418
+ Object.values(value).forEach(visit);
419
+ };
420
+ visit(doc);
421
+ return references;
422
+ }
423
+ async function resolveAudioBuffers(clips, sources) {
424
+ const srcs = new Set(clips.map((c) => c.src));
425
+ const out = /* @__PURE__ */ new Map();
426
+ for (const src of srcs) {
427
+ let data = sources.audio?.get(src) ?? sources.images?.get(src);
428
+ if (!data && sources.mediaProvider) {
429
+ try {
430
+ const url = await sources.mediaProvider.resolveUrl(src);
431
+ const resource = await fetchResourceBytes(url, {
432
+ policy: sources.resourcePolicy
433
+ });
434
+ data = toArrayBuffer(resource.bytes);
435
+ } catch {
436
+ }
437
+ }
438
+ if (data) out.set(src, data);
439
+ }
440
+ return out;
441
+ }
442
+ function resolveVideoExportCover(doc, config = {}) {
443
+ const legacyPreRollOverride = config.coverPreRoll;
444
+ const requestedDuration = config.coverDuration ?? legacyPreRollOverride;
445
+ if (requestedDuration !== void 0 && (!Number.isFinite(requestedDuration) || requestedDuration < 0 || requestedDuration > MAX_COVER_SLIDE_DURATION_SECONDS)) {
446
+ throw new Error(
447
+ `Cover duration must be a finite number of seconds between 0 and ${MAX_COVER_SLIDE_DURATION_SECONDS}`
448
+ );
449
+ }
450
+ const settings = resolveCoverSlideSettings(doc.frontmatter, {
451
+ ...config.showCoverSlide !== void 0 ? { enabled: config.showCoverSlide } : {},
452
+ ...requestedDuration !== void 0 ? { duration: requestedDuration } : {},
453
+ ...config.coverPlayback !== void 0 ? { playback: config.coverPlayback } : legacyPreRollOverride !== void 0 ? { playback: "preroll" } : {}
454
+ });
455
+ const showCoverSlide = settings.enabled && !!doc.startBlock;
456
+ const coverDuration = showCoverSlide ? settings.duration : 0;
457
+ return {
458
+ showCoverSlide,
459
+ coverDuration,
460
+ coverPlayback: settings.playback,
461
+ coverPreRoll: settings.playback === "preroll" ? coverDuration : 0
462
+ };
463
+ }
464
+ function resolveVideoCoverFramePlan(docDuration, fps, cover) {
465
+ const coverFrameCount = Math.ceil(cover.coverDuration * fps);
466
+ const storyFrameCount = Math.ceil(docDuration * fps);
467
+ const prerollFrameCount = cover.coverPlayback === "preroll" ? coverFrameCount : 0;
468
+ const totalFrames = prerollFrameCount + storyFrameCount;
469
+ return {
470
+ coverFrameCount,
471
+ storyFrameCount,
472
+ totalFrames,
473
+ totalDuration: totalFrames / fps,
474
+ audioOffset: prerollFrameCount / fps,
475
+ captureTimeForFrame: (frameIndex) => cover.coverPlayback === "preroll" && frameIndex < coverFrameCount ? 0 : (frameIndex - prerollFrameCount) / fps
476
+ };
477
+ }
478
+ function useVideoExport(options = {}) {
479
+ const [state, setState] = useState("idle");
480
+ const [progress, setProgress] = useState(0);
481
+ const [phase, setPhase] = useState("");
482
+ const [currentFrameTime, setCurrentFrameTime] = useState(null);
483
+ const [processingFps, setProcessingFps] = useState(null);
484
+ const [duration, setDuration] = useState(0);
485
+ const [outputFormat, setOutputFormat] = useState("mp4");
486
+ const [backend, setBackend] = useState(null);
487
+ const [downloadUrl, setDownloadUrl] = useState(null);
488
+ const [outputBlob, setOutputBlob] = useState(null);
489
+ const [fileSize, setFileSize] = useState(0);
490
+ const [audioIncluded, setAudioIncluded] = useState(false);
491
+ const [audioSkippedReason, setAudioSkippedReason] = useState(null);
492
+ const [error, setError] = useState(null);
493
+ const [elapsed, setElapsed] = useState(0);
494
+ const [estimatedRemaining, setEstimatedRemaining] = useState(0);
495
+ const encoderRef = useRef(null);
496
+ const gifAbortRef = useRef(null);
497
+ const cancelledRef = useRef(false);
498
+ const downloadUrlRef = useRef(null);
499
+ const startTimeRef = useRef(0);
500
+ const elapsedTimerRef = useRef(null);
501
+ const previewOptionsRef = useRef(options);
502
+ previewOptionsRef.current = options;
503
+ const frameCapture = useFrameCapture();
504
+ useEffect(() => {
505
+ return () => {
506
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
507
+ if (downloadUrlRef.current) {
508
+ URL.revokeObjectURL(downloadUrlRef.current);
509
+ }
510
+ if (encoderRef.current) {
511
+ encoderRef.current.close();
512
+ }
513
+ gifAbortRef.current?.abort();
514
+ frameCapture.destroy();
515
+ };
516
+ }, [frameCapture]);
517
+ const reset = useCallback(() => {
518
+ if (downloadUrlRef.current) {
519
+ URL.revokeObjectURL(downloadUrlRef.current);
520
+ downloadUrlRef.current = null;
521
+ }
522
+ if (encoderRef.current) {
523
+ encoderRef.current.close();
524
+ encoderRef.current = null;
525
+ }
526
+ gifAbortRef.current?.abort();
527
+ gifAbortRef.current = null;
528
+ frameCapture.destroy();
529
+ setState("idle");
530
+ setProgress(0);
531
+ setPhase("");
532
+ setCurrentFrameTime(null);
533
+ setProcessingFps(null);
534
+ setDuration(0);
535
+ setOutputFormat("mp4");
536
+ setBackend(null);
537
+ setDownloadUrl(null);
538
+ setOutputBlob(null);
539
+ setFileSize(0);
540
+ setAudioIncluded(false);
541
+ setAudioSkippedReason(null);
542
+ setError(null);
543
+ setElapsed(0);
544
+ setEstimatedRemaining(0);
545
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
546
+ cancelledRef.current = false;
547
+ }, [frameCapture]);
548
+ const cancel = useCallback(() => {
549
+ cancelledRef.current = true;
550
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
551
+ if (encoderRef.current) {
552
+ encoderRef.current.close();
553
+ encoderRef.current = null;
554
+ }
555
+ gifAbortRef.current?.abort();
556
+ gifAbortRef.current = null;
557
+ frameCapture.destroy();
558
+ setState("idle");
559
+ setProgress(0);
560
+ setPhase("Cancelled");
561
+ }, [frameCapture]);
562
+ const startExport = useCallback(
563
+ async (doc, config) => {
564
+ cancelledRef.current = false;
565
+ if (downloadUrlRef.current) {
566
+ URL.revokeObjectURL(downloadUrlRef.current);
567
+ downloadUrlRef.current = null;
568
+ }
569
+ setDownloadUrl(null);
570
+ setOutputBlob(null);
571
+ setFileSize(0);
572
+ setAudioIncluded(false);
573
+ setAudioSkippedReason(null);
574
+ setError(null);
575
+ setCurrentFrameTime(null);
576
+ setProcessingFps(null);
577
+ const quality = config.quality ?? "normal";
578
+ const effectiveOutputFormat = config.outputFormat ?? "mp4";
579
+ const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
580
+ const orientation = config.orientation ?? "landscape";
581
+ const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
582
+ const captionMode = config.captionMode ?? (effectiveOutputFormat === "gif" ? "standard" : "off");
583
+ const audioPolicy = config.audioPolicy ?? "require";
584
+ setOutputFormat(effectiveOutputFormat);
585
+ try {
586
+ const cover = resolveVideoExportCover(doc, config);
587
+ const gifDefaults = orientation === "portrait" ? { width: 540, height: 960 } : { width: 960, height: 540 };
588
+ const { width, height } = resolveDimensions({
589
+ orientation,
590
+ fps,
591
+ quality,
592
+ ...config.width !== void 0 ? { width: config.width } : effectiveOutputFormat === "gif" ? { width: gifDefaults.width } : {},
593
+ ...config.height !== void 0 ? { height: config.height } : effectiveOutputFormat === "gif" ? { height: gifDefaults.height } : {}
594
+ });
595
+ const webCodecsAvailable = supportsWebCodecs();
596
+ const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== "undefined";
597
+ if (effectiveOutputFormat === "gif" && !sharedArrayBufferAvailable) {
598
+ throw new Error(
599
+ "Animated GIF export requires ffmpeg.wasm and SharedArrayBuffer (Cross-Origin-Isolation headers)."
600
+ );
601
+ }
602
+ if (effectiveOutputFormat === "gif") {
603
+ resolveFfmpegWasmLoad2(config.ffmpegWasm, "Animated GIF export");
604
+ }
605
+ if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
606
+ throw new Error(
607
+ "No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
608
+ );
609
+ }
610
+ setState("preparing");
611
+ setPhase("Loading document\u2026");
612
+ setProgress(0);
613
+ setElapsed(0);
614
+ setEstimatedRemaining(0);
615
+ startTimeRef.current = performance.now();
616
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
617
+ elapsedTimerRef.current = setInterval(() => {
618
+ setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
619
+ }, 1e3);
620
+ let images = config.images;
621
+ let ownsLoadedImages = false;
622
+ if (!images && config.mediaProvider) {
623
+ images = /* @__PURE__ */ new Map();
624
+ ownsLoadedImages = true;
625
+ const entries = await config.mediaProvider.listMedia();
626
+ const references = collectDocumentMediaReferences(doc);
627
+ const neededEntries = entries.filter(
628
+ (entry) => references.has(entry.name) || references.has(`./${entry.name}`)
629
+ );
630
+ if (neededEntries.length > MAX_EXPORT_MEDIA_FILES) {
631
+ throw new Error(
632
+ `Document references ${neededEntries.length} media files; browser export supports at most ${MAX_EXPORT_MEDIA_FILES}.`
633
+ );
634
+ }
635
+ for (const entry of neededEntries) {
636
+ if (cancelledRef.current) return;
637
+ const url2 = await config.mediaProvider.resolveUrl(entry.name);
638
+ const resource = await fetchResourceBytes(url2, {
639
+ policy: resolveExportMediaResourcePolicy(entry.size, config.resourcePolicy)
640
+ });
641
+ const data = toArrayBuffer(resource.bytes);
642
+ images.set(entry.name, data);
643
+ }
644
+ }
645
+ const docDuration = await frameCapture.init(
646
+ doc,
647
+ {
648
+ images,
649
+ audio: config.audio,
650
+ width,
651
+ height,
652
+ animationsEnabled,
653
+ theme: config.theme,
654
+ videoPresentation: config.videoPresentation,
655
+ pipSize: config.pipSize,
656
+ pipShape: config.pipShape,
657
+ pipPosition: config.pipPosition,
658
+ showCoverSlide: cover.showCoverSlide
659
+ },
660
+ captionMode
661
+ );
662
+ if (cancelledRef.current) return;
663
+ if (docDuration <= 0) {
664
+ throw new Error("Document has zero duration \u2014 nothing to export");
665
+ }
666
+ const coverPlan = resolveVideoCoverFramePlan(docDuration, fps, cover);
667
+ const { coverFrameCount, totalFrames } = coverPlan;
668
+ const exportDuration = coverPlan.totalDuration;
669
+ setDuration(exportDuration);
670
+ setPhase("Checking video encoder\u2026");
671
+ setProgress(5);
672
+ const canUseWebCodecs = webCodecsAvailable && await settleWithin(
673
+ supportsWebCodecsH264({ width, height, fps, quality }),
674
+ ENCODER_PROBE_TIMEOUT_MS,
675
+ "The browser did not finish checking WebCodecs support.",
676
+ void 0,
677
+ document
678
+ ).catch(() => false);
679
+ const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
680
+ const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverPlan.audioOffset) : [];
681
+ const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
682
+ const tierDecision = selectAudioTier({
683
+ hasClips: timeline.length > 0,
684
+ aacSupported,
685
+ sharedArrayBufferAvailable,
686
+ canUseMainThreadWebCodecs: canUseWebCodecs
687
+ });
688
+ let renderedAudio = null;
689
+ let audioIncludedLocal = false;
690
+ let audioReasonLocal = tierDecision.reason;
691
+ if (timeline.length > 0 && tierDecision.tier === 3 && audioPolicy === "require") {
692
+ throw new Error(tierDecision.reason ?? "This browser cannot include the document audio.");
693
+ }
694
+ if (tierDecision.tier === 1 || tierDecision.tier === 2) {
695
+ setPhase("Preparing audio\u2026");
696
+ try {
697
+ const buffers = await resolveAudioBuffers(timeline, {
698
+ audio: config.audio,
699
+ images,
700
+ mediaProvider: config.mediaProvider,
701
+ resourcePolicy: config.resourcePolicy
702
+ });
703
+ try {
704
+ const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
705
+ (src) => !buffers.has(src)
706
+ );
707
+ if (missingSources.length > 0) {
708
+ audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
709
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
710
+ }
711
+ if (buffers.size === 0) {
712
+ audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
713
+ } else {
714
+ const totalAudioDur = timeline.reduce(
715
+ (max, c) => Math.max(max, c.startSec + c.durationSec),
716
+ exportDuration
717
+ );
718
+ renderedAudio = await renderAudioTimeline(
719
+ timeline,
720
+ buffers,
721
+ totalAudioDur,
722
+ EXPORT_AUDIO_SAMPLE_RATE
723
+ );
724
+ if (!renderedAudio) {
725
+ audioReasonLocal = "No included video source contained a decodable audio track.";
726
+ }
727
+ }
728
+ } finally {
729
+ buffers.clear();
730
+ }
731
+ } catch (audioErr) {
732
+ renderedAudio = null;
733
+ audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
734
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
735
+ }
736
+ }
737
+ const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
738
+ const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
739
+ if (cancelledRef.current) return;
740
+ let encoder;
741
+ if (canUseWebCodecs) {
742
+ encoder = createEncoder({
743
+ width,
744
+ height,
745
+ fps,
746
+ quality,
747
+ ...useInlineAudio && renderedAudio ? {
748
+ audio: {
749
+ numberOfChannels: renderedAudio.numberOfChannels,
750
+ sampleRate: renderedAudio.sampleRate
751
+ }
752
+ } : {},
753
+ // Plain MP4 downloads finalize to a Blob, so the muxer may spill
754
+ // settled bytes out of JS memory as it goes. GIF transcode and
755
+ // ffmpeg audio muxing need contiguous bytes — keep those in memory.
756
+ spillOutputToBlob: effectiveOutputFormat === "mp4" && !useFfmpegAudio
757
+ });
758
+ encoderRef.current = encoder;
759
+ setBackend("webcodecs");
760
+ } else if (sharedArrayBufferAvailable) {
761
+ setProgress(6);
762
+ setPhase("Loading export engine\u2026");
763
+ const workerEncoder = createWorkerEncoder({
764
+ width,
765
+ height,
766
+ fps,
767
+ quality,
768
+ totalFrames,
769
+ ffmpegWasm: config.ffmpegWasm
770
+ });
771
+ encoder = workerEncoder;
772
+ encoderRef.current = workerEncoder;
773
+ const selectedBackend = await settleWithin(
774
+ workerEncoder.ready,
775
+ ENCODER_START_TIMEOUT_MS,
776
+ "The browser export engine did not start within 60 seconds.",
777
+ void 0,
778
+ document
779
+ );
780
+ setBackend(selectedBackend);
781
+ } else {
782
+ throw new Error(
783
+ "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
784
+ );
785
+ }
786
+ if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
787
+ setPhase("Encoding audio\u2026");
788
+ try {
789
+ await encodeAacTrack(
790
+ renderedAudio,
791
+ { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
792
+ audioBitrate
793
+ );
794
+ audioIncludedLocal = true;
795
+ } catch (audioErr) {
796
+ audioIncludedLocal = false;
797
+ audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
798
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
799
+ } finally {
800
+ renderedAudio = null;
801
+ }
802
+ }
803
+ if (ownsLoadedImages) images?.clear();
804
+ images = void 0;
805
+ if (cancelledRef.current) return;
806
+ setProgress(CAPTURE_PROGRESS_START);
807
+ setPhase(`Capturing frame 1/${totalFrames}`);
808
+ setCurrentFrameTime(0);
809
+ setState("capturing");
810
+ const captureStartTime = performance.now();
811
+ const frameBoundaryTimes = [captureStartTime];
812
+ if (coverFrameCount > 0) await frameCapture.setCoverVisible(true);
813
+ for (let i = 0; i < totalFrames; i++) {
814
+ if (cancelledRef.current) return;
815
+ if (coverFrameCount > 0 && i === coverFrameCount) {
816
+ await frameCapture.setCoverVisible(false);
817
+ }
818
+ const time = i / fps;
819
+ const captureTime = coverPlan.captureTimeForFrame(i);
820
+ const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(captureTime, { reuseIfUnchanged: true }) : frameCapture.captureFrame(captureTime, { reuseIfUnchanged: true });
821
+ const frame = await settleFrameCaptureWithRecovery({
822
+ operation: captureOperation,
823
+ frameNumber: i + 1,
824
+ totalFrames,
825
+ onLateResult: releaseEncoderFrame,
826
+ activityDocument: document,
827
+ onRecoveryWait: () => setPhase(`Frame ${i + 1}/${totalFrames} is taking unusually long \u2014 still waiting\u2026`)
828
+ });
829
+ if (cancelledRef.current) {
830
+ releaseEncoderFrame(frame);
831
+ return;
832
+ }
833
+ const previewOptions = previewOptionsRef.current;
834
+ const previewInterval = Math.max(1, Math.floor(previewOptions.previewEveryNFrames ?? 1));
835
+ if (previewOptions.onFramePreview && (i === 0 || i === totalFrames - 1 || i % previewInterval === 0)) {
836
+ try {
837
+ previewOptions.onFramePreview({ source: frame, frameIndex: i, totalFrames, time });
838
+ } catch {
839
+ }
840
+ }
841
+ setPhase(`Encoding frame ${i + 1}/${totalFrames}`);
842
+ setCurrentFrameTime(time);
843
+ await settleWithin(
844
+ encoder.encodeFrame(frame, i),
845
+ FRAME_ENCODE_TIMEOUT_MS,
846
+ `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`,
847
+ void 0,
848
+ document
849
+ );
850
+ const completedFrames = i + 1;
851
+ const completedAt = performance.now();
852
+ frameBoundaryTimes.push(completedAt);
853
+ if (frameBoundaryTimes.length > FRAME_RATE_WINDOW_SIZE + 1) {
854
+ frameBoundaryTimes.shift();
855
+ }
856
+ setProcessingFps(calculateRollingFramesPerSecond(frameBoundaryTimes));
857
+ setCurrentFrameTime(Math.min(completedFrames / fps, exportDuration));
858
+ setPhase(
859
+ completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames}` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
860
+ );
861
+ const captureRatio = completedFrames / totalFrames;
862
+ const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
863
+ setProgress(Math.round(captureProgress * 10) / 10);
864
+ const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
865
+ const avgPerFrame = elapsedCapture / completedFrames;
866
+ setEstimatedRemaining(Math.round(avgPerFrame * (totalFrames - completedFrames)));
867
+ setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
868
+ }
869
+ if (cancelledRef.current) return;
870
+ setState("encoding");
871
+ setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
872
+ setProgress(95);
873
+ let outputBytes = effectiveOutputFormat === "mp4" && !useFfmpegAudio && encoder.finalizeBlob ? await encoder.finalizeBlob() : await encoder.finalize();
874
+ encoderRef.current = null;
875
+ if (cancelledRef.current) return;
876
+ if (effectiveOutputFormat === "gif") {
877
+ setPhase("Generating GIF palette\u2026");
878
+ const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
879
+ const gifAbort = new AbortController();
880
+ gifAbortRef.current = gifAbort;
881
+ try {
882
+ outputBytes = await transcodeMp4ToGifWithFfmpegWasm(
883
+ videoOnly,
884
+ { width, height, loop: 0 },
885
+ config.ffmpegWasm,
886
+ gifAbort.signal
887
+ );
888
+ } finally {
889
+ if (gifAbortRef.current === gifAbort) gifAbortRef.current = null;
890
+ }
891
+ } else if (useFfmpegAudio && renderedAudio) {
892
+ setPhase("Muxing audio\u2026");
893
+ try {
894
+ const wav = audioBufferToWav(renderedAudio);
895
+ const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
896
+ outputBytes = await muxAudioWithFfmpegWasm(
897
+ videoOnly,
898
+ wav,
899
+ audioBitrate,
900
+ config.ffmpegWasm
901
+ );
902
+ audioIncludedLocal = true;
903
+ } catch (audioErr) {
904
+ audioIncludedLocal = false;
905
+ audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
906
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
907
+ }
908
+ }
909
+ if (cancelledRef.current) return;
910
+ const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
911
+ const blob = outputBytes instanceof Blob ? outputBytes : new Blob(
912
+ [
913
+ outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes)
914
+ ],
915
+ { type: mimeType }
916
+ );
917
+ const url = URL.createObjectURL(blob);
918
+ downloadUrlRef.current = url;
919
+ setDownloadUrl(url);
920
+ setOutputBlob(blob);
921
+ setFileSize(blob.size);
922
+ setAudioIncluded(audioIncludedLocal);
923
+ setAudioSkippedReason(
924
+ effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
925
+ );
926
+ setState("complete");
927
+ setProgress(100);
928
+ setPhase("Export complete");
929
+ setEstimatedRemaining(0);
930
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
931
+ frameCapture.destroy();
932
+ } catch (err) {
933
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
934
+ if (cancelledRef.current) return;
935
+ const message = err instanceof Error ? err.message : String(err);
936
+ setState("error");
937
+ setError(message);
938
+ setPhase("Export failed");
939
+ if (encoderRef.current) {
940
+ encoderRef.current.close();
941
+ encoderRef.current = null;
942
+ }
943
+ gifAbortRef.current?.abort();
944
+ gifAbortRef.current = null;
945
+ frameCapture.destroy();
946
+ }
947
+ },
948
+ [frameCapture]
949
+ );
950
+ return {
951
+ state,
952
+ progress,
953
+ phase,
954
+ currentFrameTime,
955
+ processingFps,
956
+ duration,
957
+ outputFormat,
958
+ backend,
959
+ downloadUrl,
960
+ outputBlob,
961
+ fileSize,
962
+ audioIncluded,
963
+ audioSkippedReason,
964
+ error,
965
+ elapsed,
966
+ estimatedRemaining,
967
+ startExport,
968
+ cancel,
969
+ reset
970
+ };
971
+ }
972
+
973
+ export {
974
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
975
+ resolveVideoExportCover,
976
+ resolveVideoCoverFramePlan,
977
+ useVideoExport
978
+ };