@bendyline/squisq-video-react 2.2.10 → 2.3.0
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 +15 -5
- package/dist/{chunk-2XACUF6E.js → chunk-32QCPFXE.js} +189 -85
- package/dist/{chunk-MEPETH5V.js → chunk-5MFQMJ5Z.js} +157 -3
- package/dist/{chunk-ERG6OLLO.js → chunk-CRXJOZSH.js} +3 -3
- package/dist/chunk-NTRCMTCF.js +401 -0
- package/dist/chunk-U3RSDWQL.js +978 -0
- package/dist/chunk-YTEDBL6F.js +1075 -0
- package/dist/components/index.d.ts +4 -2
- package/dist/components/index.js +14 -5
- package/dist/cover-image/index.d.ts +23 -0
- package/dist/cover-image/index.js +11 -0
- package/dist/encoder/index.d.ts +1 -1
- package/dist/encoder/index.js +2 -2
- package/dist/hooks/index.d.ts +8 -15
- package/dist/hooks/index.js +8 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +18 -6
- package/dist/{mainThreadEncoder-BgcFyYvO.d.ts → mainThreadEncoder-BVCLjlfj.d.ts} +9 -0
- package/dist/{useVideoExport-raCbwbwb.d.ts → useVideoExport-B2eVCvTX.d.ts} +29 -5
- package/dist/workers/encode.worker.js +1 -1
- package/package.json +8 -4
- package/dist/chunk-GLOLS2CQ.js +0 -1567
package/README.md
CHANGED
|
@@ -76,10 +76,15 @@ function App() {
|
|
|
76
76
|
|
|
77
77
|
## Components
|
|
78
78
|
|
|
79
|
-
| Component
|
|
80
|
-
|
|
|
81
|
-
| `VideoExportModal`
|
|
82
|
-
| `VideoExportButton`
|
|
79
|
+
| Component | Description |
|
|
80
|
+
| ----------------------- | ---------------------------------------------------------------------------------- |
|
|
81
|
+
| `VideoExportModal` | Full modal UI — configure MP4/GIF, captions, motion, quality, fps, and orientation |
|
|
82
|
+
| `VideoExportButton` | Drop-in button that opens the export modal via portal |
|
|
83
|
+
| `CoverImageExportModal` | Save the managed cover as PNG, JPEG, or WebP with bounded resolution controls |
|
|
84
|
+
|
|
85
|
+
Import `CoverImageExportModal` from
|
|
86
|
+
`@bendyline/squisq-video-react/cover-image` when a surface only needs cover
|
|
87
|
+
capture. This entry point excludes the MP4/GIF encoder worker graph.
|
|
83
88
|
|
|
84
89
|
## Hooks
|
|
85
90
|
|
|
@@ -94,11 +99,16 @@ The `VideoExportModal` lets users configure:
|
|
|
94
99
|
|
|
95
100
|
- **Format:** MP4 video or animated GIF
|
|
96
101
|
- **Quality:** draft, normal, or high
|
|
97
|
-
- **FPS:** 10, 15, 24, or 30
|
|
102
|
+
- **FPS:** 10, 15, 24, or 30; MP4 defaults to 30 fps and GIF to 10 fps
|
|
98
103
|
- **Orientation:** MP4 defaults to 1920x1080/1080x1920; GIF defaults to 960x540/540x960
|
|
99
104
|
- **Captions:** off, standard, or social
|
|
100
105
|
- **Animations & transitions:** enabled by default for MP4 and disabled by default for GIF
|
|
101
106
|
|
|
107
|
+
Managed covers inherit `squisq-cover-duration` and `squisq-cover-playback`
|
|
108
|
+
from document frontmatter. `preroll` adds the cover before story frame zero
|
|
109
|
+
and shifts audio; `overlay` keeps the exported duration unchanged while the
|
|
110
|
+
story and audio advance underneath the visible cover.
|
|
111
|
+
|
|
102
112
|
## Using the Hook Directly
|
|
103
113
|
|
|
104
114
|
For custom export UIs, use `useVideoExport` directly:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DEFAULT_MP4_SPILL_THRESHOLD_BYTES,
|
|
2
3
|
applyWebCodecsBackpressure,
|
|
3
4
|
createMp4Muxer,
|
|
4
5
|
resolveWebCodecsQueueLimit
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-5MFQMJ5Z.js";
|
|
6
7
|
|
|
7
8
|
// src/mainThreadEncoder.ts
|
|
8
9
|
import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
|
|
@@ -24,6 +25,14 @@ async function supportsWebCodecsH264(config) {
|
|
|
24
25
|
return false;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
28
|
+
var RECLAIMED_CODEC_ERROR = /codec reclaimed due to inactivity/i;
|
|
29
|
+
var MAX_CODEC_RECOVERY_ATTEMPTS = 2;
|
|
30
|
+
function toError(caught) {
|
|
31
|
+
return caught instanceof Error ? caught : new Error(String(caught));
|
|
32
|
+
}
|
|
33
|
+
function isReclaimedCodecError(error) {
|
|
34
|
+
return RECLAIMED_CODEC_ERROR.test(error.message);
|
|
35
|
+
}
|
|
27
36
|
function createEncoder(config) {
|
|
28
37
|
validateVideoExportOptions(config);
|
|
29
38
|
if (!supportsWebCodecs()) {
|
|
@@ -35,64 +44,141 @@ function createEncoder(config) {
|
|
|
35
44
|
width: config.width,
|
|
36
45
|
height: config.height,
|
|
37
46
|
fps: config.fps,
|
|
38
|
-
...config.audio ? { audio: config.audio } : {}
|
|
47
|
+
...config.audio ? { audio: config.audio } : {},
|
|
48
|
+
...config.spillOutputToBlob ? { spillToBlobThresholdBytes: DEFAULT_MP4_SPILL_THRESHOLD_BYTES } : {}
|
|
39
49
|
});
|
|
40
50
|
let closed = false;
|
|
41
51
|
let fatalError = null;
|
|
52
|
+
let recoverableError = null;
|
|
42
53
|
const frameDuration = 1e6 / config.fps;
|
|
43
54
|
const queueLimit = resolveWebCodecsQueueLimit(config);
|
|
44
55
|
let framesSinceFlush = 0;
|
|
56
|
+
let forceNextKeyFrame = false;
|
|
57
|
+
let encoderGeneration = 0;
|
|
58
|
+
let encoder;
|
|
45
59
|
function fail(err) {
|
|
46
60
|
closed = true;
|
|
47
61
|
if (encoder.state !== "closed") encoder.close();
|
|
48
62
|
return fatalError ?? err;
|
|
49
63
|
}
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
error(err) {
|
|
56
|
-
fatalError ?? (fatalError = new Error(`WebCodecs encoder error: ${err.message}`));
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
encoder.configure({
|
|
60
|
-
// Deliberate profile split from the fallback worker (avc1.42001f, Baseline):
|
|
61
|
-
// this primary WebCodecs path targets H.264 High@4.0 for better quality up
|
|
62
|
-
// to 1080p; the wasm-fallback worker uses Baseline for max decoder compat.
|
|
64
|
+
const videoEncoderConfig = {
|
|
65
|
+
// Deliberate profile split from the fallback worker (avc1.42001f,
|
|
66
|
+
// Baseline): this primary WebCodecs path targets H.264 High@4.0 for
|
|
67
|
+
// better quality up to 1080p; the wasm-fallback worker uses Baseline for
|
|
68
|
+
// maximum decoder compatibility.
|
|
63
69
|
codec: "avc1.640028",
|
|
64
|
-
// H.264 High profile, level 4.0 (supports up to 1080p)
|
|
65
70
|
width: config.width,
|
|
66
71
|
height: config.height,
|
|
67
72
|
bitrate: bitrateForQuality(config.quality, config.width, config.height),
|
|
68
73
|
framerate: config.fps
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
};
|
|
75
|
+
function createConfiguredVideoEncoder() {
|
|
76
|
+
const generation = ++encoderGeneration;
|
|
77
|
+
const nextEncoder = new VideoEncoder({
|
|
78
|
+
output(chunk, meta) {
|
|
79
|
+
if (closed || generation !== encoderGeneration) return;
|
|
80
|
+
muxer.addVideoChunk(chunk, meta ?? void 0);
|
|
81
|
+
},
|
|
82
|
+
error(err) {
|
|
83
|
+
if (closed || generation !== encoderGeneration || fatalError || recoverableError) return;
|
|
84
|
+
const wrapped = new Error(`WebCodecs encoder error: ${err.message}`);
|
|
85
|
+
if (isReclaimedCodecError(wrapped)) {
|
|
86
|
+
recoverableError = wrapped;
|
|
87
|
+
} else {
|
|
88
|
+
fatalError = wrapped;
|
|
89
|
+
}
|
|
75
90
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
});
|
|
92
|
+
nextEncoder.configure(videoEncoderConfig);
|
|
93
|
+
return nextEncoder;
|
|
94
|
+
}
|
|
95
|
+
function recoverCodec() {
|
|
96
|
+
const previousEncoder = encoder;
|
|
97
|
+
recoverableError = null;
|
|
98
|
+
if (previousEncoder.state !== "closed") previousEncoder.close();
|
|
99
|
+
encoder = createConfiguredVideoEncoder();
|
|
100
|
+
framesSinceFlush = 0;
|
|
101
|
+
forceNextKeyFrame = true;
|
|
102
|
+
}
|
|
103
|
+
encoder = createConfiguredVideoEncoder();
|
|
104
|
+
async function finishVideoEncoding() {
|
|
105
|
+
if (closed) throw new Error("Encoder already closed");
|
|
106
|
+
let recoveryAttempts = 0;
|
|
107
|
+
while (true) {
|
|
108
|
+
if (fatalError) throw fail(fatalError);
|
|
109
|
+
if (recoverableError) {
|
|
110
|
+
if (recoveryAttempts >= MAX_CODEC_RECOVERY_ATTEMPTS) {
|
|
111
|
+
throw fail(recoverableError);
|
|
112
|
+
}
|
|
113
|
+
recoverCodec();
|
|
114
|
+
recoveryAttempts++;
|
|
79
115
|
}
|
|
80
116
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
117
|
+
await encoder.flush();
|
|
118
|
+
} catch (caught) {
|
|
119
|
+
const error = toError(caught);
|
|
120
|
+
if (!recoverableError && isReclaimedCodecError(error)) {
|
|
121
|
+
recoverableError = error;
|
|
122
|
+
}
|
|
123
|
+
if (recoverableError && recoveryAttempts < MAX_CODEC_RECOVERY_ATTEMPTS) continue;
|
|
124
|
+
throw fail(fatalError ?? recoverableError ?? error);
|
|
125
|
+
}
|
|
126
|
+
if (fatalError) throw fail(fatalError);
|
|
127
|
+
if (recoverableError) continue;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
encoder.close();
|
|
131
|
+
closed = true;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
async encodeFrame(source, frameIndex) {
|
|
135
|
+
try {
|
|
87
136
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
137
|
+
let recoveryAttempts = 0;
|
|
138
|
+
while (true) {
|
|
139
|
+
if (fatalError) throw fail(fatalError);
|
|
140
|
+
if (closed) throw new Error("Encoder already closed");
|
|
141
|
+
if (recoverableError) {
|
|
142
|
+
if (recoveryAttempts >= MAX_CODEC_RECOVERY_ATTEMPTS) {
|
|
143
|
+
throw fail(recoverableError);
|
|
144
|
+
}
|
|
145
|
+
recoverCodec();
|
|
146
|
+
recoveryAttempts++;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const drained = await applyWebCodecsBackpressure(
|
|
150
|
+
encoder,
|
|
151
|
+
queueLimit,
|
|
152
|
+
framesSinceFlush
|
|
153
|
+
);
|
|
154
|
+
if (drained) framesSinceFlush = 0;
|
|
155
|
+
if (fatalError) throw fail(fatalError);
|
|
156
|
+
if (recoverableError) continue;
|
|
157
|
+
if (closed) throw new Error("Encoder already closed");
|
|
158
|
+
const timestamp = Math.round(frameIndex * frameDuration);
|
|
159
|
+
const frame = new VideoFrame(source, { timestamp });
|
|
160
|
+
try {
|
|
161
|
+
const keyFrame = forceNextKeyFrame || frameIndex % 30 === 0;
|
|
162
|
+
encoder.encode(frame, { keyFrame });
|
|
163
|
+
framesSinceFlush++;
|
|
164
|
+
forceNextKeyFrame = false;
|
|
165
|
+
} finally {
|
|
166
|
+
frame.close();
|
|
167
|
+
}
|
|
168
|
+
if (recoverableError) continue;
|
|
169
|
+
return;
|
|
170
|
+
} catch (caught) {
|
|
171
|
+
const error = toError(caught);
|
|
172
|
+
if (!recoverableError && isReclaimedCodecError(error)) {
|
|
173
|
+
recoverableError = error;
|
|
174
|
+
}
|
|
175
|
+
if (recoverableError && recoveryAttempts < MAX_CODEC_RECOVERY_ATTEMPTS) continue;
|
|
176
|
+
throw fail(fatalError ?? recoverableError ?? error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (caught) {
|
|
180
|
+
throw fail(toError(caught));
|
|
93
181
|
}
|
|
94
|
-
} catch (err) {
|
|
95
|
-
throw fail(err instanceof Error ? err : new Error(String(err)));
|
|
96
182
|
} finally {
|
|
97
183
|
if ("close" in source) source.close();
|
|
98
184
|
}
|
|
@@ -102,18 +188,13 @@ function createEncoder(config) {
|
|
|
102
188
|
muxer.addAudioChunk(chunk, meta);
|
|
103
189
|
},
|
|
104
190
|
async finalize() {
|
|
105
|
-
|
|
106
|
-
if (fatalError) throw fail(fatalError);
|
|
107
|
-
try {
|
|
108
|
-
await encoder.flush();
|
|
109
|
-
} catch (err) {
|
|
110
|
-
throw fail(err instanceof Error ? err : new Error(String(err)));
|
|
111
|
-
}
|
|
112
|
-
if (fatalError) throw fail(fatalError);
|
|
113
|
-
encoder.close();
|
|
114
|
-
closed = true;
|
|
191
|
+
await finishVideoEncoding();
|
|
115
192
|
return muxer.finalize();
|
|
116
193
|
},
|
|
194
|
+
async finalizeBlob() {
|
|
195
|
+
await finishVideoEncoding();
|
|
196
|
+
return muxer.finalizeBlob();
|
|
197
|
+
},
|
|
117
198
|
close() {
|
|
118
199
|
if (closed) return;
|
|
119
200
|
closed = true;
|
|
@@ -186,25 +267,35 @@ async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate
|
|
|
186
267
|
if (requiredFailures.length > 0) {
|
|
187
268
|
throw new Error(`No decodable audio track was found in: ${requiredFailures.join(", ")}`);
|
|
188
269
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
270
|
+
const scheduledNodes = [];
|
|
271
|
+
try {
|
|
272
|
+
for (const clip of clips) {
|
|
273
|
+
const buffer = decoded.get(clip.src);
|
|
274
|
+
if (!buffer) {
|
|
275
|
+
if (clip.sourceKind === "video") continue;
|
|
276
|
+
throw new Error(`Audio source was not decoded: ${clip.src}`);
|
|
277
|
+
}
|
|
278
|
+
const node = ctx.createBufferSource();
|
|
279
|
+
node.buffer = buffer;
|
|
280
|
+
node.connect(ctx.destination);
|
|
281
|
+
const when = Math.max(0, clip.startSec);
|
|
282
|
+
const offset = Math.max(0, clip.sourceInSec);
|
|
283
|
+
const duration = Math.max(0, clip.durationSec);
|
|
284
|
+
node.start(when, offset, duration);
|
|
285
|
+
scheduledNodes.push(node);
|
|
195
286
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
287
|
+
if (scheduledNodes.length === 0) return null;
|
|
288
|
+
return await ctx.startRendering();
|
|
289
|
+
} finally {
|
|
290
|
+
for (const node of scheduledNodes) {
|
|
291
|
+
node.disconnect();
|
|
292
|
+
node.buffer = null;
|
|
293
|
+
}
|
|
294
|
+
decoded.clear();
|
|
204
295
|
}
|
|
205
|
-
if (scheduledNodes === 0) return null;
|
|
206
|
-
return ctx.startRendering();
|
|
207
296
|
}
|
|
297
|
+
var AAC_FRAME_SAMPLES = 1024;
|
|
298
|
+
var MAX_AAC_QUEUE_SECONDS = 2;
|
|
208
299
|
async function encodeAacTrack(audioBuffer, sink, bitrate) {
|
|
209
300
|
if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
|
|
210
301
|
throw new Error("WebCodecs AudioEncoder is not available.");
|
|
@@ -219,34 +310,47 @@ async function encodeAacTrack(audioBuffer, sink, bitrate) {
|
|
|
219
310
|
}
|
|
220
311
|
});
|
|
221
312
|
encoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: channels, bitrate });
|
|
222
|
-
const FRAME = 1024;
|
|
223
313
|
const total = audioBuffer.length;
|
|
314
|
+
const queueLimit = Math.max(
|
|
315
|
+
1,
|
|
316
|
+
Math.ceil(sampleRate * MAX_AAC_QUEUE_SECONDS / AAC_FRAME_SAMPLES)
|
|
317
|
+
);
|
|
224
318
|
const channelData = [];
|
|
225
319
|
for (let ch = 0; ch < channels; ch++) {
|
|
226
320
|
channelData.push(audioBuffer.getChannelData(ch));
|
|
227
321
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
322
|
+
try {
|
|
323
|
+
for (let offset = 0; offset < total; offset += AAC_FRAME_SAMPLES) {
|
|
324
|
+
if (encodeError) throw encodeError;
|
|
325
|
+
if (encoder.encodeQueueSize >= queueLimit) {
|
|
326
|
+
await encoder.flush();
|
|
327
|
+
if (encodeError) throw encodeError;
|
|
328
|
+
}
|
|
329
|
+
const count = Math.min(AAC_FRAME_SAMPLES, total - offset);
|
|
330
|
+
const planar = new Float32Array(count * channels);
|
|
331
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
332
|
+
planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
|
|
333
|
+
}
|
|
334
|
+
const timestamp = Math.round(offset / sampleRate * 1e6);
|
|
335
|
+
const audioData = new AudioData({
|
|
336
|
+
format: "f32-planar",
|
|
337
|
+
sampleRate,
|
|
338
|
+
numberOfFrames: count,
|
|
339
|
+
numberOfChannels: channels,
|
|
340
|
+
timestamp,
|
|
341
|
+
data: planar
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
encoder.encode(audioData);
|
|
345
|
+
} finally {
|
|
346
|
+
audioData.close();
|
|
347
|
+
}
|
|
234
348
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
numberOfFrames: count,
|
|
240
|
-
numberOfChannels: channels,
|
|
241
|
-
timestamp,
|
|
242
|
-
data: planar
|
|
243
|
-
});
|
|
244
|
-
encoder.encode(audioData);
|
|
245
|
-
audioData.close();
|
|
349
|
+
await encoder.flush();
|
|
350
|
+
if (encodeError) throw encodeError;
|
|
351
|
+
} finally {
|
|
352
|
+
if (encoder.state !== "closed") encoder.close();
|
|
246
353
|
}
|
|
247
|
-
await encoder.flush();
|
|
248
|
-
encoder.close();
|
|
249
|
-
if (encodeError) throw encodeError;
|
|
250
354
|
}
|
|
251
355
|
function audioBufferToWav(buffer) {
|
|
252
356
|
const channels = buffer.numberOfChannels;
|
|
@@ -1905,8 +1905,151 @@ ensureNotFinalized_fn = function() {
|
|
|
1905
1905
|
};
|
|
1906
1906
|
|
|
1907
1907
|
// src/mp4Mux.ts
|
|
1908
|
+
var DEFAULT_MP4_SPILL_THRESHOLD_BYTES = 32 * 1024 * 1024;
|
|
1909
|
+
var ChunkedMp4Output = class {
|
|
1910
|
+
constructor(spillThresholdBytes = null) {
|
|
1911
|
+
this.spillThresholdBytes = spillThresholdBytes;
|
|
1912
|
+
this.writes = [];
|
|
1913
|
+
this.spilled = [];
|
|
1914
|
+
this.bufferedBytes = 0;
|
|
1915
|
+
this.length = 0;
|
|
1916
|
+
}
|
|
1917
|
+
write(data, position) {
|
|
1918
|
+
const owned = new Uint8Array(data);
|
|
1919
|
+
const end = position + owned.byteLength;
|
|
1920
|
+
if (position >= this.length) {
|
|
1921
|
+
this.writes.push({ position, data: owned });
|
|
1922
|
+
this.bufferedBytes += owned.byteLength;
|
|
1923
|
+
this.length = end;
|
|
1924
|
+
this.maybeSpill();
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const updated = [];
|
|
1928
|
+
for (const existing of this.writes) {
|
|
1929
|
+
const existingEnd = existing.position + existing.data.byteLength;
|
|
1930
|
+
if (existingEnd <= position || existing.position >= end) {
|
|
1931
|
+
updated.push(existing);
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
if (existing.position < position) {
|
|
1935
|
+
updated.push({
|
|
1936
|
+
position: existing.position,
|
|
1937
|
+
data: existing.data.subarray(0, position - existing.position)
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
if (existingEnd > end) {
|
|
1941
|
+
updated.push({
|
|
1942
|
+
position: end,
|
|
1943
|
+
data: existing.data.subarray(end - existing.position)
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
updated.push({ position, data: owned });
|
|
1948
|
+
updated.sort((left, right) => left.position - right.position);
|
|
1949
|
+
this.writes = updated;
|
|
1950
|
+
this.bufferedBytes = updated.reduce((sum, write) => sum + write.data.byteLength, 0);
|
|
1951
|
+
this.length = Math.max(this.length, end);
|
|
1952
|
+
this.maybeSpill();
|
|
1953
|
+
}
|
|
1954
|
+
overlapsSpilled(write) {
|
|
1955
|
+
const end = write.position + write.data.byteLength;
|
|
1956
|
+
return this.spilled.some(
|
|
1957
|
+
(part) => part.position < end && part.position + part.size > write.position
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Consolidate buffered writes into Blob parts once they exceed the
|
|
1962
|
+
* threshold. Writes overlapping an already-spilled region are patches over
|
|
1963
|
+
* Blob bytes; they stay in memory (they are tiny) and win at assembly.
|
|
1964
|
+
*/
|
|
1965
|
+
maybeSpill() {
|
|
1966
|
+
if (this.spillThresholdBytes === null || this.bufferedBytes < this.spillThresholdBytes) return;
|
|
1967
|
+
const spillable = this.writes.filter((write) => !this.overlapsSpilled(write)).sort((left, right) => left.position - right.position);
|
|
1968
|
+
if (spillable.length === 0) return;
|
|
1969
|
+
const keep = new Set(spillable);
|
|
1970
|
+
let run = [];
|
|
1971
|
+
const flushRun = () => {
|
|
1972
|
+
if (run.length === 0) return;
|
|
1973
|
+
const position = run[0].position;
|
|
1974
|
+
const size = run.reduce((sum, write) => sum + write.data.byteLength, 0);
|
|
1975
|
+
this.spilled.push({
|
|
1976
|
+
position,
|
|
1977
|
+
size,
|
|
1978
|
+
blob: new Blob(run.map((write) => write.data))
|
|
1979
|
+
});
|
|
1980
|
+
run = [];
|
|
1981
|
+
};
|
|
1982
|
+
for (const write of spillable) {
|
|
1983
|
+
const previous = run[run.length - 1];
|
|
1984
|
+
if (previous && previous.position + previous.data.byteLength !== write.position) flushRun();
|
|
1985
|
+
run.push(write);
|
|
1986
|
+
}
|
|
1987
|
+
flushRun();
|
|
1988
|
+
this.spilled.sort((left, right) => left.position - right.position);
|
|
1989
|
+
this.writes = this.writes.filter((write) => !keep.has(write));
|
|
1990
|
+
this.bufferedBytes = this.writes.reduce((sum, write) => sum + write.data.byteLength, 0);
|
|
1991
|
+
}
|
|
1992
|
+
/** Regions in position order; in-memory writes take precedence over Blobs. */
|
|
1993
|
+
assembleParts() {
|
|
1994
|
+
const boundaries = /* @__PURE__ */ new Set([0, this.length]);
|
|
1995
|
+
for (const write of this.writes) {
|
|
1996
|
+
boundaries.add(write.position);
|
|
1997
|
+
boundaries.add(write.position + write.data.byteLength);
|
|
1998
|
+
}
|
|
1999
|
+
for (const part of this.spilled) {
|
|
2000
|
+
boundaries.add(part.position);
|
|
2001
|
+
boundaries.add(part.position + part.size);
|
|
2002
|
+
}
|
|
2003
|
+
const sorted = [...boundaries].sort((left, right) => left - right);
|
|
2004
|
+
const parts = [];
|
|
2005
|
+
for (let i = 0; i + 1 < sorted.length; i++) {
|
|
2006
|
+
const start = sorted[i];
|
|
2007
|
+
const end = sorted[i + 1];
|
|
2008
|
+
if (end <= start) continue;
|
|
2009
|
+
const write = this.writes.find(
|
|
2010
|
+
(candidate) => candidate.position <= start && candidate.position + candidate.data.byteLength >= end
|
|
2011
|
+
);
|
|
2012
|
+
if (write) {
|
|
2013
|
+
parts.push(write.data.subarray(start - write.position, end - write.position));
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
const part = this.spilled.find(
|
|
2017
|
+
(candidate) => candidate.position <= start && candidate.position + candidate.size >= end
|
|
2018
|
+
);
|
|
2019
|
+
if (part) {
|
|
2020
|
+
parts.push(part.blob.slice(start - part.position, end - part.position));
|
|
2021
|
+
continue;
|
|
2022
|
+
}
|
|
2023
|
+
parts.push(new Uint8Array(end - start));
|
|
2024
|
+
}
|
|
2025
|
+
return parts;
|
|
2026
|
+
}
|
|
2027
|
+
toArrayBuffer() {
|
|
2028
|
+
if (this.spilled.length > 0) {
|
|
2029
|
+
throw new Error("Spilled MP4 output can only finalize to a Blob");
|
|
2030
|
+
}
|
|
2031
|
+
const output = new Uint8Array(this.length);
|
|
2032
|
+
for (const write of this.writes) output.set(write.data, write.position);
|
|
2033
|
+
this.release();
|
|
2034
|
+
return output.buffer;
|
|
2035
|
+
}
|
|
2036
|
+
toBlob() {
|
|
2037
|
+
const blob = new Blob(this.assembleParts(), { type: "video/mp4" });
|
|
2038
|
+
this.release();
|
|
2039
|
+
return blob;
|
|
2040
|
+
}
|
|
2041
|
+
release() {
|
|
2042
|
+
this.writes = [];
|
|
2043
|
+
this.spilled = [];
|
|
2044
|
+
this.bufferedBytes = 0;
|
|
2045
|
+
this.length = 0;
|
|
2046
|
+
}
|
|
2047
|
+
};
|
|
1908
2048
|
function createMp4Muxer(options) {
|
|
1909
|
-
const
|
|
2049
|
+
const output = new ChunkedMp4Output(options.spillToBlobThresholdBytes ?? null);
|
|
2050
|
+
const target = new StreamTarget({
|
|
2051
|
+
onData: (data, position) => output.write(data, position)
|
|
2052
|
+
});
|
|
1910
2053
|
const muxer = new Muxer({
|
|
1911
2054
|
target,
|
|
1912
2055
|
video: {
|
|
@@ -1930,6 +2073,12 @@ function createMp4Muxer(options) {
|
|
|
1930
2073
|
// releases the sample payload immediately.
|
|
1931
2074
|
fastStart: false
|
|
1932
2075
|
});
|
|
2076
|
+
let finalized = false;
|
|
2077
|
+
const finalizeMuxer = () => {
|
|
2078
|
+
if (finalized) throw new Error("MP4 muxer already finalized");
|
|
2079
|
+
muxer.finalize();
|
|
2080
|
+
finalized = true;
|
|
2081
|
+
};
|
|
1933
2082
|
return {
|
|
1934
2083
|
hasAudioTrack: options.audio !== void 0,
|
|
1935
2084
|
addVideoChunk(chunk, meta) {
|
|
@@ -1945,8 +2094,12 @@ function createMp4Muxer(options) {
|
|
|
1945
2094
|
muxer.addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta);
|
|
1946
2095
|
},
|
|
1947
2096
|
finalize() {
|
|
1948
|
-
|
|
1949
|
-
return
|
|
2097
|
+
finalizeMuxer();
|
|
2098
|
+
return output.toArrayBuffer();
|
|
2099
|
+
},
|
|
2100
|
+
finalizeBlob() {
|
|
2101
|
+
finalizeMuxer();
|
|
2102
|
+
return output.toBlob();
|
|
1950
2103
|
}
|
|
1951
2104
|
};
|
|
1952
2105
|
}
|
|
@@ -1973,6 +2126,7 @@ function shouldEncodeFfmpegBatch(frameCount, byteLength, fps) {
|
|
|
1973
2126
|
}
|
|
1974
2127
|
|
|
1975
2128
|
export {
|
|
2129
|
+
DEFAULT_MP4_SPILL_THRESHOLD_BYTES,
|
|
1976
2130
|
createMp4Muxer,
|
|
1977
2131
|
resolveWebCodecsQueueLimit,
|
|
1978
2132
|
applyWebCodecsBackpressure,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useVideoExport
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-U3RSDWQL.js";
|
|
4
4
|
|
|
5
5
|
// src/VideoExportModal.tsx
|
|
6
6
|
import { useState, useCallback, useId, useRef } from "react";
|
|
@@ -174,7 +174,7 @@ function VideoExportModal({
|
|
|
174
174
|
const initialOutputFormat = defaultConfig?.outputFormat ?? "mp4";
|
|
175
175
|
const [outputFormat, setOutputFormat] = useState(initialOutputFormat);
|
|
176
176
|
const [quality, setQuality] = useState(defaultConfig?.quality ?? "normal");
|
|
177
|
-
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 :
|
|
177
|
+
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 : 30));
|
|
178
178
|
const [orientation, setOrientation] = useState(
|
|
179
179
|
defaultConfig?.orientation ?? "landscape"
|
|
180
180
|
);
|
|
@@ -251,7 +251,7 @@ function VideoExportModal({
|
|
|
251
251
|
setAnimationsEnabled(false);
|
|
252
252
|
setCaptionMode("standard");
|
|
253
253
|
} else {
|
|
254
|
-
setFps(
|
|
254
|
+
setFps(30);
|
|
255
255
|
setAnimationsEnabled(true);
|
|
256
256
|
setCaptionMode("off");
|
|
257
257
|
}
|