@bendyline/squisq-video-react 2.2.9 → 2.2.11
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 +6 -2
- package/dist/{chunk-NX34XGLL.js → chunk-F2XUI32B.js} +646 -98
- package/dist/{chunk-MEPETH5V.js → chunk-I4SXMCDF.js} +78 -3
- package/dist/{chunk-2XACUF6E.js → chunk-KJ5RKG67.js} +186 -84
- package/dist/{chunk-SO656KT7.js → chunk-KW5BBYKP.js} +48 -17
- package/dist/components/index.d.ts +12 -4
- package/dist/components/index.js +4 -4
- package/dist/encoder/index.d.ts +1 -1
- package/dist/encoder/index.js +2 -2
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -4
- package/dist/{mainThreadEncoder-BgcFyYvO.d.ts → mainThreadEncoder-CiVsL1Bf.d.ts} +2 -0
- package/dist/{useVideoExport-DKpdXZ0o.d.ts → useVideoExport-CM5XiM6Z.d.ts} +3 -1
- package/dist/workers/encode.worker.js +1 -1
- package/package.json +4 -4
|
@@ -1905,8 +1905,73 @@ ensureNotFinalized_fn = function() {
|
|
|
1905
1905
|
};
|
|
1906
1906
|
|
|
1907
1907
|
// src/mp4Mux.ts
|
|
1908
|
+
var ChunkedMp4Output = class {
|
|
1909
|
+
constructor() {
|
|
1910
|
+
this.writes = [];
|
|
1911
|
+
this.length = 0;
|
|
1912
|
+
}
|
|
1913
|
+
write(data, position) {
|
|
1914
|
+
const owned = new Uint8Array(data);
|
|
1915
|
+
const end = position + owned.byteLength;
|
|
1916
|
+
if (position >= this.length) {
|
|
1917
|
+
this.writes.push({ position, data: owned });
|
|
1918
|
+
this.length = end;
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const updated = [];
|
|
1922
|
+
for (const existing of this.writes) {
|
|
1923
|
+
const existingEnd = existing.position + existing.data.byteLength;
|
|
1924
|
+
if (existingEnd <= position || existing.position >= end) {
|
|
1925
|
+
updated.push(existing);
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
if (existing.position < position) {
|
|
1929
|
+
updated.push({
|
|
1930
|
+
position: existing.position,
|
|
1931
|
+
data: existing.data.subarray(0, position - existing.position)
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (existingEnd > end) {
|
|
1935
|
+
updated.push({
|
|
1936
|
+
position: end,
|
|
1937
|
+
data: existing.data.subarray(end - existing.position)
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
updated.push({ position, data: owned });
|
|
1942
|
+
updated.sort((left, right) => left.position - right.position);
|
|
1943
|
+
this.writes = updated;
|
|
1944
|
+
this.length = Math.max(this.length, end);
|
|
1945
|
+
}
|
|
1946
|
+
toArrayBuffer() {
|
|
1947
|
+
const output = new Uint8Array(this.length);
|
|
1948
|
+
for (const write of this.writes) output.set(write.data, write.position);
|
|
1949
|
+
this.release();
|
|
1950
|
+
return output.buffer;
|
|
1951
|
+
}
|
|
1952
|
+
toBlob() {
|
|
1953
|
+
const parts = [];
|
|
1954
|
+
let position = 0;
|
|
1955
|
+
for (const write of this.writes) {
|
|
1956
|
+
if (write.position > position) parts.push(new Uint8Array(write.position - position));
|
|
1957
|
+
parts.push(write.data);
|
|
1958
|
+
position = write.position + write.data.byteLength;
|
|
1959
|
+
}
|
|
1960
|
+
if (position < this.length) parts.push(new Uint8Array(this.length - position));
|
|
1961
|
+
const blob = new Blob(parts, { type: "video/mp4" });
|
|
1962
|
+
this.release();
|
|
1963
|
+
return blob;
|
|
1964
|
+
}
|
|
1965
|
+
release() {
|
|
1966
|
+
this.writes = [];
|
|
1967
|
+
this.length = 0;
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1908
1970
|
function createMp4Muxer(options) {
|
|
1909
|
-
const
|
|
1971
|
+
const output = new ChunkedMp4Output();
|
|
1972
|
+
const target = new StreamTarget({
|
|
1973
|
+
onData: (data, position) => output.write(data, position)
|
|
1974
|
+
});
|
|
1910
1975
|
const muxer = new Muxer({
|
|
1911
1976
|
target,
|
|
1912
1977
|
video: {
|
|
@@ -1930,6 +1995,12 @@ function createMp4Muxer(options) {
|
|
|
1930
1995
|
// releases the sample payload immediately.
|
|
1931
1996
|
fastStart: false
|
|
1932
1997
|
});
|
|
1998
|
+
let finalized = false;
|
|
1999
|
+
const finalizeMuxer = () => {
|
|
2000
|
+
if (finalized) throw new Error("MP4 muxer already finalized");
|
|
2001
|
+
muxer.finalize();
|
|
2002
|
+
finalized = true;
|
|
2003
|
+
};
|
|
1933
2004
|
return {
|
|
1934
2005
|
hasAudioTrack: options.audio !== void 0,
|
|
1935
2006
|
addVideoChunk(chunk, meta) {
|
|
@@ -1945,8 +2016,12 @@ function createMp4Muxer(options) {
|
|
|
1945
2016
|
muxer.addAudioChunkRaw(data, type, timestampMicros, durationMicros, meta);
|
|
1946
2017
|
},
|
|
1947
2018
|
finalize() {
|
|
1948
|
-
|
|
1949
|
-
return
|
|
2019
|
+
finalizeMuxer();
|
|
2020
|
+
return output.toArrayBuffer();
|
|
2021
|
+
},
|
|
2022
|
+
finalizeBlob() {
|
|
2023
|
+
finalizeMuxer();
|
|
2024
|
+
return output.toBlob();
|
|
1950
2025
|
}
|
|
1951
2026
|
};
|
|
1952
2027
|
}
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
applyWebCodecsBackpressure,
|
|
3
3
|
createMp4Muxer,
|
|
4
4
|
resolveWebCodecsQueueLimit
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-I4SXMCDF.js";
|
|
6
6
|
|
|
7
7
|
// src/mainThreadEncoder.ts
|
|
8
8
|
import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
|
|
@@ -24,6 +24,14 @@ async function supportsWebCodecsH264(config) {
|
|
|
24
24
|
return false;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
var RECLAIMED_CODEC_ERROR = /codec reclaimed due to inactivity/i;
|
|
28
|
+
var MAX_CODEC_RECOVERY_ATTEMPTS = 2;
|
|
29
|
+
function toError(caught) {
|
|
30
|
+
return caught instanceof Error ? caught : new Error(String(caught));
|
|
31
|
+
}
|
|
32
|
+
function isReclaimedCodecError(error) {
|
|
33
|
+
return RECLAIMED_CODEC_ERROR.test(error.message);
|
|
34
|
+
}
|
|
27
35
|
function createEncoder(config) {
|
|
28
36
|
validateVideoExportOptions(config);
|
|
29
37
|
if (!supportsWebCodecs()) {
|
|
@@ -39,60 +47,136 @@ function createEncoder(config) {
|
|
|
39
47
|
});
|
|
40
48
|
let closed = false;
|
|
41
49
|
let fatalError = null;
|
|
50
|
+
let recoverableError = null;
|
|
42
51
|
const frameDuration = 1e6 / config.fps;
|
|
43
52
|
const queueLimit = resolveWebCodecsQueueLimit(config);
|
|
44
53
|
let framesSinceFlush = 0;
|
|
54
|
+
let forceNextKeyFrame = false;
|
|
55
|
+
let encoderGeneration = 0;
|
|
56
|
+
let encoder;
|
|
45
57
|
function fail(err) {
|
|
46
58
|
closed = true;
|
|
47
59
|
if (encoder.state !== "closed") encoder.close();
|
|
48
60
|
return fatalError ?? err;
|
|
49
61
|
}
|
|
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.
|
|
62
|
+
const videoEncoderConfig = {
|
|
63
|
+
// Deliberate profile split from the fallback worker (avc1.42001f,
|
|
64
|
+
// Baseline): this primary WebCodecs path targets H.264 High@4.0 for
|
|
65
|
+
// better quality up to 1080p; the wasm-fallback worker uses Baseline for
|
|
66
|
+
// maximum decoder compatibility.
|
|
63
67
|
codec: "avc1.640028",
|
|
64
|
-
// H.264 High profile, level 4.0 (supports up to 1080p)
|
|
65
68
|
width: config.width,
|
|
66
69
|
height: config.height,
|
|
67
70
|
bitrate: bitrateForQuality(config.quality, config.width, config.height),
|
|
68
71
|
framerate: config.fps
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
};
|
|
73
|
+
function createConfiguredVideoEncoder() {
|
|
74
|
+
const generation = ++encoderGeneration;
|
|
75
|
+
const nextEncoder = new VideoEncoder({
|
|
76
|
+
output(chunk, meta) {
|
|
77
|
+
if (closed || generation !== encoderGeneration) return;
|
|
78
|
+
muxer.addVideoChunk(chunk, meta ?? void 0);
|
|
79
|
+
},
|
|
80
|
+
error(err) {
|
|
81
|
+
if (closed || generation !== encoderGeneration || fatalError || recoverableError) return;
|
|
82
|
+
const wrapped = new Error(`WebCodecs encoder error: ${err.message}`);
|
|
83
|
+
if (isReclaimedCodecError(wrapped)) {
|
|
84
|
+
recoverableError = wrapped;
|
|
85
|
+
} else {
|
|
86
|
+
fatalError = wrapped;
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
});
|
|
90
|
+
nextEncoder.configure(videoEncoderConfig);
|
|
91
|
+
return nextEncoder;
|
|
92
|
+
}
|
|
93
|
+
function recoverCodec() {
|
|
94
|
+
const previousEncoder = encoder;
|
|
95
|
+
recoverableError = null;
|
|
96
|
+
if (previousEncoder.state !== "closed") previousEncoder.close();
|
|
97
|
+
encoder = createConfiguredVideoEncoder();
|
|
98
|
+
framesSinceFlush = 0;
|
|
99
|
+
forceNextKeyFrame = true;
|
|
100
|
+
}
|
|
101
|
+
encoder = createConfiguredVideoEncoder();
|
|
102
|
+
async function finishVideoEncoding() {
|
|
103
|
+
if (closed) throw new Error("Encoder already closed");
|
|
104
|
+
let recoveryAttempts = 0;
|
|
105
|
+
while (true) {
|
|
106
|
+
if (fatalError) throw fail(fatalError);
|
|
107
|
+
if (recoverableError) {
|
|
108
|
+
if (recoveryAttempts >= MAX_CODEC_RECOVERY_ATTEMPTS) {
|
|
109
|
+
throw fail(recoverableError);
|
|
110
|
+
}
|
|
111
|
+
recoverCodec();
|
|
112
|
+
recoveryAttempts++;
|
|
79
113
|
}
|
|
80
114
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
115
|
+
await encoder.flush();
|
|
116
|
+
} catch (caught) {
|
|
117
|
+
const error = toError(caught);
|
|
118
|
+
if (!recoverableError && isReclaimedCodecError(error)) {
|
|
119
|
+
recoverableError = error;
|
|
120
|
+
}
|
|
121
|
+
if (recoverableError && recoveryAttempts < MAX_CODEC_RECOVERY_ATTEMPTS) continue;
|
|
122
|
+
throw fail(fatalError ?? recoverableError ?? error);
|
|
123
|
+
}
|
|
124
|
+
if (fatalError) throw fail(fatalError);
|
|
125
|
+
if (recoverableError) continue;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
encoder.close();
|
|
129
|
+
closed = true;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
async encodeFrame(source, frameIndex) {
|
|
133
|
+
try {
|
|
87
134
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
135
|
+
let recoveryAttempts = 0;
|
|
136
|
+
while (true) {
|
|
137
|
+
if (fatalError) throw fail(fatalError);
|
|
138
|
+
if (closed) throw new Error("Encoder already closed");
|
|
139
|
+
if (recoverableError) {
|
|
140
|
+
if (recoveryAttempts >= MAX_CODEC_RECOVERY_ATTEMPTS) {
|
|
141
|
+
throw fail(recoverableError);
|
|
142
|
+
}
|
|
143
|
+
recoverCodec();
|
|
144
|
+
recoveryAttempts++;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const drained = await applyWebCodecsBackpressure(
|
|
148
|
+
encoder,
|
|
149
|
+
queueLimit,
|
|
150
|
+
framesSinceFlush
|
|
151
|
+
);
|
|
152
|
+
if (drained) framesSinceFlush = 0;
|
|
153
|
+
if (fatalError) throw fail(fatalError);
|
|
154
|
+
if (recoverableError) continue;
|
|
155
|
+
if (closed) throw new Error("Encoder already closed");
|
|
156
|
+
const timestamp = Math.round(frameIndex * frameDuration);
|
|
157
|
+
const frame = new VideoFrame(source, { timestamp });
|
|
158
|
+
try {
|
|
159
|
+
const keyFrame = forceNextKeyFrame || frameIndex % 30 === 0;
|
|
160
|
+
encoder.encode(frame, { keyFrame });
|
|
161
|
+
framesSinceFlush++;
|
|
162
|
+
forceNextKeyFrame = false;
|
|
163
|
+
} finally {
|
|
164
|
+
frame.close();
|
|
165
|
+
}
|
|
166
|
+
if (recoverableError) continue;
|
|
167
|
+
return;
|
|
168
|
+
} catch (caught) {
|
|
169
|
+
const error = toError(caught);
|
|
170
|
+
if (!recoverableError && isReclaimedCodecError(error)) {
|
|
171
|
+
recoverableError = error;
|
|
172
|
+
}
|
|
173
|
+
if (recoverableError && recoveryAttempts < MAX_CODEC_RECOVERY_ATTEMPTS) continue;
|
|
174
|
+
throw fail(fatalError ?? recoverableError ?? error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (caught) {
|
|
178
|
+
throw fail(toError(caught));
|
|
93
179
|
}
|
|
94
|
-
} catch (err) {
|
|
95
|
-
throw fail(err instanceof Error ? err : new Error(String(err)));
|
|
96
180
|
} finally {
|
|
97
181
|
if ("close" in source) source.close();
|
|
98
182
|
}
|
|
@@ -102,18 +186,13 @@ function createEncoder(config) {
|
|
|
102
186
|
muxer.addAudioChunk(chunk, meta);
|
|
103
187
|
},
|
|
104
188
|
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;
|
|
189
|
+
await finishVideoEncoding();
|
|
115
190
|
return muxer.finalize();
|
|
116
191
|
},
|
|
192
|
+
async finalizeBlob() {
|
|
193
|
+
await finishVideoEncoding();
|
|
194
|
+
return muxer.finalizeBlob();
|
|
195
|
+
},
|
|
117
196
|
close() {
|
|
118
197
|
if (closed) return;
|
|
119
198
|
closed = true;
|
|
@@ -186,25 +265,35 @@ async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate
|
|
|
186
265
|
if (requiredFailures.length > 0) {
|
|
187
266
|
throw new Error(`No decodable audio track was found in: ${requiredFailures.join(", ")}`);
|
|
188
267
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
|
|
268
|
+
const scheduledNodes = [];
|
|
269
|
+
try {
|
|
270
|
+
for (const clip of clips) {
|
|
271
|
+
const buffer = decoded.get(clip.src);
|
|
272
|
+
if (!buffer) {
|
|
273
|
+
if (clip.sourceKind === "video") continue;
|
|
274
|
+
throw new Error(`Audio source was not decoded: ${clip.src}`);
|
|
275
|
+
}
|
|
276
|
+
const node = ctx.createBufferSource();
|
|
277
|
+
node.buffer = buffer;
|
|
278
|
+
node.connect(ctx.destination);
|
|
279
|
+
const when = Math.max(0, clip.startSec);
|
|
280
|
+
const offset = Math.max(0, clip.sourceInSec);
|
|
281
|
+
const duration = Math.max(0, clip.durationSec);
|
|
282
|
+
node.start(when, offset, duration);
|
|
283
|
+
scheduledNodes.push(node);
|
|
195
284
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
285
|
+
if (scheduledNodes.length === 0) return null;
|
|
286
|
+
return await ctx.startRendering();
|
|
287
|
+
} finally {
|
|
288
|
+
for (const node of scheduledNodes) {
|
|
289
|
+
node.disconnect();
|
|
290
|
+
node.buffer = null;
|
|
291
|
+
}
|
|
292
|
+
decoded.clear();
|
|
204
293
|
}
|
|
205
|
-
if (scheduledNodes === 0) return null;
|
|
206
|
-
return ctx.startRendering();
|
|
207
294
|
}
|
|
295
|
+
var AAC_FRAME_SAMPLES = 1024;
|
|
296
|
+
var MAX_AAC_QUEUE_SECONDS = 2;
|
|
208
297
|
async function encodeAacTrack(audioBuffer, sink, bitrate) {
|
|
209
298
|
if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
|
|
210
299
|
throw new Error("WebCodecs AudioEncoder is not available.");
|
|
@@ -219,34 +308,47 @@ async function encodeAacTrack(audioBuffer, sink, bitrate) {
|
|
|
219
308
|
}
|
|
220
309
|
});
|
|
221
310
|
encoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: channels, bitrate });
|
|
222
|
-
const FRAME = 1024;
|
|
223
311
|
const total = audioBuffer.length;
|
|
312
|
+
const queueLimit = Math.max(
|
|
313
|
+
1,
|
|
314
|
+
Math.ceil(sampleRate * MAX_AAC_QUEUE_SECONDS / AAC_FRAME_SAMPLES)
|
|
315
|
+
);
|
|
224
316
|
const channelData = [];
|
|
225
317
|
for (let ch = 0; ch < channels; ch++) {
|
|
226
318
|
channelData.push(audioBuffer.getChannelData(ch));
|
|
227
319
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
320
|
+
try {
|
|
321
|
+
for (let offset = 0; offset < total; offset += AAC_FRAME_SAMPLES) {
|
|
322
|
+
if (encodeError) throw encodeError;
|
|
323
|
+
if (encoder.encodeQueueSize >= queueLimit) {
|
|
324
|
+
await encoder.flush();
|
|
325
|
+
if (encodeError) throw encodeError;
|
|
326
|
+
}
|
|
327
|
+
const count = Math.min(AAC_FRAME_SAMPLES, total - offset);
|
|
328
|
+
const planar = new Float32Array(count * channels);
|
|
329
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
330
|
+
planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
|
|
331
|
+
}
|
|
332
|
+
const timestamp = Math.round(offset / sampleRate * 1e6);
|
|
333
|
+
const audioData = new AudioData({
|
|
334
|
+
format: "f32-planar",
|
|
335
|
+
sampleRate,
|
|
336
|
+
numberOfFrames: count,
|
|
337
|
+
numberOfChannels: channels,
|
|
338
|
+
timestamp,
|
|
339
|
+
data: planar
|
|
340
|
+
});
|
|
341
|
+
try {
|
|
342
|
+
encoder.encode(audioData);
|
|
343
|
+
} finally {
|
|
344
|
+
audioData.close();
|
|
345
|
+
}
|
|
234
346
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
numberOfFrames: count,
|
|
240
|
-
numberOfChannels: channels,
|
|
241
|
-
timestamp,
|
|
242
|
-
data: planar
|
|
243
|
-
});
|
|
244
|
-
encoder.encode(audioData);
|
|
245
|
-
audioData.close();
|
|
347
|
+
await encoder.flush();
|
|
348
|
+
if (encodeError) throw encodeError;
|
|
349
|
+
} finally {
|
|
350
|
+
if (encoder.state !== "closed") encoder.close();
|
|
246
351
|
}
|
|
247
|
-
await encoder.flush();
|
|
248
|
-
encoder.close();
|
|
249
|
-
if (encodeError) throw encodeError;
|
|
250
352
|
}
|
|
251
353
|
function audioBufferToWav(buffer) {
|
|
252
354
|
const channels = buffer.numberOfChannels;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useVideoExport
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-F2XUI32B.js";
|
|
4
4
|
|
|
5
5
|
// src/VideoExportModal.tsx
|
|
6
6
|
import { useState, useCallback, useId, useRef } from "react";
|
|
@@ -18,6 +18,9 @@ function formatProcessingFps(framesPerSecond) {
|
|
|
18
18
|
function formatRealtimeMultiplier(processingFps, outputFps) {
|
|
19
19
|
return `${(processingFps / outputFps).toFixed(2)}\xD7 realtime`;
|
|
20
20
|
}
|
|
21
|
+
function resolveVideoSaveActionLabel(format, formatter) {
|
|
22
|
+
return formatter?.(format) ?? `Save ${format.toUpperCase()} to Downloads`;
|
|
23
|
+
}
|
|
21
24
|
var FRAME_PREVIEW_INTERVAL = 15;
|
|
22
25
|
var FRAME_PREVIEW_WIDTH = 480;
|
|
23
26
|
var FRAME_PREVIEW_HEIGHT = 270;
|
|
@@ -160,6 +163,8 @@ function VideoExportModal({
|
|
|
160
163
|
defaultConfig,
|
|
161
164
|
colorScheme = "light",
|
|
162
165
|
uiPalette,
|
|
166
|
+
saveOutput,
|
|
167
|
+
saveActionLabel,
|
|
163
168
|
onClose
|
|
164
169
|
}) {
|
|
165
170
|
const overlayRef = useRef(null);
|
|
@@ -169,7 +174,7 @@ function VideoExportModal({
|
|
|
169
174
|
const initialOutputFormat = defaultConfig?.outputFormat ?? "mp4";
|
|
170
175
|
const [outputFormat, setOutputFormat] = useState(initialOutputFormat);
|
|
171
176
|
const [quality, setQuality] = useState(defaultConfig?.quality ?? "normal");
|
|
172
|
-
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 :
|
|
177
|
+
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 : 30));
|
|
173
178
|
const [orientation, setOrientation] = useState(
|
|
174
179
|
defaultConfig?.orientation ?? "landscape"
|
|
175
180
|
);
|
|
@@ -228,6 +233,7 @@ function VideoExportModal({
|
|
|
228
233
|
processingFps,
|
|
229
234
|
outputFormat: completedOutputFormat,
|
|
230
235
|
downloadUrl,
|
|
236
|
+
outputBlob,
|
|
231
237
|
fileSize,
|
|
232
238
|
audioIncluded,
|
|
233
239
|
audioSkippedReason,
|
|
@@ -245,7 +251,7 @@ function VideoExportModal({
|
|
|
245
251
|
setAnimationsEnabled(false);
|
|
246
252
|
setCaptionMode("standard");
|
|
247
253
|
} else {
|
|
248
|
-
setFps(
|
|
254
|
+
setFps(30);
|
|
249
255
|
setAnimationsEnabled(true);
|
|
250
256
|
setCaptionMode("off");
|
|
251
257
|
}
|
|
@@ -290,16 +296,31 @@ function VideoExportModal({
|
|
|
290
296
|
defaultConfig,
|
|
291
297
|
startExport
|
|
292
298
|
]);
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
299
|
+
const [saving, setSaving] = useState(false);
|
|
300
|
+
const [saveError, setSaveError] = useState(null);
|
|
301
|
+
const handleSave = useCallback(async () => {
|
|
302
|
+
if (!downloadUrl || !outputBlob) return;
|
|
297
303
|
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
304
|
+
const filename = `document-${ts}.${completedOutputFormat}`;
|
|
305
|
+
setSaveError(null);
|
|
306
|
+
if (!saveOutput) {
|
|
307
|
+
const a = document.createElement("a");
|
|
308
|
+
a.href = downloadUrl;
|
|
309
|
+
a.download = filename;
|
|
310
|
+
document.body.appendChild(a);
|
|
311
|
+
a.click();
|
|
312
|
+
document.body.removeChild(a);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
setSaving(true);
|
|
316
|
+
try {
|
|
317
|
+
await saveOutput(outputBlob, filename);
|
|
318
|
+
} catch (caught) {
|
|
319
|
+
setSaveError(caught instanceof Error ? caught.message : "The export could not be saved.");
|
|
320
|
+
} finally {
|
|
321
|
+
setSaving(false);
|
|
322
|
+
}
|
|
323
|
+
}, [completedOutputFormat, downloadUrl, outputBlob, saveOutput]);
|
|
303
324
|
const handleClose = useCallback(() => {
|
|
304
325
|
if (state === "capturing" || state === "encoding" || state === "preparing") {
|
|
305
326
|
cancelExport();
|
|
@@ -591,11 +612,17 @@ function VideoExportModal({
|
|
|
591
612
|
] }),
|
|
592
613
|
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
593
614
|
/* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
|
|
594
|
-
/* @__PURE__ */
|
|
595
|
-
"
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
615
|
+
/* @__PURE__ */ jsx(
|
|
616
|
+
"button",
|
|
617
|
+
{
|
|
618
|
+
style: themedPrimaryButtonStyle,
|
|
619
|
+
onClick: () => void handleSave(),
|
|
620
|
+
disabled: saving,
|
|
621
|
+
children: saving ? "Saving..." : resolveVideoSaveActionLabel(completedOutputFormat, saveActionLabel)
|
|
622
|
+
}
|
|
623
|
+
)
|
|
624
|
+
] }),
|
|
625
|
+
saveError && /* @__PURE__ */ jsx("p", { role: "alert", style: { fontSize: 12, color: palette.danger, margin: "8px 0 0 0" }, children: saveError })
|
|
599
626
|
] }),
|
|
600
627
|
state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
601
628
|
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.danger }, children: "Export failed" }),
|
|
@@ -639,6 +666,8 @@ function VideoExportButton({
|
|
|
639
666
|
defaultConfig,
|
|
640
667
|
colorScheme,
|
|
641
668
|
uiPalette,
|
|
669
|
+
saveOutput,
|
|
670
|
+
saveActionLabel,
|
|
642
671
|
label,
|
|
643
672
|
style,
|
|
644
673
|
disabled
|
|
@@ -661,6 +690,8 @@ function VideoExportButton({
|
|
|
661
690
|
defaultConfig,
|
|
662
691
|
colorScheme,
|
|
663
692
|
uiPalette,
|
|
693
|
+
saveOutput,
|
|
694
|
+
saveActionLabel,
|
|
664
695
|
onClose: handleClose
|
|
665
696
|
}
|
|
666
697
|
),
|
|
@@ -1,10 +1,10 @@
|
|
|
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, e as VideoOutputFormat } from '../useVideoExport-CM5XiM6Z.js';
|
|
4
4
|
import '@bendyline/squisq/markdown';
|
|
5
5
|
import '@bendyline/squisq-video';
|
|
6
6
|
import '@bendyline/squisq-react';
|
|
7
|
-
import '../mainThreadEncoder-
|
|
7
|
+
import '../mainThreadEncoder-CiVsL1Bf.js';
|
|
8
8
|
|
|
9
9
|
interface VideoExportModalProps {
|
|
10
10
|
/** The document to export */
|
|
@@ -33,6 +33,10 @@ interface VideoExportModalProps {
|
|
|
33
33
|
colorScheme?: 'light' | 'dark';
|
|
34
34
|
/** Optional host overrides for dialog surfaces, controls, status, and accent colors. */
|
|
35
35
|
uiPalette?: Partial<VideoExportPalette>;
|
|
36
|
+
/** Optional host save flow. Return false when the user cancels a picker. */
|
|
37
|
+
saveOutput?: (blob: Blob, filename: string) => boolean | void | Promise<boolean | void>;
|
|
38
|
+
/** Host-aware label for the completed export action. */
|
|
39
|
+
saveActionLabel?: (format: VideoOutputFormat) => string;
|
|
36
40
|
/** Called when the modal should close */
|
|
37
41
|
onClose: () => void;
|
|
38
42
|
}
|
|
@@ -52,7 +56,7 @@ interface VideoExportPalette {
|
|
|
52
56
|
success: string;
|
|
53
57
|
danger: string;
|
|
54
58
|
}
|
|
55
|
-
declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
|
|
59
|
+
declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, saveOutput, saveActionLabel, onClose, }: VideoExportModalProps): react_jsx_runtime.JSX.Element;
|
|
56
60
|
|
|
57
61
|
interface VideoExportButtonProps {
|
|
58
62
|
/** The document to export */
|
|
@@ -79,6 +83,10 @@ interface VideoExportButtonProps {
|
|
|
79
83
|
colorScheme?: 'light' | 'dark';
|
|
80
84
|
/** Optional host palette overrides forwarded to the portaled modal. */
|
|
81
85
|
uiPalette?: Partial<VideoExportPalette>;
|
|
86
|
+
/** Optional host save flow forwarded to the portaled modal. */
|
|
87
|
+
saveOutput?: VideoExportModalProps['saveOutput'];
|
|
88
|
+
/** Host-aware completed-export action label forwarded to the modal. */
|
|
89
|
+
saveActionLabel?: VideoExportModalProps['saveActionLabel'];
|
|
82
90
|
/** Button label (defaults to "Export Video", or "Export GIF" for a GIF default config) */
|
|
83
91
|
label?: string;
|
|
84
92
|
/** Additional inline styles for the button */
|
|
@@ -86,6 +94,6 @@ interface VideoExportButtonProps {
|
|
|
86
94
|
/** Whether the button is disabled */
|
|
87
95
|
disabled?: boolean;
|
|
88
96
|
}
|
|
89
|
-
declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
|
|
97
|
+
declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, defaultConfig, colorScheme, uiPalette, saveOutput, saveActionLabel, label, style, disabled, }: VideoExportButtonProps): react_jsx_runtime.JSX.Element;
|
|
90
98
|
|
|
91
99
|
export { VideoExportButton, type VideoExportButtonProps, VideoExportModal, type VideoExportModalProps, type VideoExportPalette };
|
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-KW5BBYKP.js";
|
|
5
|
+
import "../chunk-F2XUI32B.js";
|
|
6
|
+
import "../chunk-KJ5RKG67.js";
|
|
7
|
+
import "../chunk-I4SXMCDF.js";
|
|
8
8
|
export {
|
|
9
9
|
VideoExportButton,
|
|
10
10
|
VideoExportModal
|
package/dist/encoder/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { E as EncoderConfig, a as EncoderFrameSource, M as MainThreadEncoder, c as createEncoder, s as supportsWebCodecs, b as supportsWebCodecsH264 } from '../mainThreadEncoder-
|
|
1
|
+
export { E as EncoderConfig, a as EncoderFrameSource, M as MainThreadEncoder, c as createEncoder, s as supportsWebCodecs, b as supportsWebCodecsH264 } from '../mainThreadEncoder-CiVsL1Bf.js';
|
|
2
2
|
export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
|
|
3
3
|
|
|
4
4
|
/**
|
package/dist/encoder/index.js
CHANGED
package/dist/hooks/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from '../useVideoExport-
|
|
1
|
+
export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from '../useVideoExport-CM5XiM6Z.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';
|
|
5
5
|
import '@bendyline/squisq/markdown';
|
|
6
|
-
import '../mainThreadEncoder-
|
|
6
|
+
import '../mainThreadEncoder-CiVsL1Bf.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* useFrameCapture — Hidden div + html2canvas frame capture.
|