@alpha2-sh/editor 0.0.0 → 0.1.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 +107 -1
- package/dist/browser/bridge.d.ts +44 -0
- package/dist/browser/bridge.js +2176 -0
- package/dist/browser/extensions-worker-entry.js +3 -0
- package/dist/browser/extensions-worker.d.ts +46 -0
- package/dist/browser/extensions-worker.js +149 -0
- package/dist/browser/hash-worker-entry.d.ts +1 -0
- package/dist/browser/hash-worker-entry.js +3 -0
- package/dist/browser/hash-worker.d.ts +24 -0
- package/dist/browser/hash-worker.js +87 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/jobs.d.ts +82 -0
- package/dist/browser/jobs.js +291 -0
- package/dist/browser/resources.d.ts +40 -0
- package/dist/browser/resources.js +83 -0
- package/dist/browser/runtime/assets.d.ts +233 -0
- package/dist/browser/runtime/assets.js +633 -0
- package/dist/browser/runtime/audio.d.ts +60 -0
- package/dist/browser/runtime/audio.js +549 -0
- package/dist/browser/runtime/budget.d.ts +11 -0
- package/dist/browser/runtime/budget.js +26 -0
- package/dist/browser/runtime/caption-fonts.d.ts +34 -0
- package/dist/browser/runtime/caption-fonts.js +69 -0
- package/dist/browser/runtime/color.d.ts +7 -0
- package/dist/browser/runtime/color.js +12 -0
- package/dist/browser/runtime/engine.d.ts +91 -0
- package/dist/browser/runtime/engine.js +352 -0
- package/dist/browser/runtime/export-audio.d.ts +23 -0
- package/dist/browser/runtime/export-audio.js +75 -0
- package/dist/browser/runtime/exporter.d.ts +140 -0
- package/dist/browser/runtime/exporter.js +751 -0
- package/dist/browser/runtime/extension-import.d.ts +196 -0
- package/dist/browser/runtime/extension-import.js +521 -0
- package/dist/browser/runtime/fingerprint.d.ts +57 -0
- package/dist/browser/runtime/fingerprint.js +157 -0
- package/dist/browser/runtime/generators.d.ts +188 -0
- package/dist/browser/runtime/generators.js +173 -0
- package/dist/browser/runtime/locks.d.ts +32 -0
- package/dist/browser/runtime/locks.js +86 -0
- package/dist/browser/runtime/output.d.ts +87 -0
- package/dist/browser/runtime/output.js +230 -0
- package/dist/browser/runtime/preview-utils.d.ts +3 -0
- package/dist/browser/runtime/preview-utils.js +49 -0
- package/dist/browser/runtime/proxies.d.ts +72 -0
- package/dist/browser/runtime/proxies.js +369 -0
- package/dist/browser/runtime/proxy-storage.d.ts +59 -0
- package/dist/browser/runtime/proxy-storage.js +119 -0
- package/dist/browser/runtime/sources.d.ts +82 -0
- package/dist/browser/runtime/sources.js +819 -0
- package/dist/browser/runtime/staging.d.ts +10 -0
- package/dist/browser/runtime/staging.js +32 -0
- package/dist/browser/runtime/still.d.ts +21 -0
- package/dist/browser/runtime/still.js +52 -0
- package/dist/browser/runtime/wasm.d.ts +9 -0
- package/dist/browser/runtime/wasm.js +20 -0
- package/dist/browser/types.d.ts +549 -0
- package/dist/browser/types.js +1 -0
- package/dist/browser/worker-entry.d.ts +1 -0
- package/dist/browser/worker-entry.js +2 -0
- package/dist/browser/worker.d.ts +24 -0
- package/dist/browser/worker.js +239 -0
- package/dist/core/captions.d.ts +34 -0
- package/dist/core/captions.js +108 -0
- package/dist/core/clip-time.d.ts +31 -0
- package/dist/core/clip-time.js +125 -0
- package/dist/core/commands.d.ts +413 -0
- package/dist/core/commands.js +1118 -0
- package/dist/core/compositions.d.ts +234 -0
- package/dist/core/compositions.js +851 -0
- package/dist/core/document.d.ts +245 -0
- package/dist/core/document.js +683 -0
- package/dist/core/keyframes.d.ts +82 -0
- package/dist/core/keyframes.js +153 -0
- package/dist/core/presets.d.ts +64 -0
- package/dist/core/presets.js +241 -0
- package/dist/core/session.d.ts +150 -0
- package/dist/core/session.js +541 -0
- package/dist/core/text.d.ts +17 -0
- package/dist/core/text.js +17 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +39 -0
- package/dist/core/timeline-index.d.ts +44 -0
- package/dist/core/timeline-index.js +59 -0
- package/dist/core/transitions.d.ts +20 -0
- package/dist/core/transitions.js +118 -0
- package/dist/core/validated.d.ts +23 -0
- package/dist/core/validated.js +52 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/native-client/index.d.ts +131 -0
- package/dist/native-client/index.js +354 -0
- package/dist/ports/index.d.ts +146 -0
- package/dist/ports/index.js +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.js +94 -0
- package/package.json +69 -8
- /package/{index.js → dist/browser/extensions-worker-entry.d.ts} +0 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// Deterministic export: the engine renders offscreen at export quality on its own exact rational
|
|
2
|
+
// frame schedule, so no swapchain race can duplicate a frame and a missing source retries in place.
|
|
3
|
+
import { audioEncoderSampleRate, canEncodeVideoTrack, MediabunnyMp4Sink, } from "alpha2/mediabunny";
|
|
4
|
+
import { engineVideoCodec, qualityLevels, resolveAudioBitrate, resolveVideoRateControl, } from "alpha2/rate-control";
|
|
5
|
+
import { videoFrameFromI420, videoFrameRepackedBytes, } from "alpha2/video-frames";
|
|
6
|
+
import * as engineBinding from "alpha2/wasm";
|
|
7
|
+
import { classifyBrowserFailure, ExportFailureError, withSecondaryFailure, } from "alpha2/export-failure";
|
|
8
|
+
import { audibleSources } from "./audio.js";
|
|
9
|
+
import { exportAudioWriter } from "./export-audio.js";
|
|
10
|
+
import { storageSupport } from "./output.js";
|
|
11
|
+
import { estimatedStagingBytes, minimumExportWorkingSet } from "./staging.js";
|
|
12
|
+
import { DEFAULT_IN_FLIGHT_BYTES } from "./budget.js";
|
|
13
|
+
import { secondsToTicks } from "alpha2/project";
|
|
14
|
+
// Upper bounds on frames in each stage. Within them the byte budget decides: a frame is submitted
|
|
15
|
+
// only when its bytes fit.
|
|
16
|
+
const MAX_IN_FLIGHT_READBACKS = 3;
|
|
17
|
+
const MAX_PENDING_VIDEO_WRITES = 3;
|
|
18
|
+
/** The video codecs a browser export may request, with display labels. */
|
|
19
|
+
export const VIDEO_CODECS = [
|
|
20
|
+
{ value: "avc", label: "H.264 (AVC)" },
|
|
21
|
+
{ value: "hevc", label: "H.265 (HEVC)" },
|
|
22
|
+
{ value: "vp9", label: "VP9" },
|
|
23
|
+
{ value: "av1", label: "AV1" },
|
|
24
|
+
];
|
|
25
|
+
/** The engine's quality level names, lowest first. */
|
|
26
|
+
export function qualityLevelNames() {
|
|
27
|
+
return qualityLevels(engineBinding);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the video rate an export at `quality` will encode at, through the engine: the same
|
|
31
|
+
* function the native host calls.
|
|
32
|
+
*/
|
|
33
|
+
export function resolvedVideoRate({ codec, width, height, frameRate, quality, }) {
|
|
34
|
+
return resolveVideoRateControl(engineBinding, {
|
|
35
|
+
codec,
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
frameRate,
|
|
39
|
+
quality,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Maps the browser codec names onto the engine's. */
|
|
43
|
+
export { engineVideoCodec };
|
|
44
|
+
/**
|
|
45
|
+
* Which video codecs this browser encodes for an export at `width`x`height`, `frameRate`, and
|
|
46
|
+
* `videoQuality` (default `high`), asked with the configuration that export would open its encoder
|
|
47
|
+
* with: a codec answered `false` is refused when such an export starts.
|
|
48
|
+
*/
|
|
49
|
+
export async function probeEncoders(width, height, { frameRate = { num: 30, den: 1 }, videoQuality = "high" } = {}) {
|
|
50
|
+
const results = {};
|
|
51
|
+
for (const codec of VIDEO_CODECS)
|
|
52
|
+
results[codec.value] = await canEncodeVideoTrack(codec.value, {
|
|
53
|
+
width,
|
|
54
|
+
height,
|
|
55
|
+
rate: resolvedVideoRate({
|
|
56
|
+
codec: engineVideoCodec(codec.value),
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
frameRate,
|
|
60
|
+
quality: videoQuality,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Renders `range` (seconds) of the loaded project through the engine's export schedule and writes
|
|
67
|
+
* it, with its mixed audio, to the output `openOutput` opens. Resolves to the sink's output, the
|
|
68
|
+
* opened output (not yet finished), and the export report. Any failure discards the output and is
|
|
69
|
+
* rethrown as an `ExportFailureError` with the phase it happened in; a cancellation rethrows the
|
|
70
|
+
* abort reason. `createSink(options)` makes the muxer the frames and audio are written to, a
|
|
71
|
+
* `MediabunnyMp4Sink` unless a test supplies another.
|
|
72
|
+
*/
|
|
73
|
+
export async function exportTimeline({ supplyFrame, renderFrame, sourceTransfer, engine, project, doc, library, audioSupplier, range, options, openOutput, onProgress, signal, createSink = (sinkOptions) => new MediabunnyMp4Sink(sinkOptions), }) {
|
|
74
|
+
const started = performance.now();
|
|
75
|
+
const diagnosticsBefore = rendererDiagnostics(engine);
|
|
76
|
+
const transferBefore = sourceTransfer?.();
|
|
77
|
+
const metrics = browserExportMetrics();
|
|
78
|
+
const settings = doc.settings;
|
|
79
|
+
const rate = exportFrameRate(options.frameRate, settings);
|
|
80
|
+
const fps = rate.num / rate.den;
|
|
81
|
+
const start = range.start;
|
|
82
|
+
const end = Math.max(start + 1 / fps, range.end);
|
|
83
|
+
let frameCount = Math.max(1, Math.round((end - start) * fps));
|
|
84
|
+
const audio = project ? audibleSources(project, library) : [];
|
|
85
|
+
const wantsAudio = options.includeAudio !== false && audio.length > 0 && engine != null;
|
|
86
|
+
const audioQuality = options.audioQuality ?? "high";
|
|
87
|
+
const audioEncoding = wantsAudio
|
|
88
|
+
? await chooseAudioCodec(settings.sampleRate, audioQuality)
|
|
89
|
+
: undefined;
|
|
90
|
+
const audioCodec = audioEncoding?.codec;
|
|
91
|
+
const videoCodec = options.videoCodec ?? "avc";
|
|
92
|
+
const videoQuality = options.videoQuality ?? "high";
|
|
93
|
+
// Resolved by the engine, so `high` costs the same here as on the native path.
|
|
94
|
+
const videoRateControl = resolvedVideoRate({
|
|
95
|
+
codec: engineVideoCodec(videoCodec),
|
|
96
|
+
width: settings.width,
|
|
97
|
+
height: settings.height,
|
|
98
|
+
frameRate: rate,
|
|
99
|
+
quality: videoQuality,
|
|
100
|
+
});
|
|
101
|
+
await requireVideoEncoder(videoCodec, settings, videoRateControl);
|
|
102
|
+
const audioBitrate = audioCodec === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: resolveAudioBitrate(engineBinding, audioCodec, audioQuality);
|
|
105
|
+
// Pipelined steps carry their own phase (`inPhase`); this is only the main loop's.
|
|
106
|
+
const position = { phase: "prepare", frameIndex: undefined };
|
|
107
|
+
const enter = (phase, frameIndex) => {
|
|
108
|
+
position.phase = phase;
|
|
109
|
+
position.frameIndex = frameIndex;
|
|
110
|
+
};
|
|
111
|
+
// Before anything renders: refused here when the destination cannot hold the estimated file.
|
|
112
|
+
const output = await openOutput({
|
|
113
|
+
durationSeconds: end - start,
|
|
114
|
+
videoBitrate: videoRateControl.bitrate,
|
|
115
|
+
audioBitrate: audioBitrate ?? 0,
|
|
116
|
+
});
|
|
117
|
+
const sink = createSink({
|
|
118
|
+
videoCodec,
|
|
119
|
+
audioCodec,
|
|
120
|
+
videoRateControl,
|
|
121
|
+
audioBitrate,
|
|
122
|
+
audioSampleRate: audioEncoding?.sampleRate,
|
|
123
|
+
audioResampler: engineBinding,
|
|
124
|
+
// Snapping to the exact rate is what keeps the written track CFR.
|
|
125
|
+
frameRate: rate,
|
|
126
|
+
destination: output.destination,
|
|
127
|
+
});
|
|
128
|
+
// Audio is written a window ahead of the video so the file comes out interleaved.
|
|
129
|
+
let writeAudioThrough = async () => { };
|
|
130
|
+
// Staged readbacks and queued encoder frames share one byte budget that must fit one of each.
|
|
131
|
+
const budget = options.memory?.inFlightBytes ?? DEFAULT_IN_FLIGHT_BYTES;
|
|
132
|
+
let frameCost = estimatedStagingBytes(settings.width, settings.height);
|
|
133
|
+
const minimumWorkingSet = minimumExportWorkingSet(settings.width, settings.height);
|
|
134
|
+
metrics.memory = {
|
|
135
|
+
inFlightByteBudget: budget,
|
|
136
|
+
minimumWorkingSetBytes: minimumWorkingSet,
|
|
137
|
+
peakInFlightBytes: 0,
|
|
138
|
+
budgetWaits: 0,
|
|
139
|
+
idleBytesReleased: 0,
|
|
140
|
+
scope: "rendered frames this export holds; the browser's decoder, encoder, and driver memory are outside the budget",
|
|
141
|
+
};
|
|
142
|
+
if (minimumWorkingSet > budget) {
|
|
143
|
+
await output.abort(new Error("refused")).catch(() => { });
|
|
144
|
+
throw new ExportFailureError({
|
|
145
|
+
code: "RESOURCE_LIMIT",
|
|
146
|
+
message: `One ${settings.width}x${settings.height} frame needs ${minimumWorkingSet} bytes in flight (its GPU readback and the copy the encoder takes), over the ${budget}-byte budget`,
|
|
147
|
+
origin: "browser",
|
|
148
|
+
operation: "export",
|
|
149
|
+
backend: rendererBackend(engine),
|
|
150
|
+
phase: "prepare",
|
|
151
|
+
resource: "unknown",
|
|
152
|
+
requestedBytes: minimumWorkingSet,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const videoShare = FRAMES_SHARE;
|
|
157
|
+
engine.beginExport(BigInt(secondsToTicks(start)), BigInt(secondsToTicks(end)), rate.num, rate.den, engineVideoCodec(videoCodec));
|
|
158
|
+
// The engine's schedule, not the request's, decides the frames and the audio start.
|
|
159
|
+
frameCount = JSON.parse(engine.exportProgress()).total;
|
|
160
|
+
if (wantsAudio)
|
|
161
|
+
writeAudioThrough = exportAudioWriter({
|
|
162
|
+
engine,
|
|
163
|
+
audioSupplier,
|
|
164
|
+
sink,
|
|
165
|
+
sampleRate: settings.sampleRate,
|
|
166
|
+
signal,
|
|
167
|
+
metrics,
|
|
168
|
+
enter,
|
|
169
|
+
});
|
|
170
|
+
const pendingReadbacks = [];
|
|
171
|
+
const pendingWrites = [];
|
|
172
|
+
let writeTail = Promise.resolve();
|
|
173
|
+
let pendingReadbackBytes = 0;
|
|
174
|
+
let pendingEncodeBytes = 0;
|
|
175
|
+
let produced = 0;
|
|
176
|
+
try {
|
|
177
|
+
let waitingAt = null;
|
|
178
|
+
let waits = 0;
|
|
179
|
+
let exportComplete = false;
|
|
180
|
+
// The first scheduled frame starts at or before the range start, so it is the time origin;
|
|
181
|
+
// the raw range start would make the first sample's timestamp negative.
|
|
182
|
+
let originTicks = null;
|
|
183
|
+
// Idle renderer memory is released once per pressure episode, which ends when all drains.
|
|
184
|
+
let pressured = false;
|
|
185
|
+
const inFlight = () => pendingReadbackBytes + pendingEncodeBytes;
|
|
186
|
+
while (!exportComplete || pendingReadbacks.length > 0) {
|
|
187
|
+
while (!exportComplete &&
|
|
188
|
+
pendingReadbacks.length < MAX_IN_FLIGHT_READBACKS) {
|
|
189
|
+
if (inFlight() + frameCost > budget) {
|
|
190
|
+
metrics.memory.budgetWaits += 1;
|
|
191
|
+
if (!pressured && typeof engine.releaseIdleMemory === "function")
|
|
192
|
+
metrics.memory.idleBytesReleased += engine.releaseIdleMemory();
|
|
193
|
+
pressured = true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (inFlight() === 0)
|
|
197
|
+
pressured = false;
|
|
198
|
+
throwIfAborted(signal);
|
|
199
|
+
enter("render", produced + pendingReadbacks.length);
|
|
200
|
+
const submitStarted = performance.now();
|
|
201
|
+
const ticket = renderFrame();
|
|
202
|
+
metrics.frameSubmitMs += performance.now() - submitStarted;
|
|
203
|
+
if (ticket.kind === "complete") {
|
|
204
|
+
exportComplete = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (ticket.kind === "waiting") {
|
|
208
|
+
// The cursor has not advanced, so uploading and retrying is exact.
|
|
209
|
+
waits = ticket.timeTicks === waitingAt ? waits + 1 : 0;
|
|
210
|
+
waitingAt = ticket.timeTicks;
|
|
211
|
+
if (waits >= 4) {
|
|
212
|
+
// Like the SDK's export runner: a source not uploaded will not upload on retry.
|
|
213
|
+
throw new ExportFailureError({
|
|
214
|
+
code: "RESOURCE_LIMIT",
|
|
215
|
+
message: `export stalled at ${(Number(ticket.timeTicks) / 120_000).toFixed(3)}s waiting for ${ticket.missingSources.join(", ")} after ${waits} refills: the supply does not upload them`,
|
|
216
|
+
origin: "browser",
|
|
217
|
+
operation: "export",
|
|
218
|
+
backend: rendererBackend(engine),
|
|
219
|
+
phase: "upload",
|
|
220
|
+
stage: "sources-not-held",
|
|
221
|
+
frameIndex: produced + pendingReadbacks.length,
|
|
222
|
+
timeTicks: String(ticket.timeTicks),
|
|
223
|
+
resource: "unknown",
|
|
224
|
+
diagnostics: {
|
|
225
|
+
missingSources: [...ticket.missingSources],
|
|
226
|
+
refills: waits,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const supplyStarted = performance.now();
|
|
231
|
+
enter("decode", produced + pendingReadbacks.length);
|
|
232
|
+
await supplyFrame(Number(ticket.timeTicks) / 120_000, ticket.missingSources);
|
|
233
|
+
metrics.sourceConvertUploadMs += performance.now() - supplyStarted;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
waitingAt = null;
|
|
237
|
+
metrics.stagingBytesPerFrame = ticket.retainedBytes;
|
|
238
|
+
frameCost = ticket.retainedBytes;
|
|
239
|
+
pendingReadbackBytes += ticket.retainedBytes;
|
|
240
|
+
metrics.memory.peakInFlightBytes = Math.max(metrics.memory.peakInFlightBytes, inFlight());
|
|
241
|
+
metrics.peakReadbackBytes = Math.max(metrics.peakReadbackBytes, pendingReadbackBytes);
|
|
242
|
+
const mappingStarted = performance.now();
|
|
243
|
+
const readback = ticket.readback.finally(() => {
|
|
244
|
+
metrics.readbackMapLatencyMs += performance.now() - mappingStarted;
|
|
245
|
+
});
|
|
246
|
+
void readback.catch(() => { });
|
|
247
|
+
pendingReadbacks.push({
|
|
248
|
+
readback,
|
|
249
|
+
retainedBytes: ticket.retainedBytes,
|
|
250
|
+
});
|
|
251
|
+
metrics.maxInFlightReadbacks = Math.max(metrics.maxInFlightReadbacks, pendingReadbacks.length);
|
|
252
|
+
if (pendingWrites.length > 0)
|
|
253
|
+
metrics.pipelineOverlapFrames += 1;
|
|
254
|
+
}
|
|
255
|
+
if (pendingReadbacks.length === 0) {
|
|
256
|
+
if (exportComplete)
|
|
257
|
+
continue;
|
|
258
|
+
// Only encoder frames hold the budget: wait for the oldest to be encoded.
|
|
259
|
+
const oldest = pendingWrites.shift();
|
|
260
|
+
if (oldest === undefined)
|
|
261
|
+
throw new ExportFailureError({
|
|
262
|
+
code: "RESOURCE_LIMIT",
|
|
263
|
+
message: `One frame (${frameCost} bytes) does not fit the ${budget}-byte in-flight budget`,
|
|
264
|
+
origin: "browser",
|
|
265
|
+
operation: "export",
|
|
266
|
+
backend: rendererBackend(engine),
|
|
267
|
+
phase: "render",
|
|
268
|
+
frameIndex: produced,
|
|
269
|
+
resource: "unknown",
|
|
270
|
+
requestedBytes: frameCost,
|
|
271
|
+
});
|
|
272
|
+
await oldest.write;
|
|
273
|
+
pendingEncodeBytes -= oldest.bytes;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const pendingDepth = pendingReadbacks.length + pendingWrites.length;
|
|
277
|
+
metrics.maxPendingMediaEvents = Math.max(metrics.maxPendingMediaEvents, pendingDepth);
|
|
278
|
+
metrics.pendingDepthTotal += pendingDepth;
|
|
279
|
+
metrics.pendingDepthSamples += 1;
|
|
280
|
+
const pendingReadback = pendingReadbacks.shift();
|
|
281
|
+
const readbackWaitStarted = performance.now();
|
|
282
|
+
enter("readback", produced);
|
|
283
|
+
const step = await pendingReadback.readback;
|
|
284
|
+
metrics.readbackWaitMs += performance.now() - readbackWaitStarted;
|
|
285
|
+
pendingReadbackBytes -= pendingReadback.retainedBytes;
|
|
286
|
+
metrics.outputReadbackBytes += pendingReadback.retainedBytes;
|
|
287
|
+
const ticks = Number(step.timeTicks);
|
|
288
|
+
if (originTicks === null)
|
|
289
|
+
originTicks = ticks;
|
|
290
|
+
const packedBytes = step.pixels.byteLength;
|
|
291
|
+
enter("encode", produced);
|
|
292
|
+
const frame = await i420VideoFrame(step, Math.round(((ticks - originTicks) / 120_000) * 1_000_000), Math.round((1_000_000 * rate.den) / rate.num));
|
|
293
|
+
const output = {
|
|
294
|
+
index: BigInt(step.index),
|
|
295
|
+
timeTicks: BigInt(ticks - originTicks),
|
|
296
|
+
frame,
|
|
297
|
+
};
|
|
298
|
+
metrics.packedBytesPerFrame = packedBytes;
|
|
299
|
+
metrics.outputRepackBytes += videoFrameRepackedBytes(frame);
|
|
300
|
+
const encodeQueued = performance.now();
|
|
301
|
+
if (pendingWrites.length > 0)
|
|
302
|
+
metrics.encodeBackpressureCount += 1;
|
|
303
|
+
const write = writeTail.then(async () => {
|
|
304
|
+
metrics.encodeQueueWaitMs += performance.now() - encodeQueued;
|
|
305
|
+
const encodeStarted = performance.now();
|
|
306
|
+
try {
|
|
307
|
+
await inPhase("encode", Number(output.index), () => sink.writeVideo(output));
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
metrics.videoEncodeMs += performance.now() - encodeStarted;
|
|
311
|
+
}
|
|
312
|
+
}, (error) => {
|
|
313
|
+
frame.close();
|
|
314
|
+
throw error;
|
|
315
|
+
});
|
|
316
|
+
writeTail = write;
|
|
317
|
+
pendingEncodeBytes += packedBytes;
|
|
318
|
+
metrics.memory.peakInFlightBytes = Math.max(metrics.memory.peakInFlightBytes, inFlight());
|
|
319
|
+
metrics.peakEncodeQueueBytes = Math.max(metrics.peakEncodeQueueBytes, pendingEncodeBytes);
|
|
320
|
+
pendingWrites.push({ write, bytes: packedBytes });
|
|
321
|
+
metrics.maxEncodeQueueEvents = Math.max(metrics.maxEncodeQueueEvents, pendingWrites.length);
|
|
322
|
+
void write.catch(() => { });
|
|
323
|
+
while (pendingWrites.length >= MAX_PENDING_VIDEO_WRITES ||
|
|
324
|
+
(pendingWrites.length > 1 && inFlight() > budget)) {
|
|
325
|
+
const pendingWrite = pendingWrites.shift();
|
|
326
|
+
await pendingWrite.write;
|
|
327
|
+
pendingEncodeBytes -= pendingWrite.bytes;
|
|
328
|
+
}
|
|
329
|
+
produced += 1;
|
|
330
|
+
await writeAudioThrough(produced / fps + AUDIO_LEAD_SECONDS);
|
|
331
|
+
onProgress?.({
|
|
332
|
+
phase: "video",
|
|
333
|
+
ratio: (produced / frameCount) * videoShare,
|
|
334
|
+
frame: produced,
|
|
335
|
+
frameCount,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
await writeTail;
|
|
339
|
+
pendingWrites.length = 0;
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await Promise.allSettled(pendingReadbacks.map(({ readback }) => readback));
|
|
343
|
+
await Promise.allSettled(pendingWrites.map(({ write }) => write));
|
|
344
|
+
engine.endExport();
|
|
345
|
+
}
|
|
346
|
+
await writeAudioThrough(Infinity);
|
|
347
|
+
enter("mux", undefined);
|
|
348
|
+
onProgress?.({ phase: "finalizing", ratio: FRAMES_SHARE });
|
|
349
|
+
const finishStarted = performance.now();
|
|
350
|
+
await sink.finish();
|
|
351
|
+
metrics.muxFinishMs = performance.now() - finishStarted;
|
|
352
|
+
const written = sink.output;
|
|
353
|
+
if (written === null || sink.bytesWritten === 0)
|
|
354
|
+
throw new Error("the encoder returned an empty result");
|
|
355
|
+
const destinationStats = output.stats();
|
|
356
|
+
metrics.output = {
|
|
357
|
+
mode: output.mode,
|
|
358
|
+
bytes: sink.bytesWritten,
|
|
359
|
+
estimatedBytes: output.estimatedBytes,
|
|
360
|
+
peakQueuedBytes: destinationStats?.peakQueuedBytes ?? null,
|
|
361
|
+
maxQueuedBytes: destinationStats?.maxQueuedBytes ?? null,
|
|
362
|
+
storage: storageSupport(),
|
|
363
|
+
// A report, not a check (`openExportOutput`).
|
|
364
|
+
storageEstimate: output.storageEstimate ?? null,
|
|
365
|
+
};
|
|
366
|
+
const renderMs = performance.now() - started;
|
|
367
|
+
const transferAfter = sourceTransfer?.();
|
|
368
|
+
if (transferBefore && transferAfter) {
|
|
369
|
+
metrics.sourcePlanarCopyFrames =
|
|
370
|
+
transferAfter.planarCopyFrames - transferBefore.planarCopyFrames;
|
|
371
|
+
metrics.sourcePlanarCopyBytes =
|
|
372
|
+
transferAfter.planarCopyBytes - transferBefore.planarCopyBytes;
|
|
373
|
+
}
|
|
374
|
+
const result = {
|
|
375
|
+
renderMs,
|
|
376
|
+
frames: produced,
|
|
377
|
+
audio: wantsAudio,
|
|
378
|
+
videoCodec,
|
|
379
|
+
// Named like the native host's result for native-versus-Wasm comparison.
|
|
380
|
+
videoRateControl: {
|
|
381
|
+
bitrate: videoRateControl.bitrate,
|
|
382
|
+
mode: videoRateControl.bitrateMode,
|
|
383
|
+
quantizer: videoRateControl.quantizer ?? null,
|
|
384
|
+
level: typeof videoQuality === "string" ? videoQuality : null,
|
|
385
|
+
source: "request",
|
|
386
|
+
},
|
|
387
|
+
// `quantizer` when the encoder accepted constant-quantizer encoding, else the bitrate path.
|
|
388
|
+
appliedVideoRateControl: sink.appliedVideoRateControl ?? null,
|
|
389
|
+
audioCodec,
|
|
390
|
+
audioRateControl: audioBitrate === undefined
|
|
391
|
+
? null
|
|
392
|
+
: {
|
|
393
|
+
bitrate: audioBitrate,
|
|
394
|
+
level: typeof audioQuality === "string" ? audioQuality : null,
|
|
395
|
+
},
|
|
396
|
+
fps: produced / Math.max(renderMs / 1_000, Number.EPSILON),
|
|
397
|
+
// A retry renders from the first frame: nothing of a failed attempt is kept.
|
|
398
|
+
resume: {
|
|
399
|
+
status: "unavailable",
|
|
400
|
+
reason: "browser exports keep no frames between attempts; a retry renders from the first frame",
|
|
401
|
+
},
|
|
402
|
+
performance: finishBrowserExportMetrics(metrics, diagnosticsBefore, rendererDiagnostics(engine)),
|
|
403
|
+
};
|
|
404
|
+
// One JSON object, so DevTools users can copy it without expanding a live console object.
|
|
405
|
+
console.log(JSON.stringify(result));
|
|
406
|
+
throwIfAborted(signal);
|
|
407
|
+
return { written, output, result };
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
// Discard first, so a muxer that closes its stream on cancel cannot commit a partial file.
|
|
411
|
+
const discarded = output.abort(error).then(() => null, (abortError) => abortError);
|
|
412
|
+
if (signal?.aborted) {
|
|
413
|
+
await discarded;
|
|
414
|
+
await sink.abort(error instanceof Error ? error : new Error(String(error)));
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
const tagged = phaseOfError(error);
|
|
418
|
+
let failure = classifyBrowserFailure(error, {
|
|
419
|
+
phase: tagged?.phase ?? position.phase,
|
|
420
|
+
frameIndex: tagged ? tagged.frameIndex : position.frameIndex,
|
|
421
|
+
backend: rendererBackend(engine),
|
|
422
|
+
diagnostics: {
|
|
423
|
+
request: {
|
|
424
|
+
width: settings.width,
|
|
425
|
+
height: settings.height,
|
|
426
|
+
frameRate: rate,
|
|
427
|
+
startSeconds: start,
|
|
428
|
+
endSeconds: end,
|
|
429
|
+
frames: frameCount,
|
|
430
|
+
videoCodec,
|
|
431
|
+
audioCodec: audioCodec ?? null,
|
|
432
|
+
audioSampleRate: audioEncoding?.sampleRate ?? null,
|
|
433
|
+
sources: (project?.sources ?? []).map((source) => source.id),
|
|
434
|
+
},
|
|
435
|
+
output: {
|
|
436
|
+
mode: output.mode,
|
|
437
|
+
estimatedBytes: output.estimatedBytes,
|
|
438
|
+
storageEstimate: output.storageEstimate ?? null,
|
|
439
|
+
},
|
|
440
|
+
memory: {
|
|
441
|
+
scope: "bytes this export holds in flight; browser, decoder, and encoder memory not included",
|
|
442
|
+
peakReadbackBytes: metrics.peakReadbackBytes,
|
|
443
|
+
peakEncodeQueueBytes: metrics.peakEncodeQueueBytes,
|
|
444
|
+
stagingBytesPerFrame: metrics.stagingBytesPerFrame,
|
|
445
|
+
maxInFlightReadbacks: metrics.maxInFlightReadbacks,
|
|
446
|
+
jsHeap: jsHeap(),
|
|
447
|
+
},
|
|
448
|
+
...deviceDiagnostics(engine),
|
|
449
|
+
environment: browserEnvironment(),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
const discardError = await discarded;
|
|
453
|
+
if (discardError !== null)
|
|
454
|
+
failure = withSecondaryFailure(failure, {
|
|
455
|
+
code: "UNKNOWN",
|
|
456
|
+
phase: "cleanup",
|
|
457
|
+
message: String(discardError?.message ?? discardError),
|
|
458
|
+
});
|
|
459
|
+
try {
|
|
460
|
+
await sink.abort(error instanceof Error ? error : new Error(String(error)));
|
|
461
|
+
}
|
|
462
|
+
catch (abortError) {
|
|
463
|
+
failure = withSecondaryFailure(failure, {
|
|
464
|
+
code: "UNKNOWN",
|
|
465
|
+
phase: "cleanup",
|
|
466
|
+
message: String(abortError?.message ?? abortError),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
throw new ExportFailureError(failure, { cause: error });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** How far the audio is written ahead of the video. */
|
|
473
|
+
const AUDIO_LEAD_SECONDS = 0.25;
|
|
474
|
+
/** Share of the job the frames stand for; finalizing the file is the rest. */
|
|
475
|
+
const FRAMES_SHARE = 0.97;
|
|
476
|
+
const errorPhases = new WeakMap();
|
|
477
|
+
/**
|
|
478
|
+
* Runs `step`, marking an error it throws with the phase and frame it came from, unless an earlier
|
|
479
|
+
* step already marked it.
|
|
480
|
+
*/
|
|
481
|
+
async function inPhase(phase, frameIndex, step) {
|
|
482
|
+
try {
|
|
483
|
+
return await step();
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (typeof error === "object" && error !== null && !errorPhases.has(error))
|
|
487
|
+
errorPhases.set(error, { phase, frameIndex });
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
function phaseOfError(error) {
|
|
492
|
+
return typeof error === "object" && error !== null
|
|
493
|
+
? errorPhases.get(error)
|
|
494
|
+
: undefined;
|
|
495
|
+
}
|
|
496
|
+
/** The device's error log and the renderer's pooled GPU bytes; not its pass timings. */
|
|
497
|
+
function deviceDiagnostics(engine) {
|
|
498
|
+
const diagnostics = rendererDiagnostics(engine);
|
|
499
|
+
if (!diagnostics)
|
|
500
|
+
return {};
|
|
501
|
+
const pool = (stats) => stats && {
|
|
502
|
+
activeBytes: stats.activeBytes ?? stats.active_bytes ?? null,
|
|
503
|
+
highWaterBytes: stats.highWaterBytes ?? stats.high_water_bytes ?? null,
|
|
504
|
+
};
|
|
505
|
+
return {
|
|
506
|
+
gpu: diagnostics.gpu ?? null,
|
|
507
|
+
adapter: diagnostics.adapter ?? null,
|
|
508
|
+
gpuMemory: {
|
|
509
|
+
scope: "renderer-pooled allocations; browser and driver memory not included",
|
|
510
|
+
textures: pool(diagnostics.textures),
|
|
511
|
+
uploadTextures: pool(diagnostics.uploadTextures),
|
|
512
|
+
readbackBuffers: pool(diagnostics.readbackBuffers),
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function rendererBackend(engine) {
|
|
517
|
+
const backend = rendererDiagnostics(engine)?.adapter?.backend;
|
|
518
|
+
if (backend === "BrowserWebGpu")
|
|
519
|
+
return "browser-webgpu";
|
|
520
|
+
if (typeof backend === "string")
|
|
521
|
+
return "browser-webgl";
|
|
522
|
+
return "unknown";
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Chrome's JS heap figures, where it reports them; they do not include ArrayBuffers, wasm, or GPU
|
|
526
|
+
* memory.
|
|
527
|
+
*/
|
|
528
|
+
function jsHeap() {
|
|
529
|
+
const memory = globalThis.performance?.memory;
|
|
530
|
+
return memory
|
|
531
|
+
? {
|
|
532
|
+
usedBytes: memory.usedJSHeapSize,
|
|
533
|
+
totalBytes: memory.totalJSHeapSize,
|
|
534
|
+
limitBytes: memory.jsHeapSizeLimit,
|
|
535
|
+
}
|
|
536
|
+
: null;
|
|
537
|
+
}
|
|
538
|
+
function browserEnvironment() {
|
|
539
|
+
const navigator = globalThis.navigator;
|
|
540
|
+
return {
|
|
541
|
+
userAgent: navigator?.userAgent ?? null,
|
|
542
|
+
deviceMemoryGiB: navigator?.deviceMemory ?? null,
|
|
543
|
+
hardwareConcurrency: navigator?.hardwareConcurrency ?? null,
|
|
544
|
+
webgpu: typeof navigator?.gpu === "object" && navigator.gpu !== null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function browserExportMetrics() {
|
|
548
|
+
return {
|
|
549
|
+
pipelineEnabled: true,
|
|
550
|
+
sourceConvertUploadMs: 0,
|
|
551
|
+
frameSubmitMs: 0,
|
|
552
|
+
readbackMapLatencyMs: 0,
|
|
553
|
+
readbackWaitMs: 0,
|
|
554
|
+
encodeQueueWaitMs: 0,
|
|
555
|
+
videoEncodeMs: 0,
|
|
556
|
+
audioDecodeMs: 0,
|
|
557
|
+
audioMixMs: 0,
|
|
558
|
+
audioEncodeMs: 0,
|
|
559
|
+
muxFinishMs: 0,
|
|
560
|
+
stagingBytesPerFrame: 0,
|
|
561
|
+
packedBytesPerFrame: 0,
|
|
562
|
+
peakReadbackBytes: 0,
|
|
563
|
+
outputReadbackBytes: 0,
|
|
564
|
+
outputRepackBytes: 0,
|
|
565
|
+
sourcePlanarCopyFrames: 0,
|
|
566
|
+
sourcePlanarCopyBytes: 0,
|
|
567
|
+
peakEncodeQueueBytes: 0,
|
|
568
|
+
maxPendingMediaEvents: 0,
|
|
569
|
+
pendingDepthTotal: 0,
|
|
570
|
+
pendingDepthSamples: 0,
|
|
571
|
+
maxInFlightReadbacks: 0,
|
|
572
|
+
maxEncodeQueueEvents: 0,
|
|
573
|
+
encodeBackpressureCount: 0,
|
|
574
|
+
pipelineOverlapFrames: 0,
|
|
575
|
+
audioBlocksInterleaved: 0,
|
|
576
|
+
output: null,
|
|
577
|
+
memory: null,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function finishBrowserExportMetrics(metrics, before, after) {
|
|
581
|
+
const result = {
|
|
582
|
+
pipelineEnabled: metrics.pipelineEnabled,
|
|
583
|
+
sourceConvertUploadMs: metrics.sourceConvertUploadMs,
|
|
584
|
+
frameSubmitMs: metrics.frameSubmitMs,
|
|
585
|
+
readbackMapLatencyMs: metrics.readbackMapLatencyMs,
|
|
586
|
+
readbackWaitMs: metrics.readbackWaitMs,
|
|
587
|
+
readbackBlockingMs: metrics.readbackWaitMs,
|
|
588
|
+
encodeQueueWaitMs: metrics.encodeQueueWaitMs,
|
|
589
|
+
videoEncodeMs: metrics.videoEncodeMs,
|
|
590
|
+
audioDecodeMs: metrics.audioDecodeMs,
|
|
591
|
+
audioMixMs: metrics.audioMixMs,
|
|
592
|
+
audioEncodeMs: metrics.audioEncodeMs,
|
|
593
|
+
muxFinishMs: metrics.muxFinishMs,
|
|
594
|
+
inFlightDepth: MAX_IN_FLIGHT_READBACKS,
|
|
595
|
+
encodeChannelDepth: MAX_PENDING_VIDEO_WRITES,
|
|
596
|
+
stagingBytesPerFrame: metrics.stagingBytesPerFrame,
|
|
597
|
+
packedBytesPerFrame: metrics.packedBytesPerFrame,
|
|
598
|
+
peakReadbackBytes: metrics.peakReadbackBytes,
|
|
599
|
+
// Mapped output readbacks plus HDR source frames copied out planar for the tone map.
|
|
600
|
+
outputReadbackBytes: metrics.outputReadbackBytes,
|
|
601
|
+
// Copies made to pack planes tight where VideoFrame ignores `layout`; 0 elsewhere.
|
|
602
|
+
outputRepackBytes: metrics.outputRepackBytes,
|
|
603
|
+
sourcePlanarCopyFrames: metrics.sourcePlanarCopyFrames,
|
|
604
|
+
sourcePlanarCopyBytes: metrics.sourcePlanarCopyBytes,
|
|
605
|
+
peakEncodeQueueBytes: metrics.peakEncodeQueueBytes,
|
|
606
|
+
maxPendingMediaEvents: metrics.maxPendingMediaEvents,
|
|
607
|
+
averagePendingMediaEvents: metrics.pendingDepthSamples === 0
|
|
608
|
+
? 0
|
|
609
|
+
: metrics.pendingDepthTotal / metrics.pendingDepthSamples,
|
|
610
|
+
maxInFlightReadbacks: metrics.maxInFlightReadbacks,
|
|
611
|
+
maxEncodeQueueEvents: metrics.maxEncodeQueueEvents,
|
|
612
|
+
encodeBackpressureCount: metrics.encodeBackpressureCount,
|
|
613
|
+
pipelineOverlapFrames: metrics.pipelineOverlapFrames,
|
|
614
|
+
audioBlocksInterleaved: metrics.audioBlocksInterleaved,
|
|
615
|
+
memory: metrics.memory,
|
|
616
|
+
output: metrics.output,
|
|
617
|
+
};
|
|
618
|
+
if (before == null || after == null)
|
|
619
|
+
return result;
|
|
620
|
+
const counters = [
|
|
621
|
+
["graphNodes", "graphNodes"],
|
|
622
|
+
["graphPasses", "graphRenderPasses"],
|
|
623
|
+
["renderPasses", "renderPasses"],
|
|
624
|
+
["sourceSubmissions", "sourceSubmissions"],
|
|
625
|
+
["graphSubmissions", "graphSubmissions"],
|
|
626
|
+
["readbackSubmissions", "readbackSubmissions"],
|
|
627
|
+
["frameSubmissions", "frameSubmissions"],
|
|
628
|
+
["gpuCopyOperations", "gpuCopyOperations"],
|
|
629
|
+
["gpuCopyBytes", "gpuCopyBytes"],
|
|
630
|
+
];
|
|
631
|
+
for (const [output, input] of counters) {
|
|
632
|
+
result[output] = Math.max(0, (after.work?.[input] ?? 0) - (before.work?.[input] ?? 0));
|
|
633
|
+
}
|
|
634
|
+
appendPoolMetrics(result, "textures", "textures", "texture", before, after);
|
|
635
|
+
appendPoolMetrics(result, "uploadTextures", "uploadTextures", "uploadTexture", before, after);
|
|
636
|
+
appendPoolMetrics(result, "readbackBuffers", "readbackBuffers", "readbackBuffer", before, after);
|
|
637
|
+
appendPoolMetrics(result, "uniformBuffers", "uniformBuffers", "uniformBuffer", before, after);
|
|
638
|
+
result.sourceUploads = Math.max(0, (after.sourceFrames?.uploads ?? 0) - (before.sourceFrames?.uploads ?? 0));
|
|
639
|
+
result.physicalSourceReuses = Math.max(0, (after.sourceFrames?.physicalReuses ?? 0) -
|
|
640
|
+
(before.sourceFrames?.physicalReuses ?? 0));
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
function appendPoolMetrics(result, input, counterPrefix, highWaterPrefix, before, after) {
|
|
644
|
+
for (const counter of ["Created", "Reused", "Discarded", "Evicted"]) {
|
|
645
|
+
const key = counter.toLowerCase();
|
|
646
|
+
if (after[input]?.[key] === undefined)
|
|
647
|
+
continue;
|
|
648
|
+
result[`${counterPrefix}${counter}`] = Math.max(0, after[input][key] - (before[input]?.[key] ?? 0));
|
|
649
|
+
}
|
|
650
|
+
result[`${highWaterPrefix}HighWaterBytes`] =
|
|
651
|
+
after[input]?.highWaterBytes ?? 0;
|
|
652
|
+
}
|
|
653
|
+
function rendererDiagnostics(engine) {
|
|
654
|
+
if (typeof engine?.diagnostics !== "function")
|
|
655
|
+
return null;
|
|
656
|
+
try {
|
|
657
|
+
return JSON.parse(engine.diagnostics());
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function i420VideoFrame(step, timestamp, duration) {
|
|
664
|
+
if (step.format !== "I420" || step.width % 2 || step.height % 2) {
|
|
665
|
+
throw new Error("wasm export returned an invalid I420 frame");
|
|
666
|
+
}
|
|
667
|
+
// Rows stay padded as the GPU wrote them; where the browser ignores `layout` (Safari),
|
|
668
|
+
// `videoFrameFromI420` packs them tight first.
|
|
669
|
+
const widths = [step.width, step.width / 2, step.width / 2];
|
|
670
|
+
const rows = [step.height, step.height / 2, step.height / 2];
|
|
671
|
+
if (step.layout?.length !== 3 ||
|
|
672
|
+
step.layout.some(({ offset, stride }, plane) => stride < widths[plane] ||
|
|
673
|
+
offset + stride * (rows[plane] - 1) + widths[plane] >
|
|
674
|
+
step.pixels.byteLength)) {
|
|
675
|
+
throw new Error("wasm export returned an invalid I420 plane layout");
|
|
676
|
+
}
|
|
677
|
+
if (typeof step.colorSpace?.matrix !== "string") {
|
|
678
|
+
throw new Error("wasm export returned no color space for its I420 frame");
|
|
679
|
+
}
|
|
680
|
+
return videoFrameFromI420(step.pixels, {
|
|
681
|
+
format: "I420",
|
|
682
|
+
codedWidth: step.width,
|
|
683
|
+
codedHeight: step.height,
|
|
684
|
+
timestamp,
|
|
685
|
+
duration,
|
|
686
|
+
layout: [...step.layout],
|
|
687
|
+
// Without it the browser tags the stream with its own default for a buffer-backed I420 frame.
|
|
688
|
+
colorSpace: step.colorSpace,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/** The audio codecs an export may write, in the order they are preferred, with their names. */
|
|
692
|
+
const AUDIO_CODECS = { aac: "AAC", opus: "Opus" };
|
|
693
|
+
/**
|
|
694
|
+
* The audio codec this browser encodes the export's audio with, and the rate its encoder is given:
|
|
695
|
+
* AAC where it can, else Opus, each at the project rate where the encoder takes it and otherwise at
|
|
696
|
+
* the rate a native host would convert to (`audioEncoderSampleRate` in `alpha2/mediabunny`), so
|
|
697
|
+
* every project rate exports. A browser that encodes neither at any rate (Safari before 26 has a
|
|
698
|
+
* VideoEncoder but no AudioEncoder) is refused here, before anything renders, as `INVALID_INPUT`:
|
|
699
|
+
* a retry would meet the same browser.
|
|
700
|
+
*
|
|
701
|
+
* @returns {Promise<{ codec: "aac" | "opus", sampleRate: number }>}
|
|
702
|
+
*/
|
|
703
|
+
export async function chooseAudioCodec(sampleRate, audioQuality) {
|
|
704
|
+
for (const codec of /** @type {("aac" | "opus")[]} */ (Object.keys(AUDIO_CODECS))) {
|
|
705
|
+
const encoderRate = await audioEncoderSampleRate(codec, {
|
|
706
|
+
sampleRate,
|
|
707
|
+
numberOfChannels: 2,
|
|
708
|
+
bitrate: resolveAudioBitrate(engineBinding, codec, audioQuality),
|
|
709
|
+
});
|
|
710
|
+
if (encoderRate !== null)
|
|
711
|
+
return { codec, sampleRate: encoderRate };
|
|
712
|
+
}
|
|
713
|
+
throw new ExportFailureError({
|
|
714
|
+
code: "INVALID_INPUT",
|
|
715
|
+
message: `This browser cannot encode audio (${Object.values(AUDIO_CODECS).join(" or ")}, stereo) at ${sampleRate} Hz or at any rate it could be converted to. Export without audio, or use the native exporter.`,
|
|
716
|
+
origin: "browser",
|
|
717
|
+
operation: "export",
|
|
718
|
+
backend: "unknown",
|
|
719
|
+
phase: "prepare",
|
|
720
|
+
resource: "unknown",
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Refuses an export whose video codec this browser does not encode at the export's size and rate,
|
|
725
|
+
* before anything renders, as `INVALID_INPUT`: WebCodecs leaves the codecs to the browser, and a
|
|
726
|
+
* retry would meet the same browser.
|
|
727
|
+
*/
|
|
728
|
+
export async function requireVideoEncoder(codec, { width, height }, rate) {
|
|
729
|
+
if (await canEncodeVideoTrack(codec, { width, height, rate }))
|
|
730
|
+
return;
|
|
731
|
+
const label = VIDEO_CODECS.find((c) => c.value === codec)?.label ?? String(codec);
|
|
732
|
+
throw new ExportFailureError({
|
|
733
|
+
code: "INVALID_INPUT",
|
|
734
|
+
message: `This browser cannot encode ${label} video at ${width}x${height}. Choose another video codec, or use the native exporter.`,
|
|
735
|
+
origin: "browser",
|
|
736
|
+
operation: "export",
|
|
737
|
+
backend: "unknown",
|
|
738
|
+
phase: "prepare",
|
|
739
|
+
resource: "unknown",
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/** The exact rational export rate: the override as given, else the project's own. */
|
|
743
|
+
function exportFrameRate(override, settings) {
|
|
744
|
+
return override
|
|
745
|
+
? { num: override.num, den: override.den }
|
|
746
|
+
: { num: settings.fpsNum, den: settings.fpsDen };
|
|
747
|
+
}
|
|
748
|
+
function throwIfAborted(signal) {
|
|
749
|
+
if (signal?.aborted)
|
|
750
|
+
throw signal.reason ?? new DOMException("Export cancelled", "AbortError");
|
|
751
|
+
}
|