@bendyline/squisq-video-react 1.2.0 → 1.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 +31 -5
- package/dist/chunk-6DKLHXX3.js +47 -0
- package/dist/chunk-6DKLHXX3.js.map +1 -0
- package/dist/index.d.ts +120 -38
- package/dist/index.js +320 -25
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +3 -22
- package/dist/workers/encode.worker.js.map +1 -1
- package/package.json +8 -5
- package/src/VideoExportButton.tsx +14 -2
- package/src/VideoExportModal.tsx +37 -7
- package/src/__tests__/audioTrack.test.ts +108 -0
- package/src/__tests__/mp4MuxAudio.test.ts +72 -0
- package/src/__tests__/videoExportProps.test.tsx +73 -0
- package/src/audioTrack.ts +323 -0
- package/src/hooks/useFrameCapture.ts +1 -1
- package/src/hooks/useVideoExport.ts +187 -6
- package/src/index.ts +5 -2
- package/src/mainThreadEncoder.ts +26 -13
- package/src/mp4Mux.ts +77 -3
- package/src/workers/encode.worker.ts +6 -9
- package/dist/chunk-LZYWP4HB.js +0 -28
- package/dist/chunk-LZYWP4HB.js.map +0 -1
- package/src/__tests__/encodeParams.test.ts +0 -33
- package/src/workers/encodeParams.ts +0 -24
package/dist/index.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createMp4Muxer
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-6DKLHXX3.js";
|
|
4
4
|
|
|
5
5
|
// src/VideoExportModal.tsx
|
|
6
6
|
import { useState as useState2, useCallback as useCallback3 } from "react";
|
|
7
7
|
|
|
8
8
|
// src/hooks/useVideoExport.ts
|
|
9
9
|
import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } from "react";
|
|
10
|
-
import { resolveDimensions } from "@bendyline/squisq-video";
|
|
10
|
+
import { resolveDimensions, computeAudioTimeline, QUALITY_PRESETS } from "@bendyline/squisq-video";
|
|
11
11
|
|
|
12
12
|
// src/mainThreadEncoder.ts
|
|
13
|
+
import { bitrateForQuality } from "@bendyline/squisq-video";
|
|
13
14
|
function supportsWebCodecs() {
|
|
14
15
|
return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
|
|
15
16
|
}
|
|
@@ -28,18 +29,6 @@ async function supportsWebCodecsH264(config) {
|
|
|
28
29
|
return false;
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
|
-
function bitrateForQuality(quality, width, height) {
|
|
32
|
-
const pixels = width * height;
|
|
33
|
-
const baseBitrate = pixels * 4;
|
|
34
|
-
switch (quality) {
|
|
35
|
-
case "draft":
|
|
36
|
-
return Math.round(baseBitrate * 0.5);
|
|
37
|
-
case "high":
|
|
38
|
-
return Math.round(baseBitrate * 2);
|
|
39
|
-
default:
|
|
40
|
-
return baseBitrate;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
32
|
function createEncoder(config) {
|
|
44
33
|
if (!supportsWebCodecs()) {
|
|
45
34
|
throw new Error(
|
|
@@ -54,7 +43,8 @@ function createEncoder(config) {
|
|
|
54
43
|
const muxer = createMp4Muxer({
|
|
55
44
|
width: config.width,
|
|
56
45
|
height: config.height,
|
|
57
|
-
fps: config.fps
|
|
46
|
+
fps: config.fps,
|
|
47
|
+
...config.audio ? { audio: config.audio } : {}
|
|
58
48
|
});
|
|
59
49
|
let closed = false;
|
|
60
50
|
const frameDuration = 1e6 / config.fps;
|
|
@@ -68,6 +58,9 @@ function createEncoder(config) {
|
|
|
68
58
|
}
|
|
69
59
|
});
|
|
70
60
|
encoder.configure({
|
|
61
|
+
// Deliberate profile split from the fallback worker (avc1.42001f, Baseline):
|
|
62
|
+
// this primary WebCodecs path targets H.264 High@4.0 for better quality up
|
|
63
|
+
// to 1080p; the wasm-fallback worker uses Baseline for max decoder compat.
|
|
71
64
|
codec: "avc1.640028",
|
|
72
65
|
// H.264 High profile, level 4.0 (supports up to 1080p)
|
|
73
66
|
width: config.width,
|
|
@@ -88,6 +81,10 @@ function createEncoder(config) {
|
|
|
88
81
|
frame.close();
|
|
89
82
|
bitmap.close();
|
|
90
83
|
},
|
|
84
|
+
addAudioChunk(chunk, meta) {
|
|
85
|
+
if (closed || !muxer.hasAudioTrack) return;
|
|
86
|
+
muxer.addAudioChunk(chunk, meta);
|
|
87
|
+
},
|
|
91
88
|
async finalize() {
|
|
92
89
|
if (closed) throw new Error("Encoder already closed");
|
|
93
90
|
await encoder.flush();
|
|
@@ -198,6 +195,178 @@ function createWorkerEncoder(config) {
|
|
|
198
195
|
};
|
|
199
196
|
}
|
|
200
197
|
|
|
198
|
+
// src/audioTrack.ts
|
|
199
|
+
var EXPORT_AUDIO_SAMPLE_RATE = 48e3;
|
|
200
|
+
var EXPORT_AUDIO_CHANNELS = 2;
|
|
201
|
+
var REASON_NO_AAC_NO_SAB = "This browser cannot encode AAC audio (WebCodecs AudioEncoder unavailable) and the ffmpeg.wasm fallback requires cross-origin isolation (SharedArrayBuffer).";
|
|
202
|
+
async function supportsWebCodecsAac(sampleRate = EXPORT_AUDIO_SAMPLE_RATE, channels = EXPORT_AUDIO_CHANNELS) {
|
|
203
|
+
if (typeof AudioEncoder === "undefined") return false;
|
|
204
|
+
try {
|
|
205
|
+
const support = await AudioEncoder.isConfigSupported({
|
|
206
|
+
codec: "mp4a.40.2",
|
|
207
|
+
sampleRate,
|
|
208
|
+
numberOfChannels: channels,
|
|
209
|
+
bitrate: 128e3
|
|
210
|
+
});
|
|
211
|
+
return support.supported === true;
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function selectAudioTier(inputs) {
|
|
217
|
+
if (!inputs.hasClips) {
|
|
218
|
+
return { tier: 3, reason: null };
|
|
219
|
+
}
|
|
220
|
+
if (inputs.aacSupported && inputs.canUseMainThreadWebCodecs) {
|
|
221
|
+
return { tier: 1, reason: null };
|
|
222
|
+
}
|
|
223
|
+
if (inputs.sharedArrayBufferAvailable) {
|
|
224
|
+
return { tier: 2, reason: null };
|
|
225
|
+
}
|
|
226
|
+
return { tier: 3, reason: REASON_NO_AAC_NO_SAB };
|
|
227
|
+
}
|
|
228
|
+
function resolveOfflineAudioContext() {
|
|
229
|
+
const g = globalThis;
|
|
230
|
+
const ctor = g.OfflineAudioContext ?? g.webkitOfflineAudioContext;
|
|
231
|
+
if (!ctor) {
|
|
232
|
+
throw new Error("OfflineAudioContext is not available in this environment.");
|
|
233
|
+
}
|
|
234
|
+
return ctor;
|
|
235
|
+
}
|
|
236
|
+
async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate = EXPORT_AUDIO_SAMPLE_RATE) {
|
|
237
|
+
const Ctor = resolveOfflineAudioContext();
|
|
238
|
+
const channels = EXPORT_AUDIO_CHANNELS;
|
|
239
|
+
const length = Math.max(1, Math.ceil(totalDurationSec * sampleRate));
|
|
240
|
+
const ctx = new Ctor(channels, length, sampleRate);
|
|
241
|
+
const decoded = /* @__PURE__ */ new Map();
|
|
242
|
+
for (const [src, data] of buffers) {
|
|
243
|
+
try {
|
|
244
|
+
decoded.set(src, await ctx.decodeAudioData(data.slice(0)));
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const clip of clips) {
|
|
249
|
+
const buffer = decoded.get(clip.src);
|
|
250
|
+
if (!buffer) continue;
|
|
251
|
+
const node = ctx.createBufferSource();
|
|
252
|
+
node.buffer = buffer;
|
|
253
|
+
node.connect(ctx.destination);
|
|
254
|
+
const when = Math.max(0, clip.startSec);
|
|
255
|
+
const offset = Math.max(0, clip.sourceInSec);
|
|
256
|
+
const duration = Math.max(0, clip.durationSec);
|
|
257
|
+
node.start(when, offset, duration);
|
|
258
|
+
}
|
|
259
|
+
return ctx.startRendering();
|
|
260
|
+
}
|
|
261
|
+
async function encodeAacTrack(audioBuffer, sink, bitrate) {
|
|
262
|
+
if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
|
|
263
|
+
throw new Error("WebCodecs AudioEncoder is not available.");
|
|
264
|
+
}
|
|
265
|
+
const sampleRate = audioBuffer.sampleRate;
|
|
266
|
+
const channels = audioBuffer.numberOfChannels;
|
|
267
|
+
let encodeError = null;
|
|
268
|
+
const encoder = new AudioEncoder({
|
|
269
|
+
output: (chunk, meta) => sink.addAudioChunk(chunk, meta ?? void 0),
|
|
270
|
+
error: (err) => {
|
|
271
|
+
encodeError = err instanceof Error ? err : new Error(String(err));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
encoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: channels, bitrate });
|
|
275
|
+
const FRAME = 1024;
|
|
276
|
+
const total = audioBuffer.length;
|
|
277
|
+
const channelData = [];
|
|
278
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
279
|
+
channelData.push(audioBuffer.getChannelData(ch));
|
|
280
|
+
}
|
|
281
|
+
for (let offset = 0; offset < total; offset += FRAME) {
|
|
282
|
+
if (encodeError) break;
|
|
283
|
+
const count = Math.min(FRAME, total - offset);
|
|
284
|
+
const planar = new Float32Array(count * channels);
|
|
285
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
286
|
+
planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
|
|
287
|
+
}
|
|
288
|
+
const timestamp = Math.round(offset / sampleRate * 1e6);
|
|
289
|
+
const audioData = new AudioData({
|
|
290
|
+
format: "f32-planar",
|
|
291
|
+
sampleRate,
|
|
292
|
+
numberOfFrames: count,
|
|
293
|
+
numberOfChannels: channels,
|
|
294
|
+
timestamp,
|
|
295
|
+
data: planar
|
|
296
|
+
});
|
|
297
|
+
encoder.encode(audioData);
|
|
298
|
+
audioData.close();
|
|
299
|
+
}
|
|
300
|
+
await encoder.flush();
|
|
301
|
+
encoder.close();
|
|
302
|
+
if (encodeError) throw encodeError;
|
|
303
|
+
}
|
|
304
|
+
function audioBufferToWav(buffer) {
|
|
305
|
+
const channels = buffer.numberOfChannels;
|
|
306
|
+
const sampleRate = buffer.sampleRate;
|
|
307
|
+
const frames = buffer.length;
|
|
308
|
+
const bytesPerSample = 2;
|
|
309
|
+
const blockAlign = channels * bytesPerSample;
|
|
310
|
+
const dataSize = frames * blockAlign;
|
|
311
|
+
const out = new ArrayBuffer(44 + dataSize);
|
|
312
|
+
const view = new DataView(out);
|
|
313
|
+
const writeStr = (offset, str) => {
|
|
314
|
+
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
|
|
315
|
+
};
|
|
316
|
+
writeStr(0, "RIFF");
|
|
317
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
318
|
+
writeStr(8, "WAVE");
|
|
319
|
+
writeStr(12, "fmt ");
|
|
320
|
+
view.setUint32(16, 16, true);
|
|
321
|
+
view.setUint16(20, 1, true);
|
|
322
|
+
view.setUint16(22, channels, true);
|
|
323
|
+
view.setUint32(24, sampleRate, true);
|
|
324
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
325
|
+
view.setUint16(32, blockAlign, true);
|
|
326
|
+
view.setUint16(34, bytesPerSample * 8, true);
|
|
327
|
+
writeStr(36, "data");
|
|
328
|
+
view.setUint32(40, dataSize, true);
|
|
329
|
+
const channelData = [];
|
|
330
|
+
for (let ch = 0; ch < channels; ch++) channelData.push(buffer.getChannelData(ch));
|
|
331
|
+
let pos = 44;
|
|
332
|
+
for (let i = 0; i < frames; i++) {
|
|
333
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
334
|
+
let sample = channelData[ch][i];
|
|
335
|
+
sample = Math.max(-1, Math.min(1, sample));
|
|
336
|
+
view.setInt16(pos, sample < 0 ? sample * 32768 : sample * 32767, true);
|
|
337
|
+
pos += 2;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return new Uint8Array(out);
|
|
341
|
+
}
|
|
342
|
+
async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate) {
|
|
343
|
+
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
|
|
344
|
+
const ffmpeg = new FFmpeg();
|
|
345
|
+
await ffmpeg.load();
|
|
346
|
+
try {
|
|
347
|
+
await ffmpeg.writeFile("video.mp4", videoMp4);
|
|
348
|
+
await ffmpeg.writeFile("audio.wav", wav);
|
|
349
|
+
await ffmpeg.exec([
|
|
350
|
+
"-i",
|
|
351
|
+
"video.mp4",
|
|
352
|
+
"-i",
|
|
353
|
+
"audio.wav",
|
|
354
|
+
"-c:v",
|
|
355
|
+
"copy",
|
|
356
|
+
"-c:a",
|
|
357
|
+
"aac",
|
|
358
|
+
"-b:a",
|
|
359
|
+
String(audioBitrate),
|
|
360
|
+
"-shortest",
|
|
361
|
+
"out.mp4"
|
|
362
|
+
]);
|
|
363
|
+
const data = await ffmpeg.readFile("out.mp4");
|
|
364
|
+
return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
365
|
+
} finally {
|
|
366
|
+
ffmpeg.terminate();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
201
370
|
// src/hooks/useFrameCapture.ts
|
|
202
371
|
import { createElement } from "react";
|
|
203
372
|
import { createRoot } from "react-dom/client";
|
|
@@ -289,7 +458,7 @@ function useFrameCapture() {
|
|
|
289
458
|
const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
|
|
290
459
|
const captionStyle = captionMode === "social" ? "social" : "standard";
|
|
291
460
|
const playerElement = createElement(DocPlayer, {
|
|
292
|
-
|
|
461
|
+
doc,
|
|
293
462
|
basePath: ".",
|
|
294
463
|
renderMode: true,
|
|
295
464
|
showControls: false,
|
|
@@ -373,6 +542,23 @@ function useFrameCapture() {
|
|
|
373
542
|
}
|
|
374
543
|
|
|
375
544
|
// src/hooks/useVideoExport.ts
|
|
545
|
+
async function resolveAudioBuffers(clips, sources) {
|
|
546
|
+
const srcs = new Set(clips.map((c) => c.src));
|
|
547
|
+
const out = /* @__PURE__ */ new Map();
|
|
548
|
+
for (const src of srcs) {
|
|
549
|
+
let data = sources.audio?.get(src) ?? sources.images?.get(src);
|
|
550
|
+
if (!data && sources.mediaProvider) {
|
|
551
|
+
try {
|
|
552
|
+
const url = await sources.mediaProvider.resolveUrl(src);
|
|
553
|
+
const res = await fetch(url);
|
|
554
|
+
if (res.ok) data = await res.arrayBuffer();
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (data) out.set(src, data);
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
376
562
|
function useVideoExport() {
|
|
377
563
|
const [state, setState] = useState("idle");
|
|
378
564
|
const [progress, setProgress] = useState(0);
|
|
@@ -381,6 +567,8 @@ function useVideoExport() {
|
|
|
381
567
|
const [backend, setBackend] = useState(null);
|
|
382
568
|
const [downloadUrl, setDownloadUrl] = useState(null);
|
|
383
569
|
const [fileSize, setFileSize] = useState(0);
|
|
570
|
+
const [audioIncluded, setAudioIncluded] = useState(false);
|
|
571
|
+
const [audioSkippedReason, setAudioSkippedReason] = useState(null);
|
|
384
572
|
const [error, setError] = useState(null);
|
|
385
573
|
const [elapsed, setElapsed] = useState(0);
|
|
386
574
|
const [estimatedRemaining, setEstimatedRemaining] = useState(0);
|
|
@@ -419,6 +607,8 @@ function useVideoExport() {
|
|
|
419
607
|
setBackend(null);
|
|
420
608
|
setDownloadUrl(null);
|
|
421
609
|
setFileSize(0);
|
|
610
|
+
setAudioIncluded(false);
|
|
611
|
+
setAudioSkippedReason(null);
|
|
422
612
|
setError(null);
|
|
423
613
|
setElapsed(0);
|
|
424
614
|
setEstimatedRemaining(0);
|
|
@@ -446,6 +636,8 @@ function useVideoExport() {
|
|
|
446
636
|
}
|
|
447
637
|
setDownloadUrl(null);
|
|
448
638
|
setFileSize(0);
|
|
639
|
+
setAudioIncluded(false);
|
|
640
|
+
setAudioSkippedReason(null);
|
|
449
641
|
setError(null);
|
|
450
642
|
const quality = config.quality ?? "normal";
|
|
451
643
|
const fps = config.fps ?? 30;
|
|
@@ -495,9 +687,62 @@ function useVideoExport() {
|
|
|
495
687
|
setPhase("Starting encoder\u2026");
|
|
496
688
|
setProgress(5);
|
|
497
689
|
const canUseWebCodecs = webCodecsAvailable && await supportsWebCodecsH264({ width, height, fps, quality });
|
|
690
|
+
const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
|
|
691
|
+
const timeline = computeAudioTimeline(doc, 0);
|
|
692
|
+
const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
|
|
693
|
+
const tierDecision = selectAudioTier({
|
|
694
|
+
hasClips: timeline.length > 0,
|
|
695
|
+
aacSupported,
|
|
696
|
+
sharedArrayBufferAvailable,
|
|
697
|
+
canUseMainThreadWebCodecs: canUseWebCodecs
|
|
698
|
+
});
|
|
699
|
+
let renderedAudio = null;
|
|
700
|
+
let audioIncludedLocal = false;
|
|
701
|
+
let audioReasonLocal = tierDecision.reason;
|
|
702
|
+
if (tierDecision.tier === 1 || tierDecision.tier === 2) {
|
|
703
|
+
setPhase("Preparing audio\u2026");
|
|
704
|
+
try {
|
|
705
|
+
const buffers = await resolveAudioBuffers(timeline, {
|
|
706
|
+
audio: config.audio,
|
|
707
|
+
images,
|
|
708
|
+
mediaProvider: config.mediaProvider
|
|
709
|
+
});
|
|
710
|
+
if (buffers.size === 0) {
|
|
711
|
+
audioReasonLocal = "Audio files for this document could not be loaded.";
|
|
712
|
+
} else {
|
|
713
|
+
const totalAudioDur = timeline.reduce(
|
|
714
|
+
(max, c) => Math.max(max, c.startSec + c.durationSec),
|
|
715
|
+
docDuration
|
|
716
|
+
);
|
|
717
|
+
renderedAudio = await renderAudioTimeline(
|
|
718
|
+
timeline,
|
|
719
|
+
buffers,
|
|
720
|
+
totalAudioDur,
|
|
721
|
+
EXPORT_AUDIO_SAMPLE_RATE
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
} catch (audioErr) {
|
|
725
|
+
renderedAudio = null;
|
|
726
|
+
audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
|
|
730
|
+
const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
|
|
731
|
+
if (cancelledRef.current) return;
|
|
498
732
|
let encoder;
|
|
499
733
|
if (canUseWebCodecs) {
|
|
500
|
-
encoder = createEncoder({
|
|
734
|
+
encoder = createEncoder({
|
|
735
|
+
width,
|
|
736
|
+
height,
|
|
737
|
+
fps,
|
|
738
|
+
quality,
|
|
739
|
+
...useInlineAudio && renderedAudio ? {
|
|
740
|
+
audio: {
|
|
741
|
+
numberOfChannels: renderedAudio.numberOfChannels,
|
|
742
|
+
sampleRate: renderedAudio.sampleRate
|
|
743
|
+
}
|
|
744
|
+
} : {}
|
|
745
|
+
});
|
|
501
746
|
setBackend("webcodecs");
|
|
502
747
|
} else if (sharedArrayBufferAvailable) {
|
|
503
748
|
const workerEncoder = createWorkerEncoder({ width, height, fps, quality });
|
|
@@ -540,17 +785,48 @@ function useVideoExport() {
|
|
|
540
785
|
encoder.encodeFrame(bitmap, i);
|
|
541
786
|
}
|
|
542
787
|
if (cancelledRef.current) return;
|
|
788
|
+
if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
|
|
789
|
+
setState("encoding");
|
|
790
|
+
setPhase("Encoding audio\u2026");
|
|
791
|
+
try {
|
|
792
|
+
await encodeAacTrack(
|
|
793
|
+
renderedAudio,
|
|
794
|
+
{ addAudioChunk: encoder.addAudioChunk.bind(encoder) },
|
|
795
|
+
audioBitrate
|
|
796
|
+
);
|
|
797
|
+
audioIncludedLocal = true;
|
|
798
|
+
} catch (audioErr) {
|
|
799
|
+
audioIncludedLocal = false;
|
|
800
|
+
audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
543
803
|
setState("encoding");
|
|
544
804
|
setPhase("Finalizing video\u2026");
|
|
545
805
|
setProgress(95);
|
|
546
|
-
|
|
806
|
+
let outputBytes = await encoder.finalize();
|
|
547
807
|
encoderRef.current = null;
|
|
548
808
|
if (cancelledRef.current) return;
|
|
549
|
-
|
|
809
|
+
if (useFfmpegAudio && renderedAudio) {
|
|
810
|
+
setPhase("Muxing audio\u2026");
|
|
811
|
+
try {
|
|
812
|
+
const wav = audioBufferToWav(renderedAudio);
|
|
813
|
+
const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
814
|
+
outputBytes = await muxAudioWithFfmpegWasm(videoOnly, wav, audioBitrate);
|
|
815
|
+
audioIncludedLocal = true;
|
|
816
|
+
} catch (audioErr) {
|
|
817
|
+
audioIncludedLocal = false;
|
|
818
|
+
audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (cancelledRef.current) return;
|
|
822
|
+
const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
|
|
823
|
+
const blob = new Blob([finalBytes], { type: "video/mp4" });
|
|
550
824
|
const url = URL.createObjectURL(blob);
|
|
551
825
|
downloadUrlRef.current = url;
|
|
552
826
|
setDownloadUrl(url);
|
|
553
|
-
setFileSize(
|
|
827
|
+
setFileSize(finalBytes.byteLength);
|
|
828
|
+
setAudioIncluded(audioIncludedLocal);
|
|
829
|
+
setAudioSkippedReason(audioIncludedLocal ? null : audioReasonLocal);
|
|
554
830
|
setState("complete");
|
|
555
831
|
setProgress(100);
|
|
556
832
|
setPhase("Export complete");
|
|
@@ -581,6 +857,8 @@ function useVideoExport() {
|
|
|
581
857
|
backend,
|
|
582
858
|
downloadUrl,
|
|
583
859
|
fileSize,
|
|
860
|
+
audioIncluded,
|
|
861
|
+
audioSkippedReason,
|
|
584
862
|
error,
|
|
585
863
|
elapsed,
|
|
586
864
|
estimatedRemaining,
|
|
@@ -684,12 +962,15 @@ function VideoExportModal({
|
|
|
684
962
|
mediaProvider,
|
|
685
963
|
images,
|
|
686
964
|
audio,
|
|
965
|
+
defaultConfig,
|
|
687
966
|
onClose
|
|
688
967
|
}) {
|
|
689
|
-
const [quality, setQuality] = useState2("normal");
|
|
690
|
-
const [fps, setFps] = useState2(24);
|
|
691
|
-
const [orientation, setOrientation] = useState2(
|
|
692
|
-
|
|
968
|
+
const [quality, setQuality] = useState2(defaultConfig?.quality ?? "normal");
|
|
969
|
+
const [fps, setFps] = useState2(defaultConfig?.fps ?? 24);
|
|
970
|
+
const [orientation, setOrientation] = useState2(
|
|
971
|
+
defaultConfig?.orientation ?? "landscape"
|
|
972
|
+
);
|
|
973
|
+
const [captionMode, setCaptionMode] = useState2(defaultConfig?.captionMode ?? "off");
|
|
693
974
|
const exportHook = useVideoExport();
|
|
694
975
|
const {
|
|
695
976
|
state,
|
|
@@ -697,6 +978,8 @@ function VideoExportModal({
|
|
|
697
978
|
backend,
|
|
698
979
|
downloadUrl,
|
|
699
980
|
fileSize,
|
|
981
|
+
audioIncluded,
|
|
982
|
+
audioSkippedReason,
|
|
700
983
|
error,
|
|
701
984
|
elapsed,
|
|
702
985
|
estimatedRemaining,
|
|
@@ -706,6 +989,8 @@ function VideoExportModal({
|
|
|
706
989
|
} = exportHook;
|
|
707
990
|
const handleExport = useCallback3(async () => {
|
|
708
991
|
const config = {
|
|
992
|
+
// defaultConfig is the base; explicit props/selections win over it.
|
|
993
|
+
...defaultConfig,
|
|
709
994
|
quality,
|
|
710
995
|
fps,
|
|
711
996
|
orientation,
|
|
@@ -713,7 +998,8 @@ function VideoExportModal({
|
|
|
713
998
|
images,
|
|
714
999
|
audio,
|
|
715
1000
|
mediaProvider,
|
|
716
|
-
|
|
1001
|
+
// Only thread the bundle through when the host actually supplied one.
|
|
1002
|
+
...playerScript !== void 0 ? { playerScript } : {}
|
|
717
1003
|
};
|
|
718
1004
|
await startExport(doc, config);
|
|
719
1005
|
}, [
|
|
@@ -726,6 +1012,7 @@ function VideoExportModal({
|
|
|
726
1012
|
audio,
|
|
727
1013
|
mediaProvider,
|
|
728
1014
|
playerScript,
|
|
1015
|
+
defaultConfig,
|
|
729
1016
|
startExport
|
|
730
1017
|
]);
|
|
731
1018
|
const handleDownload = useCallback3(() => {
|
|
@@ -848,6 +1135,10 @@ function VideoExportModal({
|
|
|
848
1135
|
(fileSize / (1024 * 1024)).toFixed(1),
|
|
849
1136
|
" MB"
|
|
850
1137
|
] }),
|
|
1138
|
+
audioIncluded ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#2d6a10", margin: "0 0 4px 0" }, children: "Audio included \u2713" }) : /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 4px 0" }, children: [
|
|
1139
|
+
"Video only",
|
|
1140
|
+
audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
|
|
1141
|
+
] }),
|
|
851
1142
|
backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 12px 0" }, children: "Encoded with WebCodecs (H.264)" }),
|
|
852
1143
|
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
853
1144
|
/* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
|
|
@@ -886,6 +1177,7 @@ function VideoExportButton({
|
|
|
886
1177
|
mediaProvider,
|
|
887
1178
|
images,
|
|
888
1179
|
audio,
|
|
1180
|
+
defaultConfig,
|
|
889
1181
|
label = "Export Video",
|
|
890
1182
|
style,
|
|
891
1183
|
disabled
|
|
@@ -904,6 +1196,7 @@ function VideoExportButton({
|
|
|
904
1196
|
mediaProvider,
|
|
905
1197
|
images,
|
|
906
1198
|
audio,
|
|
1199
|
+
defaultConfig,
|
|
907
1200
|
onClose: handleClose
|
|
908
1201
|
}
|
|
909
1202
|
),
|
|
@@ -916,6 +1209,8 @@ export {
|
|
|
916
1209
|
VideoExportModal,
|
|
917
1210
|
createEncoder,
|
|
918
1211
|
supportsWebCodecs,
|
|
1212
|
+
supportsWebCodecsAac,
|
|
1213
|
+
supportsWebCodecsH264,
|
|
919
1214
|
useFrameCapture,
|
|
920
1215
|
useVideoExport
|
|
921
1216
|
};
|