@mcut/media 0.1.0-alpha.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/LICENSE +187 -0
- package/README.md +11 -0
- package/dist/export-core-B8z3duRc.js +605 -0
- package/dist/export-worker.d.ts +1 -0
- package/dist/export-worker.js +87 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +1384 -0
- package/package.json +56 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { ALL_FORMATS, AudioSample, AudioSampleSource, BlobSource, BufferTarget, CanvasSource, Input, MkvOutputFormat, Mp4OutputFormat, Output, QUALITY_HIGH, QUALITY_MEDIUM, UrlSource, VideoSampleSink, WebMOutputFormat, canEncodeAudio, getFirstEncodableAudioCodec, getFirstEncodableVideoCodec } from "mediabunny";
|
|
2
|
+
import { createAssetId, getFrameRequests, getProjectDurationMs, getRenderableElements } from "@mcut/timeline";
|
|
3
|
+
import { renderFrame } from "@mcut/compositor";
|
|
4
|
+
//#region src/media-store.ts
|
|
5
|
+
/**
|
|
6
|
+
* Content-addressed media persistence on OPFS (the OpenCut pattern: media
|
|
7
|
+
* blobs live in the Origin Private File System keyed by content hash;
|
|
8
|
+
* project JSON stores `asset.hash` and re-binds `src` on load). Hash-keyed
|
|
9
|
+
* storage dedupes repeated imports and gives relink a stable identity.
|
|
10
|
+
*
|
|
11
|
+
* Callers fall back to their own storage (e.g. IndexedDB keyed by asset id)
|
|
12
|
+
* when OPFS is unavailable or a file was imported without a hash.
|
|
13
|
+
*/
|
|
14
|
+
const MEDIA_DIR = "mcut-media";
|
|
15
|
+
/** Largest file we hash/persist (WebCrypto digest needs the full buffer). */
|
|
16
|
+
const MAX_HASHABLE_BYTES = 512 * 1024 * 1024;
|
|
17
|
+
function isMediaStoreSupported() {
|
|
18
|
+
return typeof navigator !== "undefined" && typeof navigator.storage?.getDirectory === "function" && typeof crypto !== "undefined" && !!crypto.subtle;
|
|
19
|
+
}
|
|
20
|
+
/** SHA-256 hex of a blob's content, or null when too large to hash. */
|
|
21
|
+
async function hashBlob(blob) {
|
|
22
|
+
if (!isMediaStoreSupported() || blob.size > 536870912) return null;
|
|
23
|
+
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
|
24
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
}
|
|
26
|
+
async function mediaDir(create) {
|
|
27
|
+
if (!isMediaStoreSupported()) return null;
|
|
28
|
+
try {
|
|
29
|
+
return await (await navigator.storage.getDirectory()).getDirectoryHandle(MEDIA_DIR, { create });
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Persist a blob under its hash. No-op when already stored (same content). */
|
|
35
|
+
async function saveMediaBlob(hash, blob) {
|
|
36
|
+
const dir = await mediaDir(true);
|
|
37
|
+
if (!dir) return false;
|
|
38
|
+
try {
|
|
39
|
+
try {
|
|
40
|
+
if ((await (await dir.getFileHandle(hash)).getFile()).size === blob.size) return true;
|
|
41
|
+
} catch {}
|
|
42
|
+
const writable = await (await dir.getFileHandle(hash, { create: true })).createWritable();
|
|
43
|
+
await writable.write(blob);
|
|
44
|
+
await writable.close();
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function loadMediaBlob(hash) {
|
|
51
|
+
const dir = await mediaDir(false);
|
|
52
|
+
if (!dir) return null;
|
|
53
|
+
try {
|
|
54
|
+
return await (await dir.getFileHandle(hash)).getFile();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Delete stored blobs whose hash is not in `keep`. Returns removed count. */
|
|
60
|
+
async function pruneMediaBlobs(keep) {
|
|
61
|
+
const dir = await mediaDir(false);
|
|
62
|
+
if (!dir) return 0;
|
|
63
|
+
let removed = 0;
|
|
64
|
+
try {
|
|
65
|
+
const names = [];
|
|
66
|
+
for await (const [name] of dir) if (!keep.has(name)) names.push(name);
|
|
67
|
+
for (const name of names) try {
|
|
68
|
+
await dir.removeEntry(name);
|
|
69
|
+
removed++;
|
|
70
|
+
} catch {}
|
|
71
|
+
} catch {}
|
|
72
|
+
return removed;
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/probe.ts
|
|
76
|
+
/** Open a Mediabunny input over a Blob/File or a (blob:/http:) URL. */
|
|
77
|
+
function inputFor(src) {
|
|
78
|
+
return new Input({
|
|
79
|
+
formats: ALL_FORMATS,
|
|
80
|
+
source: typeof src === "string" ? new UrlSource(src) : new BlobSource(src)
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function isMatroskaFile(name, mimeType) {
|
|
84
|
+
const lowerName = name.toLowerCase();
|
|
85
|
+
const lowerMime = mimeType?.toLowerCase() ?? "";
|
|
86
|
+
return lowerName.endsWith(".mkv") || lowerName.endsWith(".mk3d") || lowerName.endsWith(".mka") || lowerMime.includes("matroska") || lowerMime === "video/x-matroska";
|
|
87
|
+
}
|
|
88
|
+
function finiteDurationMs(duration) {
|
|
89
|
+
return Number.isFinite(duration) && duration > 0 ? Math.round(duration * 1e3) : null;
|
|
90
|
+
}
|
|
91
|
+
function loadNativeMetadata(tag, src) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const media = document.createElement(tag);
|
|
94
|
+
let settled = false;
|
|
95
|
+
const settle = (metadata) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
media.removeAttribute("src");
|
|
100
|
+
media.load();
|
|
101
|
+
resolve(metadata);
|
|
102
|
+
};
|
|
103
|
+
const timer = setTimeout(() => settle(null), 15e3);
|
|
104
|
+
media.preload = "metadata";
|
|
105
|
+
media.muted = true;
|
|
106
|
+
media.onloadedmetadata = () => {
|
|
107
|
+
const durationMs = finiteDurationMs(media.duration);
|
|
108
|
+
if (durationMs === null) return settle(null);
|
|
109
|
+
const video = media instanceof HTMLVideoElement ? media : null;
|
|
110
|
+
const width = video?.videoWidth ?? 0;
|
|
111
|
+
const height = video?.videoHeight ?? 0;
|
|
112
|
+
const audioTracks = media.audioTracks?.length;
|
|
113
|
+
settle({
|
|
114
|
+
durationMs,
|
|
115
|
+
...width > 0 && height > 0 ? {
|
|
116
|
+
width,
|
|
117
|
+
height
|
|
118
|
+
} : {},
|
|
119
|
+
...audioTracks !== void 0 ? { audioTracks } : {}
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
media.onerror = () => settle(null);
|
|
123
|
+
media.src = src;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Browser-native metadata fallback for files the browser can play but
|
|
128
|
+
* Mediabunny cannot parse, e.g. MP4s with an extra unsupported first stream.
|
|
129
|
+
*/
|
|
130
|
+
async function probeNativeMedia(src) {
|
|
131
|
+
if (typeof document === "undefined") return null;
|
|
132
|
+
const mimeType = typeof src === "string" ? void 0 : src.type || void 0;
|
|
133
|
+
const url = typeof src === "string" ? src : URL.createObjectURL(src);
|
|
134
|
+
try {
|
|
135
|
+
const video = await loadNativeMetadata("video", url);
|
|
136
|
+
if (video?.width && video.height) return {
|
|
137
|
+
durationMs: video.durationMs,
|
|
138
|
+
hasVideo: true,
|
|
139
|
+
hasAudio: video.audioTracks === void 0 ? true : video.audioTracks > 0,
|
|
140
|
+
width: video.width,
|
|
141
|
+
height: video.height,
|
|
142
|
+
...mimeType ? { mimeType } : {}
|
|
143
|
+
};
|
|
144
|
+
const audio = await loadNativeMetadata("audio", url);
|
|
145
|
+
if (audio) return {
|
|
146
|
+
durationMs: audio.durationMs,
|
|
147
|
+
hasVideo: false,
|
|
148
|
+
hasAudio: true,
|
|
149
|
+
...mimeType ? { mimeType } : {}
|
|
150
|
+
};
|
|
151
|
+
return null;
|
|
152
|
+
} finally {
|
|
153
|
+
if (typeof src !== "string") URL.revokeObjectURL(url);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Resolve true once a throwaway `<video>` decodes the file's first frame. */
|
|
157
|
+
function canDecodeNatively(file) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const url = URL.createObjectURL(file);
|
|
160
|
+
const video = document.createElement("video");
|
|
161
|
+
const settle = (result) => {
|
|
162
|
+
video.removeAttribute("src");
|
|
163
|
+
video.load();
|
|
164
|
+
URL.revokeObjectURL(url);
|
|
165
|
+
resolve(result);
|
|
166
|
+
};
|
|
167
|
+
const timer = setTimeout(() => settle(false), 5e3);
|
|
168
|
+
video.preload = "auto";
|
|
169
|
+
video.muted = true;
|
|
170
|
+
video.onloadeddata = () => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
settle(video.videoWidth > 0);
|
|
173
|
+
};
|
|
174
|
+
video.onerror = () => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
settle(false);
|
|
177
|
+
};
|
|
178
|
+
video.src = url;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Whether a video can use native `<video>` preview instead of decoded frames. */
|
|
182
|
+
async function hasNativeVideoPreview(file, mimeType) {
|
|
183
|
+
if (isMatroskaFile(file.name, mimeType || file.type)) return false;
|
|
184
|
+
if (typeof document === "undefined") return true;
|
|
185
|
+
const type = mimeType || file.type;
|
|
186
|
+
if (!type) return true;
|
|
187
|
+
if (document.createElement("video").canPlayType(type) !== "") return true;
|
|
188
|
+
return canDecodeNatively(file);
|
|
189
|
+
}
|
|
190
|
+
/** Read duration, dimensions, and track layout of an audio/video file. */
|
|
191
|
+
async function probeMedia(src) {
|
|
192
|
+
const input = inputFor(src);
|
|
193
|
+
try {
|
|
194
|
+
const [durationSeconds, video, audio, mimeType] = await Promise.all([
|
|
195
|
+
input.computeDuration(),
|
|
196
|
+
input.getPrimaryVideoTrack(),
|
|
197
|
+
input.getPrimaryAudioTrack(),
|
|
198
|
+
input.getMimeType().catch(() => void 0)
|
|
199
|
+
]);
|
|
200
|
+
return {
|
|
201
|
+
durationMs: Math.round(durationSeconds * 1e3),
|
|
202
|
+
hasVideo: video !== null,
|
|
203
|
+
hasAudio: audio !== null,
|
|
204
|
+
...video ? {
|
|
205
|
+
width: video.displayWidth,
|
|
206
|
+
height: video.displayHeight
|
|
207
|
+
} : {},
|
|
208
|
+
...mimeType ? { mimeType } : {}
|
|
209
|
+
};
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const nativeProbe = await probeNativeMedia(src);
|
|
212
|
+
if (nativeProbe) return nativeProbe;
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
input.dispose();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Read intrinsic dimensions of an image URL (browser only). */
|
|
219
|
+
function probeImage(src) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const image = new Image();
|
|
222
|
+
image.onload = () => resolve({
|
|
223
|
+
width: image.naturalWidth,
|
|
224
|
+
height: image.naturalHeight
|
|
225
|
+
});
|
|
226
|
+
image.onerror = () => reject(/* @__PURE__ */ new Error(`failed to load image: ${src}`));
|
|
227
|
+
image.src = src;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Turn a dropped/picked file into a probed {@link AssetRef} ready for the
|
|
232
|
+
* `addAsset` command. Creates an object URL for `src` — callers own its
|
|
233
|
+
* lifetime (revoke when the asset is removed). `hash` (SHA-256) is the
|
|
234
|
+
* asset's stable identity for persistence/relink; very large files skip it.
|
|
235
|
+
*/
|
|
236
|
+
async function createAssetFromFile(file) {
|
|
237
|
+
const src = URL.createObjectURL(file);
|
|
238
|
+
const hash = await hashBlob(file).catch(() => null);
|
|
239
|
+
const base = {
|
|
240
|
+
id: createAssetId(),
|
|
241
|
+
src,
|
|
242
|
+
...hash ? { hash } : {},
|
|
243
|
+
name: file.name,
|
|
244
|
+
mimeType: file.type || void 0
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
if (file.type.startsWith("image/")) {
|
|
248
|
+
const { width, height } = await probeImage(src);
|
|
249
|
+
return {
|
|
250
|
+
...base,
|
|
251
|
+
kind: "image",
|
|
252
|
+
width,
|
|
253
|
+
height
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const probe = await probeMedia(file);
|
|
257
|
+
if (probe.hasVideo) return {
|
|
258
|
+
...base,
|
|
259
|
+
kind: "video",
|
|
260
|
+
durationMs: probe.durationMs,
|
|
261
|
+
width: probe.width,
|
|
262
|
+
height: probe.height,
|
|
263
|
+
nativePreview: await hasNativeVideoPreview(file, probe.mimeType)
|
|
264
|
+
};
|
|
265
|
+
if (probe.hasAudio) return {
|
|
266
|
+
...base,
|
|
267
|
+
kind: "audio",
|
|
268
|
+
durationMs: probe.durationMs
|
|
269
|
+
};
|
|
270
|
+
throw new Error(`"${file.name}" has no playable audio or video tracks`);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
URL.revokeObjectURL(src);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/encoders.ts
|
|
278
|
+
let ready = null;
|
|
279
|
+
/**
|
|
280
|
+
* Register WASM fallback encoders for codecs the runtime cannot encode
|
|
281
|
+
* natively — today that's AAC on Firefox (no AudioEncoder AAC support), via
|
|
282
|
+
* `@mediabunny/aac-encoder` (FFmpeg-based). The import is dynamic so
|
|
283
|
+
* browsers with native AAC never download the WASM. Idempotent; export
|
|
284
|
+
* paths await it before probing codecs.
|
|
285
|
+
*/
|
|
286
|
+
function ensureFallbackAudioEncoders() {
|
|
287
|
+
ready ??= (async () => {
|
|
288
|
+
try {
|
|
289
|
+
if (!await canEncodeAudio("aac")) {
|
|
290
|
+
const { registerAacEncoder } = await import("@mediabunny/aac-encoder");
|
|
291
|
+
registerAacEncoder();
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
})();
|
|
295
|
+
return ready;
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/export-types.ts
|
|
299
|
+
const AUDIO_SAMPLE_RATE = 48e3;
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/container-formats.ts
|
|
302
|
+
const registry = /* @__PURE__ */ new Map();
|
|
303
|
+
function registerContainerFormat(entry) {
|
|
304
|
+
if (registry.has(entry.id)) throw new Error(`container format "${entry.id}" is already registered`);
|
|
305
|
+
registry.set(entry.id, entry);
|
|
306
|
+
}
|
|
307
|
+
function getContainerFormat(id) {
|
|
308
|
+
return registry.get(id);
|
|
309
|
+
}
|
|
310
|
+
/** Every registered container format, in registration order (built-ins first). */
|
|
311
|
+
function listContainerFormats() {
|
|
312
|
+
return [...registry.values()];
|
|
313
|
+
}
|
|
314
|
+
registerContainerFormat({
|
|
315
|
+
id: "mp4",
|
|
316
|
+
label: "MP4",
|
|
317
|
+
extension: "mp4",
|
|
318
|
+
mimeType: "video/mp4",
|
|
319
|
+
createOutputFormat: () => new Mp4OutputFormat()
|
|
320
|
+
});
|
|
321
|
+
registerContainerFormat({
|
|
322
|
+
id: "webm",
|
|
323
|
+
label: "WebM",
|
|
324
|
+
extension: "webm",
|
|
325
|
+
mimeType: "video/webm",
|
|
326
|
+
createOutputFormat: () => new WebMOutputFormat()
|
|
327
|
+
});
|
|
328
|
+
registerContainerFormat({
|
|
329
|
+
id: "mkv",
|
|
330
|
+
label: "MKV",
|
|
331
|
+
extension: "mkv",
|
|
332
|
+
mimeType: "video/x-matroska",
|
|
333
|
+
createOutputFormat: () => new MkvOutputFormat()
|
|
334
|
+
});
|
|
335
|
+
//#endregion
|
|
336
|
+
//#region src/export-core.ts
|
|
337
|
+
/**
|
|
338
|
+
* The decode→composite→encode→mux pipeline. Everything here is worker-safe:
|
|
339
|
+
* no `OfflineAudioContext`, no `AudioBuffer`, no DOM — the audio mix arrives
|
|
340
|
+
* pre-rendered as planar PCM and is encoded via `AudioSampleSource`.
|
|
341
|
+
*/
|
|
342
|
+
function resolveContainerFormat(id = "mp4") {
|
|
343
|
+
const entry = getContainerFormat(id);
|
|
344
|
+
if (!entry) throw new Error(`unknown container format "${id}" (register it with registerContainerFormat)`);
|
|
345
|
+
return entry;
|
|
346
|
+
}
|
|
347
|
+
/** Can this browser encode video (and audio) for the given format? */
|
|
348
|
+
async function getExportSupport(format = "mp4") {
|
|
349
|
+
if (typeof OffscreenCanvas === "undefined" || typeof VideoEncoder === "undefined") return {
|
|
350
|
+
video: false,
|
|
351
|
+
audio: false
|
|
352
|
+
};
|
|
353
|
+
await ensureFallbackAudioEncoders();
|
|
354
|
+
const outputFormat = resolveContainerFormat(format).createOutputFormat();
|
|
355
|
+
const [video, audio] = await Promise.all([getFirstEncodableVideoCodec(outputFormat.getSupportedVideoCodecs(), {
|
|
356
|
+
width: 1920,
|
|
357
|
+
height: 1080
|
|
358
|
+
}), getFirstEncodableAudioCodec(outputFormat.getSupportedAudioCodecs(), {
|
|
359
|
+
numberOfChannels: 2,
|
|
360
|
+
sampleRate: AUDIO_SAMPLE_RATE
|
|
361
|
+
})]);
|
|
362
|
+
return {
|
|
363
|
+
video: video !== null,
|
|
364
|
+
audio: audio !== null
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** ~1s encode chunks: bounded AudioData allocations, steady backpressure. */
|
|
368
|
+
const AUDIO_CHUNK_FRAMES = 48e3;
|
|
369
|
+
/**
|
|
370
|
+
* Slice planar stereo PCM into `[left|right]` chunks for `AudioSample`
|
|
371
|
+
* (f32-planar layout = each channel contiguous within a chunk).
|
|
372
|
+
*/
|
|
373
|
+
function* planarAudioChunks(mixed, chunkFrames = AUDIO_CHUNK_FRAMES) {
|
|
374
|
+
const total = Math.min(mixed.left.length, mixed.right.length);
|
|
375
|
+
for (let start = 0; start < total; start += chunkFrames) {
|
|
376
|
+
const frames = Math.min(chunkFrames, total - start);
|
|
377
|
+
const data = new Float32Array(frames * 2);
|
|
378
|
+
data.set(mixed.left.subarray(start, start + frames), 0);
|
|
379
|
+
data.set(mixed.right.subarray(start, start + frames), frames);
|
|
380
|
+
yield {
|
|
381
|
+
data,
|
|
382
|
+
frames,
|
|
383
|
+
timestamp: start / mixed.sampleRate
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Render a project to a video file, fully client-side and deterministically:
|
|
389
|
+
* exact decoded samples per output frame (no `<video>` seeking), WebCodecs
|
|
390
|
+
* encoding, Mediabunny muxing. Runs in the export worker (or inline as the
|
|
391
|
+
* main-thread fallback).
|
|
392
|
+
*/
|
|
393
|
+
async function runExportPipeline(project, options) {
|
|
394
|
+
const { onProgress, signal, mixedAudio } = options;
|
|
395
|
+
const durationMs = getProjectDurationMs(project);
|
|
396
|
+
if (durationMs <= 0) throw new Error("Cannot export an empty project");
|
|
397
|
+
await ensureFallbackAudioEncoders();
|
|
398
|
+
const fps = project.fps;
|
|
399
|
+
const totalFrames = Math.max(1, Math.round(durationMs / 1e3 * fps));
|
|
400
|
+
const container = resolveContainerFormat(options.format);
|
|
401
|
+
const format = container.createOutputFormat();
|
|
402
|
+
const target = new BufferTarget();
|
|
403
|
+
const output = new Output({
|
|
404
|
+
format,
|
|
405
|
+
target
|
|
406
|
+
});
|
|
407
|
+
const videoCodec = await getFirstEncodableVideoCodec(format.getSupportedVideoCodecs(), {
|
|
408
|
+
width: project.width,
|
|
409
|
+
height: project.height
|
|
410
|
+
});
|
|
411
|
+
if (!videoCodec) throw new Error("This browser cannot encode video (WebCodecs unavailable or no supported codec)");
|
|
412
|
+
const canvas = new OffscreenCanvas(project.width, project.height);
|
|
413
|
+
const ctx = canvas.getContext("2d", { alpha: false });
|
|
414
|
+
if (!ctx) throw new Error("Could not create export canvas context");
|
|
415
|
+
const videoSource = new CanvasSource(canvas, {
|
|
416
|
+
codec: videoCodec,
|
|
417
|
+
bitrate: options.videoBitrate ?? QUALITY_HIGH
|
|
418
|
+
});
|
|
419
|
+
output.addVideoTrack(videoSource, { frameRate: fps });
|
|
420
|
+
let audioSource = null;
|
|
421
|
+
if (mixedAudio) {
|
|
422
|
+
const audioCodec = await getFirstEncodableAudioCodec(format.getSupportedAudioCodecs(), {
|
|
423
|
+
numberOfChannels: 2,
|
|
424
|
+
sampleRate: mixedAudio.sampleRate
|
|
425
|
+
});
|
|
426
|
+
if (audioCodec) {
|
|
427
|
+
audioSource = new AudioSampleSource({
|
|
428
|
+
codec: audioCodec,
|
|
429
|
+
bitrate: QUALITY_MEDIUM
|
|
430
|
+
});
|
|
431
|
+
output.addAudioTrack(audioSource);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const frameSource = new ExportFrameSource(project);
|
|
435
|
+
try {
|
|
436
|
+
await output.start();
|
|
437
|
+
if (audioSource && mixedAudio) {
|
|
438
|
+
for (const chunk of planarAudioChunks(mixedAudio)) {
|
|
439
|
+
signal?.throwIfAborted();
|
|
440
|
+
const sample = new AudioSample({
|
|
441
|
+
data: chunk.data,
|
|
442
|
+
format: "f32-planar",
|
|
443
|
+
numberOfChannels: 2,
|
|
444
|
+
sampleRate: mixedAudio.sampleRate,
|
|
445
|
+
timestamp: chunk.timestamp
|
|
446
|
+
});
|
|
447
|
+
await audioSource.add(sample);
|
|
448
|
+
sample.close();
|
|
449
|
+
}
|
|
450
|
+
audioSource.close();
|
|
451
|
+
}
|
|
452
|
+
for (let frame = 0; frame < totalFrames; frame++) {
|
|
453
|
+
signal?.throwIfAborted();
|
|
454
|
+
const midMs = (frame + .5) * 1e3 / fps;
|
|
455
|
+
await frameSource.prepare(midMs);
|
|
456
|
+
renderFrame(ctx, project, midMs, {
|
|
457
|
+
source: frameSource,
|
|
458
|
+
motionBlurSamples: 16
|
|
459
|
+
});
|
|
460
|
+
await videoSource.add(frame / fps, 1 / fps);
|
|
461
|
+
frameSource.releaseFrameTemporaries();
|
|
462
|
+
onProgress?.({
|
|
463
|
+
phase: "video",
|
|
464
|
+
progress: .1 + .85 * ((frame + 1) / totalFrames)
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
videoSource.close();
|
|
468
|
+
onProgress?.({
|
|
469
|
+
phase: "finalize",
|
|
470
|
+
progress: .97
|
|
471
|
+
});
|
|
472
|
+
await output.finalize();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
await output.cancel().catch(() => {});
|
|
475
|
+
throw error;
|
|
476
|
+
} finally {
|
|
477
|
+
frameSource.dispose();
|
|
478
|
+
}
|
|
479
|
+
if (!target.buffer) throw new Error("Export produced no data");
|
|
480
|
+
return {
|
|
481
|
+
buffer: target.buffer,
|
|
482
|
+
mimeType: format.mimeType,
|
|
483
|
+
extension: container.extension
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const frameKey = (assetId, sourceTimeMs) => `${assetId}@${Math.round(sourceTimeMs * 1e3)}`;
|
|
487
|
+
/**
|
|
488
|
+
* The exact {@link FrameSource} for export: per-element decoded sample
|
|
489
|
+
* iterators (monotonic within an element), advanced frame-by-frame. The
|
|
490
|
+
* async `prepare()` populates a per-frame cache that the synchronous
|
|
491
|
+
* compositor then reads.
|
|
492
|
+
*/
|
|
493
|
+
var ExportFrameSource = class {
|
|
494
|
+
project;
|
|
495
|
+
inputs = /* @__PURE__ */ new Map();
|
|
496
|
+
states = /* @__PURE__ */ new Map();
|
|
497
|
+
images = /* @__PURE__ */ new Map();
|
|
498
|
+
frameCache = /* @__PURE__ */ new Map();
|
|
499
|
+
temporaries = [];
|
|
500
|
+
constructor(project) {
|
|
501
|
+
this.project = project;
|
|
502
|
+
}
|
|
503
|
+
async prepare(timeMs) {
|
|
504
|
+
this.frameCache.clear();
|
|
505
|
+
for (const { track, element } of getRenderableElements(this.project, timeMs)) {
|
|
506
|
+
if (track.hidden) continue;
|
|
507
|
+
if (element.type === "image") {
|
|
508
|
+
const bitmap = await this.ensureImage(element.assetId);
|
|
509
|
+
if (bitmap) this.frameCache.set(frameKey(element.assetId, 0), bitmap);
|
|
510
|
+
} else if (element.type === "video" || element.type === "multicam") for (const request of getFrameRequests(this.project, element, timeMs)) {
|
|
511
|
+
const sample = await this.advance(`${element.id}:${request.assetId}`, request.assetId, request.sourceTimeMs / 1e3);
|
|
512
|
+
if (sample) {
|
|
513
|
+
const image = sample.toCanvasImageSource();
|
|
514
|
+
if (typeof VideoFrame !== "undefined" && image instanceof VideoFrame) this.temporaries.push(image);
|
|
515
|
+
this.frameCache.set(frameKey(request.assetId, request.sourceTimeMs), image);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
getFrame(assetId, sourceTimeMs) {
|
|
521
|
+
return this.frameCache.get(frameKey(assetId, sourceTimeMs)) ?? null;
|
|
522
|
+
}
|
|
523
|
+
/** Close per-frame `VideoFrame`s after the frame has been encoded. */
|
|
524
|
+
releaseFrameTemporaries() {
|
|
525
|
+
for (const frame of this.temporaries) frame.close();
|
|
526
|
+
this.temporaries = [];
|
|
527
|
+
}
|
|
528
|
+
dispose() {
|
|
529
|
+
this.releaseFrameTemporaries();
|
|
530
|
+
for (const state of this.states.values()) {
|
|
531
|
+
state.current?.close();
|
|
532
|
+
state.pending?.close();
|
|
533
|
+
state.iterator.return(void 0);
|
|
534
|
+
}
|
|
535
|
+
this.states.clear();
|
|
536
|
+
for (const { input } of this.inputs.values()) input.dispose();
|
|
537
|
+
this.inputs.clear();
|
|
538
|
+
for (const bitmap of this.images.values()) bitmap?.close();
|
|
539
|
+
this.images.clear();
|
|
540
|
+
}
|
|
541
|
+
async ensureSink(assetId) {
|
|
542
|
+
const existing = this.inputs.get(assetId);
|
|
543
|
+
if (existing) return existing.sink;
|
|
544
|
+
const asset = this.project.assets[assetId];
|
|
545
|
+
if (!asset) return null;
|
|
546
|
+
const input = inputFor(asset.src);
|
|
547
|
+
const track = await input.getPrimaryVideoTrack();
|
|
548
|
+
const sink = track ? new VideoSampleSink(track) : null;
|
|
549
|
+
this.inputs.set(assetId, {
|
|
550
|
+
input,
|
|
551
|
+
sink
|
|
552
|
+
});
|
|
553
|
+
return sink;
|
|
554
|
+
}
|
|
555
|
+
async ensureImage(assetId) {
|
|
556
|
+
if (this.images.has(assetId)) return this.images.get(assetId) ?? null;
|
|
557
|
+
const asset = this.project.assets[assetId];
|
|
558
|
+
if (!asset) return null;
|
|
559
|
+
try {
|
|
560
|
+
const response = await fetch(asset.src);
|
|
561
|
+
const bitmap = await createImageBitmap(await response.blob());
|
|
562
|
+
this.images.set(assetId, bitmap);
|
|
563
|
+
return bitmap;
|
|
564
|
+
} catch {
|
|
565
|
+
this.images.set(assetId, null);
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async advance(stateKey, assetId, targetS) {
|
|
570
|
+
let state = this.states.get(stateKey);
|
|
571
|
+
if (state && targetS < state.lastTargetS) {
|
|
572
|
+
state.current?.close();
|
|
573
|
+
state.pending?.close();
|
|
574
|
+
state.iterator.return(void 0);
|
|
575
|
+
state = void 0;
|
|
576
|
+
this.states.delete(stateKey);
|
|
577
|
+
}
|
|
578
|
+
if (!state) {
|
|
579
|
+
const sink = await this.ensureSink(assetId);
|
|
580
|
+
if (!sink) return null;
|
|
581
|
+
state = {
|
|
582
|
+
iterator: sink.samples(Math.max(0, targetS)),
|
|
583
|
+
current: null,
|
|
584
|
+
pending: null,
|
|
585
|
+
done: false,
|
|
586
|
+
lastTargetS: targetS
|
|
587
|
+
};
|
|
588
|
+
this.states.set(stateKey, state);
|
|
589
|
+
}
|
|
590
|
+
state.lastTargetS = targetS;
|
|
591
|
+
while (!state.done) if (state.pending) if (state.pending.timestamp <= targetS) {
|
|
592
|
+
state.current?.close();
|
|
593
|
+
state.current = state.pending;
|
|
594
|
+
state.pending = null;
|
|
595
|
+
} else break;
|
|
596
|
+
else {
|
|
597
|
+
const result = await state.iterator.next();
|
|
598
|
+
if (result.done) state.done = true;
|
|
599
|
+
else state.pending = result.value;
|
|
600
|
+
}
|
|
601
|
+
return state.current;
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
//#endregion
|
|
605
|
+
export { pruneMediaBlobs as _, listContainerFormats as a, ensureFallbackAudioEncoders as c, probeImage as d, probeMedia as f, loadMediaBlob as g, isMediaStoreSupported as h, getContainerFormat as i, createAssetFromFile as l, hashBlob as m, resolveContainerFormat as n, registerContainerFormat as o, MAX_HASHABLE_BYTES as p, runExportPipeline as r, AUDIO_SAMPLE_RATE as s, getExportSupport as t, inputFor as u, saveMediaBlob as v };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { g as loadMediaBlob, r as runExportPipeline } from "./export-core-B8z3duRc.js";
|
|
2
|
+
//#region src/export-worker.ts
|
|
3
|
+
const scope = globalThis;
|
|
4
|
+
const post = (message, transfer) => transfer ? scope.postMessage(message, transfer) : scope.postMessage(message);
|
|
5
|
+
/**
|
|
6
|
+
* Workers have their own `FontFaceSet` — faces loaded into `document.fonts`
|
|
7
|
+
* on the main thread are invisible here, and canvas text silently falls back
|
|
8
|
+
* without them. Best-effort: a face that fails to load degrades to the
|
|
9
|
+
* fallback font exactly like the main-thread renderer does.
|
|
10
|
+
*/
|
|
11
|
+
async function registerFonts(fonts) {
|
|
12
|
+
const fontSet = scope.fonts;
|
|
13
|
+
if (!fontSet || typeof FontFace === "undefined") return;
|
|
14
|
+
await Promise.allSettled(fonts.map(async (init) => {
|
|
15
|
+
const source = typeof init.source === "string" ? `url(${JSON.stringify(init.source)})` : init.source;
|
|
16
|
+
const face = new FontFace(init.family, source, {
|
|
17
|
+
...init.weight ? { weight: init.weight } : {},
|
|
18
|
+
...init.style ? { style: init.style } : {},
|
|
19
|
+
...init.unicodeRange ? { unicodeRange: init.unicodeRange } : {}
|
|
20
|
+
});
|
|
21
|
+
await face.load();
|
|
22
|
+
fontSet.add(face);
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Re-bind asset srcs for this worker: main-thread blob URLs are fetchable
|
|
27
|
+
* from a worker, but OPFS by content hash is both faster and immune to a
|
|
28
|
+
* revoked URL — prefer it when the asset carries a hash.
|
|
29
|
+
*/
|
|
30
|
+
async function resolveAssets(project) {
|
|
31
|
+
const urls = [];
|
|
32
|
+
const assets = { ...project.assets };
|
|
33
|
+
for (const [id, asset] of Object.entries(assets)) {
|
|
34
|
+
if (!asset.hash) continue;
|
|
35
|
+
const blob = await loadMediaBlob(asset.hash).catch(() => null);
|
|
36
|
+
if (!blob) continue;
|
|
37
|
+
const src = URL.createObjectURL(blob);
|
|
38
|
+
urls.push(src);
|
|
39
|
+
assets[id] = {
|
|
40
|
+
...asset,
|
|
41
|
+
src
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
project: {
|
|
46
|
+
...project,
|
|
47
|
+
assets
|
|
48
|
+
},
|
|
49
|
+
revoke: () => urls.forEach((url) => URL.revokeObjectURL(url))
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
scope.onmessage = async (event) => {
|
|
53
|
+
const message = event.data;
|
|
54
|
+
if (message.type !== "start") return;
|
|
55
|
+
try {
|
|
56
|
+
await registerFonts(message.fonts);
|
|
57
|
+
const { project, revoke } = await resolveAssets(message.project);
|
|
58
|
+
try {
|
|
59
|
+
const result = await runExportPipeline(project, {
|
|
60
|
+
...message.options.format ? { format: message.options.format } : {},
|
|
61
|
+
...message.options.videoBitrate !== void 0 ? { videoBitrate: message.options.videoBitrate } : {},
|
|
62
|
+
mixedAudio: message.mixedAudio,
|
|
63
|
+
onProgress: ({ progress, phase }) => post({
|
|
64
|
+
type: "progress",
|
|
65
|
+
progress,
|
|
66
|
+
phase
|
|
67
|
+
})
|
|
68
|
+
});
|
|
69
|
+
post({
|
|
70
|
+
type: "done",
|
|
71
|
+
buffer: result.buffer,
|
|
72
|
+
mimeType: result.mimeType,
|
|
73
|
+
extension: result.extension
|
|
74
|
+
}, [result.buffer]);
|
|
75
|
+
} finally {
|
|
76
|
+
revoke();
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
post({
|
|
80
|
+
type: "error",
|
|
81
|
+
message: error instanceof Error ? error.message : String(error)
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
post({ type: "ready" });
|
|
86
|
+
//#endregion
|
|
87
|
+
export {};
|