@codingfactory/mediables-vue 2.8.0 → 2.9.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/dist/PixiFrameExporter-COcgeYmj.cjs +2 -0
- package/dist/{PixiFrameExporter-CqKXaoCX.cjs.map → PixiFrameExporter-COcgeYmj.cjs.map} +1 -1
- package/dist/{PixiFrameExporter-BcywK0MP.js → PixiFrameExporter-SiG3q5-_.js} +60 -59
- package/dist/{PixiFrameExporter-BcywK0MP.js.map → PixiFrameExporter-SiG3q5-_.js.map} +1 -1
- package/dist/components/video/ui-v2/AssetRail.vue.d.ts +2 -0
- package/dist/components/video/ui-v2/AssetRailItem.vue.d.ts +7 -0
- package/dist/components/video/ui-v2/AudioMixerPanel.vue.d.ts +33 -0
- package/dist/components/video/ui-v2/CaptionSrtPanel.vue.d.ts +9 -0
- package/dist/components/video/ui-v2/DesktopInspectorSections.vue.d.ts +21 -0
- package/dist/components/video/ui-v2/DraftRecoveryBanner.vue.d.ts +12 -0
- package/dist/components/video/ui-v2/KeyframePresetPanel.vue.d.ts +26 -0
- package/dist/components/video/ui-v2/MobileClipSummary.vue.d.ts +4 -0
- package/dist/components/video/ui-v2/MobileToolPicker.vue.d.ts +3 -0
- package/dist/components/video/ui-v2/SplitPanelLayout.vue.d.ts +9 -5
- package/dist/components/video/ui-v2/TimelineTrackHeader.vue.d.ts +2 -0
- package/dist/composables/useClientVideoExport.d.ts +27 -0
- package/dist/composables/useLiveStream.d.ts +2 -2
- package/dist/composables/useRadialMenu.d.ts +1 -1
- package/dist/composables/useVideoEditor.d.ts +119 -7
- package/dist/composables/useVideoFilters.d.ts +4 -4
- package/dist/index-B6oyn6Pa.cjs +350 -0
- package/dist/index-B6oyn6Pa.cjs.map +1 -0
- package/dist/index-BGexNz7s.js +32993 -0
- package/dist/index-BGexNz7s.js.map +1 -0
- package/dist/mediables-vue.cjs +1 -1
- package/dist/mediables-vue.mjs +1 -1
- package/dist/render-page/assets/{index-y90zwXpc.js → index-jZGmiMRr.js} +991 -151
- package/dist/render-page/index.html +1 -1
- package/dist/services/VideoJobClient.d.ts +1 -1
- package/dist/style.css +1 -1
- package/dist/types/api.d.ts +14 -0
- package/dist/types/video.d.ts +164 -5
- package/dist/video/project/audioMixerSchema.d.ts +152 -0
- package/dist/video/project/captionSrt.d.ts +23 -0
- package/dist/video/project/draftRecovery.d.ts +86 -0
- package/dist/video/project/exportPresets.d.ts +172 -0
- package/dist/video/project/index.d.ts +22 -0
- package/dist/video/project/keyframeAutomation.d.ts +91 -0
- package/dist/video/project/mediaSourceAnalysis.d.ts +127 -0
- package/dist/video/project/mediaSourceCache.d.ts +47 -0
- package/dist/video/project/recipeMigration.d.ts +26 -0
- package/dist/video/project/timelineSelection.d.ts +23 -0
- package/dist/video/project/timelineTransactions.d.ts +129 -0
- package/dist/video/project/visualLayerSchema.d.ts +238 -0
- package/dist/video-engine/adapters/AudioManager.d.ts +9 -0
- package/dist/video-engine/adapters/MediablesCompositionAdapter.d.ts +3 -0
- package/dist/video-engine/adapters/TextOverlayManager.d.ts +4 -2
- package/package.json +1 -1
- package/dist/PixiFrameExporter-CqKXaoCX.cjs +0 -2
- package/dist/index-DNo3Kr1t.cjs +0 -342
- package/dist/index-DNo3Kr1t.cjs.map +0 -1
- package/dist/index-DpkdpuMs.js +0 -29327
- package/dist/index-DpkdpuMs.js.map +0 -1
|
@@ -70936,6 +70936,10 @@ const MAX_SEEK_RETRY_ATTEMPTS = 6;
|
|
|
70936
70936
|
const MIN_SEEK_TIMEOUT_MS = 250;
|
|
70937
70937
|
const MAX_SEEK_NUDGE_SECONDS = 0.25;
|
|
70938
70938
|
const MIN_SEEK_EPSILON_SECONDS = 1e-3;
|
|
70939
|
+
const HAVE_CURRENT_DATA = 2;
|
|
70940
|
+
function shouldSkipRedundantSeek(video, targetTime) {
|
|
70941
|
+
return video.readyState >= HAVE_CURRENT_DATA && !video.seeking && Math.abs(video.currentTime - targetTime) < MIN_SEEK_EPSILON_SECONDS;
|
|
70942
|
+
}
|
|
70939
70943
|
function normalizeSeekTimeoutMs(value) {
|
|
70940
70944
|
if (!Number.isFinite(value) || value === void 0 || value < MIN_SEEK_TIMEOUT_MS) {
|
|
70941
70945
|
return DEFAULT_SEEK_TIMEOUT_MS;
|
|
@@ -71046,7 +71050,548 @@ function resetTime(video) {
|
|
|
71046
71050
|
} catch {
|
|
71047
71051
|
}
|
|
71048
71052
|
}
|
|
71049
|
-
const
|
|
71053
|
+
const MIN_PLAYBACK_RATE = 0.1;
|
|
71054
|
+
const MAX_PLAYBACK_RATE = 16;
|
|
71055
|
+
const DEFAULT_PLAYBACK_RATE = 1;
|
|
71056
|
+
const DEFAULT_FRAME_TIMEOUT_MS = 4e3;
|
|
71057
|
+
class PlaybackCaptureFallbackError extends Error {
|
|
71058
|
+
constructor(startFrame, reason) {
|
|
71059
|
+
super(reason);
|
|
71060
|
+
this.name = "PlaybackCaptureFallbackError";
|
|
71061
|
+
this.startFrame = startFrame;
|
|
71062
|
+
}
|
|
71063
|
+
}
|
|
71064
|
+
function hasRequestVideoFrameCallback(video) {
|
|
71065
|
+
return typeof video.requestVideoFrameCallback === "function";
|
|
71066
|
+
}
|
|
71067
|
+
function normalizeRenderCaptureMode(value) {
|
|
71068
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
71069
|
+
if (normalized === "seek" || normalized === "rvfc" || normalized === "auto") {
|
|
71070
|
+
return normalized;
|
|
71071
|
+
}
|
|
71072
|
+
return "auto";
|
|
71073
|
+
}
|
|
71074
|
+
function normalizeCapturePlaybackRate(value) {
|
|
71075
|
+
if (!Number.isFinite(value) || value === void 0 || value <= 0) {
|
|
71076
|
+
return DEFAULT_PLAYBACK_RATE;
|
|
71077
|
+
}
|
|
71078
|
+
return Math.min(MAX_PLAYBACK_RATE, Math.max(MIN_PLAYBACK_RATE, value));
|
|
71079
|
+
}
|
|
71080
|
+
function normalizeMaxFrameGap(value) {
|
|
71081
|
+
if (!Number.isFinite(value) || value === void 0 || value < 0) {
|
|
71082
|
+
return 0;
|
|
71083
|
+
}
|
|
71084
|
+
return Math.floor(value);
|
|
71085
|
+
}
|
|
71086
|
+
function normalizeFrameTimeoutMs(value) {
|
|
71087
|
+
if (!Number.isFinite(value) || value === void 0 || value < 250) {
|
|
71088
|
+
return DEFAULT_FRAME_TIMEOUT_MS;
|
|
71089
|
+
}
|
|
71090
|
+
return Math.floor(value);
|
|
71091
|
+
}
|
|
71092
|
+
function hasUnsupportedTimeline(recipe) {
|
|
71093
|
+
const clips = Array.isArray(recipe.timeline) ? recipe.timeline : [];
|
|
71094
|
+
if (clips.length > 1) {
|
|
71095
|
+
return true;
|
|
71096
|
+
}
|
|
71097
|
+
const firstClip = clips[0];
|
|
71098
|
+
if (firstClip === void 0) {
|
|
71099
|
+
return false;
|
|
71100
|
+
}
|
|
71101
|
+
return typeof firstClip.speed === "number" && Number.isFinite(firstClip.speed) && Math.abs(firstClip.speed - 1) > 1e-4;
|
|
71102
|
+
}
|
|
71103
|
+
function resolveRenderCaptureMode(options) {
|
|
71104
|
+
const requestedMode = normalizeRenderCaptureMode(options.requestedMode);
|
|
71105
|
+
if (requestedMode === "seek") {
|
|
71106
|
+
return {
|
|
71107
|
+
requestedMode,
|
|
71108
|
+
effectiveMode: "seek",
|
|
71109
|
+
fallbackReason: null
|
|
71110
|
+
};
|
|
71111
|
+
}
|
|
71112
|
+
if (!options.supportsRequestVideoFrameCallback) {
|
|
71113
|
+
if (requestedMode === "rvfc") {
|
|
71114
|
+
return {
|
|
71115
|
+
requestedMode,
|
|
71116
|
+
effectiveMode: "seek",
|
|
71117
|
+
fallbackReason: "requestVideoFrameCallback unavailable"
|
|
71118
|
+
};
|
|
71119
|
+
}
|
|
71120
|
+
return {
|
|
71121
|
+
requestedMode,
|
|
71122
|
+
effectiveMode: "seek",
|
|
71123
|
+
fallbackReason: "auto selected seek because requestVideoFrameCallback is unavailable"
|
|
71124
|
+
};
|
|
71125
|
+
}
|
|
71126
|
+
if (!Number.isFinite(options.fps) || options.fps <= 0) {
|
|
71127
|
+
return {
|
|
71128
|
+
requestedMode,
|
|
71129
|
+
effectiveMode: "seek",
|
|
71130
|
+
fallbackReason: "auto selected seek because fps is invalid"
|
|
71131
|
+
};
|
|
71132
|
+
}
|
|
71133
|
+
if (!Number.isFinite(options.trimStart) || !Number.isFinite(options.trimEnd) || options.trimEnd <= options.trimStart) {
|
|
71134
|
+
return {
|
|
71135
|
+
requestedMode,
|
|
71136
|
+
effectiveMode: "seek",
|
|
71137
|
+
fallbackReason: "auto selected seek because trim bounds are invalid"
|
|
71138
|
+
};
|
|
71139
|
+
}
|
|
71140
|
+
if (hasUnsupportedTimeline(options.recipe)) {
|
|
71141
|
+
return {
|
|
71142
|
+
requestedMode,
|
|
71143
|
+
effectiveMode: "seek",
|
|
71144
|
+
fallbackReason: "auto selected seek because the recipe is not a single forward clip"
|
|
71145
|
+
};
|
|
71146
|
+
}
|
|
71147
|
+
return {
|
|
71148
|
+
requestedMode,
|
|
71149
|
+
effectiveMode: "rvfc",
|
|
71150
|
+
fallbackReason: null
|
|
71151
|
+
};
|
|
71152
|
+
}
|
|
71153
|
+
function mapMediaTimeToOutputFrame(mediaTime, trimStart, fps) {
|
|
71154
|
+
if (!Number.isFinite(mediaTime) || !Number.isFinite(trimStart) || !Number.isFinite(fps) || fps <= 0) {
|
|
71155
|
+
return 0;
|
|
71156
|
+
}
|
|
71157
|
+
return Math.max(0, Math.round((mediaTime - trimStart) * fps));
|
|
71158
|
+
}
|
|
71159
|
+
function outputTimeForFrame(frame, trimStart, fps) {
|
|
71160
|
+
return trimStart + frame / fps;
|
|
71161
|
+
}
|
|
71162
|
+
async function waitForNextVideoFrame(video, timeoutMs) {
|
|
71163
|
+
return new Promise((resolve, reject) => {
|
|
71164
|
+
let settled = false;
|
|
71165
|
+
let callbackHandle = null;
|
|
71166
|
+
const finish = (metadata, error) => {
|
|
71167
|
+
if (settled) {
|
|
71168
|
+
return;
|
|
71169
|
+
}
|
|
71170
|
+
settled = true;
|
|
71171
|
+
window.clearTimeout(timeoutHandle);
|
|
71172
|
+
if (callbackHandle !== null && typeof video.cancelVideoFrameCallback === "function") {
|
|
71173
|
+
video.cancelVideoFrameCallback(callbackHandle);
|
|
71174
|
+
}
|
|
71175
|
+
if (error !== void 0) {
|
|
71176
|
+
reject(error);
|
|
71177
|
+
return;
|
|
71178
|
+
}
|
|
71179
|
+
if (metadata === null) {
|
|
71180
|
+
reject(new Error("Video frame callback completed without metadata"));
|
|
71181
|
+
return;
|
|
71182
|
+
}
|
|
71183
|
+
resolve(metadata);
|
|
71184
|
+
};
|
|
71185
|
+
const timeoutHandle = window.setTimeout(() => {
|
|
71186
|
+
finish(null, new Error(`Timed out waiting for playback frame after ${timeoutMs}ms`));
|
|
71187
|
+
}, timeoutMs);
|
|
71188
|
+
callbackHandle = video.requestVideoFrameCallback((_now, metadata) => {
|
|
71189
|
+
finish(metadata);
|
|
71190
|
+
});
|
|
71191
|
+
});
|
|
71192
|
+
}
|
|
71193
|
+
async function runSeekCaptureLoop(options) {
|
|
71194
|
+
const startFrame = Math.max(0, Math.floor(options.startFrame ?? 0));
|
|
71195
|
+
for (let frame = startFrame; frame < options.totalFrames; frame++) {
|
|
71196
|
+
const timeSec = outputTimeForFrame(frame, options.trimStart, options.fps);
|
|
71197
|
+
options.touchHeartbeat("capturing-seek");
|
|
71198
|
+
await options.seekToFrame(frame, timeSec);
|
|
71199
|
+
await options.captureFrame({
|
|
71200
|
+
frame,
|
|
71201
|
+
timeSec,
|
|
71202
|
+
duplicatePreviousFrame: false
|
|
71203
|
+
});
|
|
71204
|
+
options.updateProgress(frame);
|
|
71205
|
+
options.touchHeartbeat("encoding-video");
|
|
71206
|
+
}
|
|
71207
|
+
return options.totalFrames;
|
|
71208
|
+
}
|
|
71209
|
+
async function runPlaybackCaptureLoop(options) {
|
|
71210
|
+
var _a;
|
|
71211
|
+
if (!hasRequestVideoFrameCallback(options.video)) {
|
|
71212
|
+
throw new PlaybackCaptureFallbackError(
|
|
71213
|
+
Math.max(0, Math.floor(options.startFrame ?? 0)),
|
|
71214
|
+
"requestVideoFrameCallback unavailable"
|
|
71215
|
+
);
|
|
71216
|
+
}
|
|
71217
|
+
const video = options.video;
|
|
71218
|
+
let nextFrame = Math.max(0, Math.floor(options.startFrame ?? 0));
|
|
71219
|
+
const previousPlaybackRate = video.playbackRate;
|
|
71220
|
+
const previousMuted = video.muted;
|
|
71221
|
+
const timeoutMs = normalizeFrameTimeoutMs(options.frameTimeoutMs);
|
|
71222
|
+
try {
|
|
71223
|
+
video.muted = true;
|
|
71224
|
+
video.playbackRate = normalizeCapturePlaybackRate(options.playbackRate);
|
|
71225
|
+
if (nextFrame < options.totalFrames) {
|
|
71226
|
+
const firstTimeSec = outputTimeForFrame(nextFrame, options.trimStart, options.fps);
|
|
71227
|
+
options.touchHeartbeat("capturing-playback");
|
|
71228
|
+
await options.seekToFrame(nextFrame, firstTimeSec);
|
|
71229
|
+
await options.captureFrame({
|
|
71230
|
+
frame: nextFrame,
|
|
71231
|
+
timeSec: firstTimeSec,
|
|
71232
|
+
duplicatePreviousFrame: false
|
|
71233
|
+
});
|
|
71234
|
+
options.updateProgress(nextFrame);
|
|
71235
|
+
nextFrame += 1;
|
|
71236
|
+
}
|
|
71237
|
+
while (nextFrame < options.totalFrames) {
|
|
71238
|
+
options.touchHeartbeat("capturing-playback");
|
|
71239
|
+
try {
|
|
71240
|
+
await video.play();
|
|
71241
|
+
} catch (error) {
|
|
71242
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71243
|
+
throw new PlaybackCaptureFallbackError(nextFrame, `playback start failed: ${message}`);
|
|
71244
|
+
}
|
|
71245
|
+
const waitStartedAt = performance.now();
|
|
71246
|
+
const metadata = await waitForNextVideoFrame(video, timeoutMs);
|
|
71247
|
+
(_a = options.markPlaybackWait) == null ? void 0 : _a.call(options, performance.now() - waitStartedAt);
|
|
71248
|
+
video.pause();
|
|
71249
|
+
if (metadata.mediaTime < options.trimStart) {
|
|
71250
|
+
continue;
|
|
71251
|
+
}
|
|
71252
|
+
if (metadata.mediaTime > options.trimEnd + 1 / options.fps) {
|
|
71253
|
+
throw new PlaybackCaptureFallbackError(nextFrame, "playback moved beyond trim end");
|
|
71254
|
+
}
|
|
71255
|
+
const frameIndex = mapMediaTimeToOutputFrame(metadata.mediaTime, options.trimStart, options.fps);
|
|
71256
|
+
if (frameIndex < nextFrame) {
|
|
71257
|
+
continue;
|
|
71258
|
+
}
|
|
71259
|
+
const gap = frameIndex - nextFrame;
|
|
71260
|
+
if (gap > options.maxFrameGap) {
|
|
71261
|
+
throw new PlaybackCaptureFallbackError(
|
|
71262
|
+
nextFrame,
|
|
71263
|
+
`playback skipped ${gap} output frame${gap === 1 ? "" : "s"}`
|
|
71264
|
+
);
|
|
71265
|
+
}
|
|
71266
|
+
while (nextFrame < frameIndex) {
|
|
71267
|
+
const duplicateTimeSec = outputTimeForFrame(nextFrame, options.trimStart, options.fps);
|
|
71268
|
+
await options.captureFrame({
|
|
71269
|
+
frame: nextFrame,
|
|
71270
|
+
timeSec: duplicateTimeSec,
|
|
71271
|
+
duplicatePreviousFrame: true
|
|
71272
|
+
});
|
|
71273
|
+
options.updateProgress(nextFrame);
|
|
71274
|
+
nextFrame += 1;
|
|
71275
|
+
}
|
|
71276
|
+
const timeSec = outputTimeForFrame(frameIndex, options.trimStart, options.fps);
|
|
71277
|
+
await options.captureFrame({
|
|
71278
|
+
frame: frameIndex,
|
|
71279
|
+
timeSec,
|
|
71280
|
+
duplicatePreviousFrame: false
|
|
71281
|
+
});
|
|
71282
|
+
options.updateProgress(frameIndex);
|
|
71283
|
+
nextFrame = frameIndex + 1;
|
|
71284
|
+
}
|
|
71285
|
+
return nextFrame;
|
|
71286
|
+
} finally {
|
|
71287
|
+
video.pause();
|
|
71288
|
+
video.playbackRate = previousPlaybackRate;
|
|
71289
|
+
video.muted = previousMuted;
|
|
71290
|
+
}
|
|
71291
|
+
}
|
|
71292
|
+
function nowMs() {
|
|
71293
|
+
return performance.now();
|
|
71294
|
+
}
|
|
71295
|
+
function roundMs(value) {
|
|
71296
|
+
return Math.round(value * 100) / 100;
|
|
71297
|
+
}
|
|
71298
|
+
function createRenderMetrics(options) {
|
|
71299
|
+
const startedAt = nowMs();
|
|
71300
|
+
let captureMode = options.captureMode;
|
|
71301
|
+
let fallbackReason = options.fallbackReason ?? null;
|
|
71302
|
+
let framesRendered = 0;
|
|
71303
|
+
let duplicateFrames = 0;
|
|
71304
|
+
let playbackFallbacks = 0;
|
|
71305
|
+
let seekMs = 0;
|
|
71306
|
+
let sourceSnapshotMs = 0;
|
|
71307
|
+
let textureUploadMs = 0;
|
|
71308
|
+
let pixiRenderMs = 0;
|
|
71309
|
+
let encodeMs = 0;
|
|
71310
|
+
let encodeBackpressureMs = 0;
|
|
71311
|
+
let frameReadbackMs = 0;
|
|
71312
|
+
let framePipeMs = 0;
|
|
71313
|
+
let framePipeBackpressureMs = 0;
|
|
71314
|
+
let videoFlushMs = 0;
|
|
71315
|
+
let ffmpegEncodeElapsedMs = 0;
|
|
71316
|
+
let audioEncodeMs = 0;
|
|
71317
|
+
let audioFlushMs = 0;
|
|
71318
|
+
let muxFinalizeMs = 0;
|
|
71319
|
+
let playbackWaitMs = 0;
|
|
71320
|
+
const markDuration = (metric, durationMs) => {
|
|
71321
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
71322
|
+
return;
|
|
71323
|
+
}
|
|
71324
|
+
switch (metric) {
|
|
71325
|
+
case "seek":
|
|
71326
|
+
seekMs += durationMs;
|
|
71327
|
+
return;
|
|
71328
|
+
case "sourceSnapshot":
|
|
71329
|
+
sourceSnapshotMs += durationMs;
|
|
71330
|
+
return;
|
|
71331
|
+
case "textureUpload":
|
|
71332
|
+
textureUploadMs += durationMs;
|
|
71333
|
+
return;
|
|
71334
|
+
case "pixiRender":
|
|
71335
|
+
pixiRenderMs += durationMs;
|
|
71336
|
+
return;
|
|
71337
|
+
case "encode":
|
|
71338
|
+
encodeMs += durationMs;
|
|
71339
|
+
return;
|
|
71340
|
+
case "encodeBackpressure":
|
|
71341
|
+
encodeBackpressureMs += durationMs;
|
|
71342
|
+
return;
|
|
71343
|
+
case "frameReadback":
|
|
71344
|
+
frameReadbackMs += durationMs;
|
|
71345
|
+
return;
|
|
71346
|
+
case "framePipe":
|
|
71347
|
+
framePipeMs += durationMs;
|
|
71348
|
+
return;
|
|
71349
|
+
case "framePipeBackpressure":
|
|
71350
|
+
framePipeBackpressureMs += durationMs;
|
|
71351
|
+
return;
|
|
71352
|
+
case "videoFlush":
|
|
71353
|
+
videoFlushMs += durationMs;
|
|
71354
|
+
return;
|
|
71355
|
+
case "ffmpegEncode":
|
|
71356
|
+
ffmpegEncodeElapsedMs += durationMs;
|
|
71357
|
+
return;
|
|
71358
|
+
case "audioEncode":
|
|
71359
|
+
audioEncodeMs += durationMs;
|
|
71360
|
+
return;
|
|
71361
|
+
case "audioFlush":
|
|
71362
|
+
audioFlushMs += durationMs;
|
|
71363
|
+
return;
|
|
71364
|
+
case "muxFinalize":
|
|
71365
|
+
muxFinalizeMs += durationMs;
|
|
71366
|
+
return;
|
|
71367
|
+
case "playbackWait":
|
|
71368
|
+
playbackWaitMs += durationMs;
|
|
71369
|
+
return;
|
|
71370
|
+
}
|
|
71371
|
+
};
|
|
71372
|
+
return {
|
|
71373
|
+
markDuration,
|
|
71374
|
+
markFrameRendered: () => {
|
|
71375
|
+
framesRendered += 1;
|
|
71376
|
+
},
|
|
71377
|
+
markDuplicateFrame: () => {
|
|
71378
|
+
duplicateFrames += 1;
|
|
71379
|
+
},
|
|
71380
|
+
markPlaybackFallback: (reason) => {
|
|
71381
|
+
playbackFallbacks += 1;
|
|
71382
|
+
fallbackReason = reason;
|
|
71383
|
+
},
|
|
71384
|
+
setCaptureMode: (nextCaptureMode, reason = null) => {
|
|
71385
|
+
captureMode = nextCaptureMode;
|
|
71386
|
+
fallbackReason = reason;
|
|
71387
|
+
},
|
|
71388
|
+
setFfmpegEncodeElapsed: (durationMs) => {
|
|
71389
|
+
if (Number.isFinite(durationMs) && durationMs >= 0) {
|
|
71390
|
+
ffmpegEncodeElapsedMs = durationMs;
|
|
71391
|
+
}
|
|
71392
|
+
},
|
|
71393
|
+
snapshot: () => ({
|
|
71394
|
+
encoderMode: options.encoderMode ?? "webcodecs",
|
|
71395
|
+
captureMode,
|
|
71396
|
+
requestedCaptureMode: options.requestedCaptureMode,
|
|
71397
|
+
fallbackReason,
|
|
71398
|
+
fps: options.fps,
|
|
71399
|
+
framesRendered,
|
|
71400
|
+
totalFrames: options.totalFrames,
|
|
71401
|
+
duplicateFrames,
|
|
71402
|
+
playbackFallbacks,
|
|
71403
|
+
seekMs: roundMs(seekMs),
|
|
71404
|
+
sourceSnapshotMs: roundMs(sourceSnapshotMs),
|
|
71405
|
+
textureUploadMs: roundMs(textureUploadMs),
|
|
71406
|
+
pixiRenderMs: roundMs(pixiRenderMs),
|
|
71407
|
+
encodeMs: roundMs(encodeMs),
|
|
71408
|
+
encodeBackpressureMs: roundMs(encodeBackpressureMs),
|
|
71409
|
+
frameReadbackMs: roundMs(frameReadbackMs),
|
|
71410
|
+
framePipeMs: roundMs(framePipeMs),
|
|
71411
|
+
framePipeBackpressureMs: roundMs(framePipeBackpressureMs),
|
|
71412
|
+
videoFlushMs: roundMs(videoFlushMs),
|
|
71413
|
+
ffmpegEncodeElapsedMs: roundMs(ffmpegEncodeElapsedMs),
|
|
71414
|
+
audioEncodeMs: roundMs(audioEncodeMs),
|
|
71415
|
+
audioFlushMs: roundMs(audioFlushMs),
|
|
71416
|
+
muxFinalizeMs: roundMs(muxFinalizeMs),
|
|
71417
|
+
playbackWaitMs: roundMs(playbackWaitMs),
|
|
71418
|
+
elapsedMs: roundMs(nowMs() - startedAt)
|
|
71419
|
+
})
|
|
71420
|
+
};
|
|
71421
|
+
}
|
|
71422
|
+
async function measureAsyncMetric(metrics, metric, callback) {
|
|
71423
|
+
const startedAt = nowMs();
|
|
71424
|
+
try {
|
|
71425
|
+
return await callback();
|
|
71426
|
+
} finally {
|
|
71427
|
+
metrics.markDuration(metric, nowMs() - startedAt);
|
|
71428
|
+
}
|
|
71429
|
+
}
|
|
71430
|
+
function measureSyncMetric(metrics, metric, callback) {
|
|
71431
|
+
const startedAt = nowMs();
|
|
71432
|
+
try {
|
|
71433
|
+
return callback();
|
|
71434
|
+
} finally {
|
|
71435
|
+
metrics.markDuration(metric, nowMs() - startedAt);
|
|
71436
|
+
}
|
|
71437
|
+
}
|
|
71438
|
+
const MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
|
|
71439
|
+
const MAX_IN_FLIGHT_FRAMES = 4;
|
|
71440
|
+
const MAX_IN_FLIGHT_BYTES = 64 * 1024 * 1024;
|
|
71441
|
+
function waitForMilliseconds$1(milliseconds) {
|
|
71442
|
+
return new Promise((resolve) => {
|
|
71443
|
+
window.setTimeout(resolve, milliseconds);
|
|
71444
|
+
});
|
|
71445
|
+
}
|
|
71446
|
+
function decodeFrameSocketMessage(rawData) {
|
|
71447
|
+
if (typeof rawData !== "string") {
|
|
71448
|
+
return null;
|
|
71449
|
+
}
|
|
71450
|
+
try {
|
|
71451
|
+
const decoded = JSON.parse(rawData);
|
|
71452
|
+
if (typeof decoded !== "object" || decoded === null) {
|
|
71453
|
+
return null;
|
|
71454
|
+
}
|
|
71455
|
+
const message = decoded;
|
|
71456
|
+
if (message["type"] === "ack" && typeof message["frame"] === "number") {
|
|
71457
|
+
return { type: "ack", frame: message["frame"] };
|
|
71458
|
+
}
|
|
71459
|
+
if (message["type"] === "error" && typeof message["message"] === "string") {
|
|
71460
|
+
return { type: "error", message: message["message"] };
|
|
71461
|
+
}
|
|
71462
|
+
return null;
|
|
71463
|
+
} catch {
|
|
71464
|
+
return null;
|
|
71465
|
+
}
|
|
71466
|
+
}
|
|
71467
|
+
async function waitForSocketOpen(socket) {
|
|
71468
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
71469
|
+
return;
|
|
71470
|
+
}
|
|
71471
|
+
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
|
|
71472
|
+
throw new Error("ffmpeg frame WebSocket closed before opening");
|
|
71473
|
+
}
|
|
71474
|
+
await new Promise((resolve, reject) => {
|
|
71475
|
+
const onOpen = () => {
|
|
71476
|
+
cleanup();
|
|
71477
|
+
resolve();
|
|
71478
|
+
};
|
|
71479
|
+
const onError = () => {
|
|
71480
|
+
cleanup();
|
|
71481
|
+
reject(new Error("ffmpeg frame WebSocket failed to open"));
|
|
71482
|
+
};
|
|
71483
|
+
const cleanup = () => {
|
|
71484
|
+
socket.removeEventListener("open", onOpen);
|
|
71485
|
+
socket.removeEventListener("error", onError);
|
|
71486
|
+
};
|
|
71487
|
+
socket.addEventListener("open", onOpen);
|
|
71488
|
+
socket.addEventListener("error", onError);
|
|
71489
|
+
});
|
|
71490
|
+
}
|
|
71491
|
+
async function waitForSendWindow(socket, pendingAcks, inFlightBytes, nextFrameBytes) {
|
|
71492
|
+
while (socket.bufferedAmount > MAX_BUFFERED_BYTES || pendingAcks.size >= MAX_IN_FLIGHT_FRAMES || pendingAcks.size > 0 && inFlightBytes() + nextFrameBytes > MAX_IN_FLIGHT_BYTES) {
|
|
71493
|
+
if (socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
|
|
71494
|
+
throw new Error("ffmpeg frame WebSocket closed while waiting for send window");
|
|
71495
|
+
}
|
|
71496
|
+
await waitForMilliseconds$1(10);
|
|
71497
|
+
}
|
|
71498
|
+
}
|
|
71499
|
+
function createFfmpegFrameSocket(url2) {
|
|
71500
|
+
const socket = new WebSocket(url2);
|
|
71501
|
+
socket.binaryType = "arraybuffer";
|
|
71502
|
+
const pendingAcks = /* @__PURE__ */ new Map();
|
|
71503
|
+
let pendingBytes = 0;
|
|
71504
|
+
const rejectPending = (error) => {
|
|
71505
|
+
for (const pendingAck of pendingAcks.values()) {
|
|
71506
|
+
pendingAck.reject(error);
|
|
71507
|
+
}
|
|
71508
|
+
pendingAcks.clear();
|
|
71509
|
+
pendingBytes = 0;
|
|
71510
|
+
};
|
|
71511
|
+
socket.addEventListener("message", (event) => {
|
|
71512
|
+
const message = decodeFrameSocketMessage(event.data);
|
|
71513
|
+
if (message === null) {
|
|
71514
|
+
return;
|
|
71515
|
+
}
|
|
71516
|
+
if (message.type === "error") {
|
|
71517
|
+
rejectPending(new Error(message.message));
|
|
71518
|
+
return;
|
|
71519
|
+
}
|
|
71520
|
+
const pendingAck = pendingAcks.get(message.frame);
|
|
71521
|
+
if (pendingAck === void 0) {
|
|
71522
|
+
rejectPending(new Error(`Unexpected ffmpeg frame ack ${message.frame}; no matching frame is pending`));
|
|
71523
|
+
return;
|
|
71524
|
+
}
|
|
71525
|
+
pendingAcks.delete(message.frame);
|
|
71526
|
+
pendingBytes = Math.max(0, pendingBytes - pendingAck.bytes);
|
|
71527
|
+
pendingAck.resolve();
|
|
71528
|
+
});
|
|
71529
|
+
socket.addEventListener("error", () => {
|
|
71530
|
+
rejectPending(new Error("ffmpeg frame WebSocket error"));
|
|
71531
|
+
});
|
|
71532
|
+
socket.addEventListener("close", () => {
|
|
71533
|
+
rejectPending(new Error("ffmpeg frame WebSocket closed"));
|
|
71534
|
+
});
|
|
71535
|
+
return {
|
|
71536
|
+
sendFrame: async (frameBytes, frame, onSent) => {
|
|
71537
|
+
if (pendingAcks.has(frame)) {
|
|
71538
|
+
throw new Error(`Cannot send duplicate ffmpeg frame ${frame}; it is still awaiting ack`);
|
|
71539
|
+
}
|
|
71540
|
+
await waitForSocketOpen(socket);
|
|
71541
|
+
await waitForSendWindow(socket, pendingAcks, () => pendingBytes, frameBytes.byteLength);
|
|
71542
|
+
let resolveAck = null;
|
|
71543
|
+
let rejectAck = null;
|
|
71544
|
+
const ackPromise = new Promise((resolve, reject) => {
|
|
71545
|
+
resolveAck = resolve;
|
|
71546
|
+
rejectAck = reject;
|
|
71547
|
+
});
|
|
71548
|
+
if (resolveAck === null || rejectAck === null) {
|
|
71549
|
+
throw new Error("Failed to initialize ffmpeg frame ack handlers");
|
|
71550
|
+
}
|
|
71551
|
+
pendingAcks.set(frame, {
|
|
71552
|
+
bytes: frameBytes.byteLength,
|
|
71553
|
+
promise: ackPromise,
|
|
71554
|
+
resolve: resolveAck,
|
|
71555
|
+
reject: rejectAck
|
|
71556
|
+
});
|
|
71557
|
+
pendingBytes += frameBytes.byteLength;
|
|
71558
|
+
socket.send(frameBytes);
|
|
71559
|
+
onSent == null ? void 0 : onSent();
|
|
71560
|
+
await ackPromise;
|
|
71561
|
+
},
|
|
71562
|
+
flush: async () => {
|
|
71563
|
+
while (pendingAcks.size > 0) {
|
|
71564
|
+
await Promise.race(Array.from(pendingAcks.values(), (pendingAck) => pendingAck.promise));
|
|
71565
|
+
}
|
|
71566
|
+
},
|
|
71567
|
+
close: () => {
|
|
71568
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
71569
|
+
socket.close();
|
|
71570
|
+
}
|
|
71571
|
+
}
|
|
71572
|
+
};
|
|
71573
|
+
}
|
|
71574
|
+
async function copyCanvasToRgba(canvas, width, height, fallbackCanvas, fallbackCtx) {
|
|
71575
|
+
const expectedBytes = width * height * 4;
|
|
71576
|
+
const videoFrame = new VideoFrame(canvas, { timestamp: 0 });
|
|
71577
|
+
try {
|
|
71578
|
+
const bytes2 = new Uint8Array(expectedBytes);
|
|
71579
|
+
await videoFrame.copyTo(bytes2, { format: "RGBA" });
|
|
71580
|
+
return bytes2;
|
|
71581
|
+
} catch (error) {
|
|
71582
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71583
|
+
console.warn("[RenderVideo] VideoFrame.copyTo(RGBA) failed; falling back to getImageData()", message);
|
|
71584
|
+
} finally {
|
|
71585
|
+
videoFrame.close();
|
|
71586
|
+
}
|
|
71587
|
+
fallbackCtx.clearRect(0, 0, width, height);
|
|
71588
|
+
fallbackCtx.drawImage(canvas, 0, 0, width, height);
|
|
71589
|
+
const imageData = fallbackCtx.getImageData(0, 0, width, height);
|
|
71590
|
+
if (fallbackCanvas.width !== width || fallbackCanvas.height !== height) {
|
|
71591
|
+
throw new Error("RGBA fallback canvas dimensions changed during readback");
|
|
71592
|
+
}
|
|
71593
|
+
return new Uint8Array(imageData.data);
|
|
71594
|
+
}
|
|
71050
71595
|
const PENDING_SEEK_DRAIN_TIMEOUT_MS = 1e3;
|
|
71051
71596
|
function resolveFitMode(recipe) {
|
|
71052
71597
|
var _a;
|
|
@@ -71107,6 +71652,31 @@ function waitForMilliseconds(milliseconds) {
|
|
|
71107
71652
|
window.setTimeout(resolve, milliseconds);
|
|
71108
71653
|
});
|
|
71109
71654
|
}
|
|
71655
|
+
function resolveEncoderMode(config) {
|
|
71656
|
+
return config.encoder === "ffmpeg" ? "ffmpeg" : "webcodecs";
|
|
71657
|
+
}
|
|
71658
|
+
function collectRenderRuntimeInfo() {
|
|
71659
|
+
const canvas = document.createElement("canvas");
|
|
71660
|
+
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
|
71661
|
+
const runtimeInfo = {
|
|
71662
|
+
userAgent: navigator.userAgent,
|
|
71663
|
+
webglVendor: null,
|
|
71664
|
+
webglRenderer: null,
|
|
71665
|
+
webglUnmaskedVendor: null,
|
|
71666
|
+
webglUnmaskedRenderer: null
|
|
71667
|
+
};
|
|
71668
|
+
if (gl === null) {
|
|
71669
|
+
return runtimeInfo;
|
|
71670
|
+
}
|
|
71671
|
+
runtimeInfo.webglVendor = String(gl.getParameter(gl.VENDOR));
|
|
71672
|
+
runtimeInfo.webglRenderer = String(gl.getParameter(gl.RENDERER));
|
|
71673
|
+
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
|
|
71674
|
+
if (debugInfo !== null) {
|
|
71675
|
+
runtimeInfo.webglUnmaskedVendor = String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL));
|
|
71676
|
+
runtimeInfo.webglUnmaskedRenderer = String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL));
|
|
71677
|
+
}
|
|
71678
|
+
return runtimeInfo;
|
|
71679
|
+
}
|
|
71110
71680
|
function touchRenderHeartbeat(stage) {
|
|
71111
71681
|
window.__RENDER_HEARTBEAT__ = Date.now();
|
|
71112
71682
|
if (stage !== void 0) {
|
|
@@ -71220,7 +71790,7 @@ function seekVideoOnce(video, timeSec, timeoutMs) {
|
|
|
71220
71790
|
`Video seek timed out at ${timeSec}s after ${timeoutMs}ms (readyState=${video.readyState}, seeking=${video.seeking}, currentTime=${video.currentTime.toFixed(4)}, duration=${video.duration}, networkState=${video.networkState}, paused=${video.paused}, ended=${video.ended})`
|
|
71221
71791
|
));
|
|
71222
71792
|
}, timeoutMs);
|
|
71223
|
-
if (
|
|
71793
|
+
if (shouldSkipRedundantSeek(video, timeSec)) {
|
|
71224
71794
|
ensureFrameDecoded(video, timeoutMs).then(() => finish()).catch((error) => {
|
|
71225
71795
|
const message = error instanceof Error ? error.message : String(error);
|
|
71226
71796
|
finish(new Error(`Frame decode failed at ${timeSec}s: ${message}`));
|
|
@@ -71354,6 +71924,8 @@ async function main() {
|
|
|
71354
71924
|
window.__RENDER_AUDIO_STATUS__ = null;
|
|
71355
71925
|
window.__RENDER_HEARTBEAT__ = Date.now();
|
|
71356
71926
|
window.__RENDER_STAGE__ = "initializing";
|
|
71927
|
+
window.__RENDER_METRICS__ = null;
|
|
71928
|
+
window.__RENDER_RUNTIME__ = collectRenderRuntimeInfo();
|
|
71357
71929
|
try {
|
|
71358
71930
|
const config = window.__RENDER_CONFIG__;
|
|
71359
71931
|
if (!config) throw new Error("No __RENDER_CONFIG__ found on window");
|
|
@@ -71363,9 +71935,17 @@ async function main() {
|
|
|
71363
71935
|
width,
|
|
71364
71936
|
height,
|
|
71365
71937
|
fps,
|
|
71938
|
+
sourceFrameRate,
|
|
71939
|
+
recipeFrameRate,
|
|
71940
|
+
frameRateSource,
|
|
71366
71941
|
bitrate = 5e6,
|
|
71367
|
-
audioBitrate = 128e3
|
|
71942
|
+
audioBitrate = 128e3,
|
|
71943
|
+
captureMode,
|
|
71944
|
+
capturePlaybackRate,
|
|
71945
|
+
maxFrameGap,
|
|
71946
|
+
frameSocketUrl
|
|
71368
71947
|
} = config;
|
|
71948
|
+
const encoderMode = resolveEncoderMode(config);
|
|
71369
71949
|
const trimStart = resolveTrimStart(recipe);
|
|
71370
71950
|
const video = document.getElementById("source-video");
|
|
71371
71951
|
touchRenderHeartbeat("loading-source");
|
|
@@ -71377,9 +71957,45 @@ async function main() {
|
|
|
71377
71957
|
const trimEnd = resolveTrimEnd(recipe, videoDuration);
|
|
71378
71958
|
const exportDuration = Math.max(0, trimEnd - trimStart);
|
|
71379
71959
|
const totalFrames = Math.max(1, Math.ceil(exportDuration * fps));
|
|
71380
|
-
const
|
|
71960
|
+
const keyFrameIntervalFrames = Math.max(1, Math.round(fps * 2));
|
|
71961
|
+
const captureModeResolution = resolveRenderCaptureMode({
|
|
71962
|
+
requestedMode: captureMode,
|
|
71963
|
+
recipe,
|
|
71964
|
+
trimStart,
|
|
71965
|
+
trimEnd,
|
|
71966
|
+
fps,
|
|
71967
|
+
supportsRequestVideoFrameCallback: typeof video.requestVideoFrameCallback === "function"
|
|
71968
|
+
});
|
|
71969
|
+
const metrics = createRenderMetrics({
|
|
71970
|
+
fps,
|
|
71971
|
+
totalFrames,
|
|
71972
|
+
requestedCaptureMode: captureModeResolution.requestedMode,
|
|
71973
|
+
captureMode: captureModeResolution.effectiveMode,
|
|
71974
|
+
encoderMode,
|
|
71975
|
+
fallbackReason: captureModeResolution.fallbackReason
|
|
71976
|
+
});
|
|
71977
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
71978
|
+
console.log("[Render] Runtime resolved", window.__RENDER_RUNTIME__);
|
|
71979
|
+
console.log("[Render] Timing resolved", {
|
|
71980
|
+
fps,
|
|
71981
|
+
frameRateSource: frameRateSource ?? "unknown",
|
|
71982
|
+
sourceFrameRate: sourceFrameRate ?? null,
|
|
71983
|
+
recipeFrameRate: recipeFrameRate ?? null,
|
|
71984
|
+
trimStart,
|
|
71985
|
+
trimEnd,
|
|
71986
|
+
exportDuration,
|
|
71987
|
+
totalFrames,
|
|
71988
|
+
keyFrameIntervalFrames,
|
|
71989
|
+
encoderMode,
|
|
71990
|
+
requestedCaptureMode: captureModeResolution.requestedMode,
|
|
71991
|
+
captureMode: captureModeResolution.effectiveMode,
|
|
71992
|
+
captureFallbackReason: captureModeResolution.fallbackReason
|
|
71993
|
+
});
|
|
71994
|
+
const hasAudioEncoder = encoderMode === "webcodecs" && typeof AudioEncoder !== "undefined";
|
|
71381
71995
|
console.log("[RenderAudio] AudioEncoder available:", hasAudioEncoder);
|
|
71382
|
-
if (
|
|
71996
|
+
if (encoderMode === "ffmpeg") {
|
|
71997
|
+
window.__RENDER_AUDIO_STATUS__ = "external:ffmpeg";
|
|
71998
|
+
} else if (!hasAudioEncoder) {
|
|
71383
71999
|
window.__RENDER_AUDIO_STATUS__ = "skipped:AudioEncoder-unavailable";
|
|
71384
72000
|
}
|
|
71385
72001
|
const audioPromise = hasAudioEncoder ? decodeAudioFromSource(sourceUrl) : Promise.resolve(null);
|
|
@@ -71445,171 +72061,395 @@ async function main() {
|
|
|
71445
72061
|
}
|
|
71446
72062
|
sprite.filters = filterInstances;
|
|
71447
72063
|
const audioBuffer = await audioPromise;
|
|
71448
|
-
const hasAudio = !!audioBuffer && audioBuffer.numberOfChannels > 0 && audioBuffer.length > 0;
|
|
72064
|
+
const hasAudio = encoderMode === "webcodecs" && !!audioBuffer && audioBuffer.numberOfChannels > 0 && audioBuffer.length > 0;
|
|
71449
72065
|
console.log("[RenderAudio] hasAudio:", hasAudio, audioBuffer ? { channels: audioBuffer.numberOfChannels, length: audioBuffer.length } : "null");
|
|
71450
|
-
if (!hasAudio && hasAudioEncoder) {
|
|
72066
|
+
if (encoderMode === "webcodecs" && !hasAudio && hasAudioEncoder) {
|
|
71451
72067
|
window.__RENDER_AUDIO_STATUS__ = "skipped:decode-failed";
|
|
71452
72068
|
}
|
|
71453
|
-
const chunkWriteQueue = createChunkWriteQueue();
|
|
71454
|
-
|
|
71455
|
-
|
|
71456
|
-
|
|
71457
|
-
|
|
71458
|
-
|
|
72069
|
+
const chunkWriteQueue = encoderMode === "webcodecs" ? createChunkWriteQueue() : null;
|
|
72070
|
+
let muxer = null;
|
|
72071
|
+
let encoder = null;
|
|
72072
|
+
let ffmpegFrameSocket = null;
|
|
72073
|
+
const pendingFfmpegFrameAcks = [];
|
|
72074
|
+
let ffmpegFrameAckFailure = null;
|
|
72075
|
+
const trackFfmpegFrameAck = (ackPromise) => {
|
|
72076
|
+
let trackedAck;
|
|
72077
|
+
trackedAck = ackPromise.catch((error) => {
|
|
72078
|
+
if (ffmpegFrameAckFailure === null) {
|
|
72079
|
+
ffmpegFrameAckFailure = error instanceof Error ? error : new Error(String(error));
|
|
71459
72080
|
}
|
|
71460
|
-
})
|
|
71461
|
-
|
|
71462
|
-
|
|
71463
|
-
|
|
71464
|
-
|
|
71465
|
-
|
|
71466
|
-
|
|
71467
|
-
fastStart: false
|
|
71468
|
-
// StreamTarget doesn't support fastStart
|
|
72081
|
+
}).finally(() => {
|
|
72082
|
+
const index = pendingFfmpegFrameAcks.indexOf(trackedAck);
|
|
72083
|
+
if (index >= 0) {
|
|
72084
|
+
pendingFfmpegFrameAcks.splice(index, 1);
|
|
72085
|
+
}
|
|
72086
|
+
});
|
|
72087
|
+
pendingFfmpegFrameAcks.push(trackedAck);
|
|
71469
72088
|
};
|
|
71470
|
-
|
|
71471
|
-
|
|
71472
|
-
|
|
71473
|
-
|
|
71474
|
-
|
|
71475
|
-
|
|
71476
|
-
|
|
71477
|
-
|
|
71478
|
-
|
|
71479
|
-
|
|
71480
|
-
|
|
71481
|
-
|
|
71482
|
-
|
|
71483
|
-
|
|
71484
|
-
|
|
71485
|
-
|
|
71486
|
-
|
|
71487
|
-
|
|
71488
|
-
|
|
71489
|
-
|
|
71490
|
-
|
|
71491
|
-
|
|
71492
|
-
|
|
71493
|
-
|
|
71494
|
-
|
|
72089
|
+
const assertNoFfmpegFrameAckFailure = () => {
|
|
72090
|
+
if (ffmpegFrameAckFailure !== null) {
|
|
72091
|
+
throw ffmpegFrameAckFailure;
|
|
72092
|
+
}
|
|
72093
|
+
};
|
|
72094
|
+
const flushFfmpegFrameAcks = async () => {
|
|
72095
|
+
if (ffmpegFrameSocket !== null) {
|
|
72096
|
+
await ffmpegFrameSocket.flush();
|
|
72097
|
+
}
|
|
72098
|
+
while (pendingFfmpegFrameAcks.length > 0) {
|
|
72099
|
+
const pendingAcks = pendingFfmpegFrameAcks.splice(0);
|
|
72100
|
+
await Promise.all(pendingAcks);
|
|
72101
|
+
assertNoFfmpegFrameAckFailure();
|
|
72102
|
+
}
|
|
72103
|
+
assertNoFfmpegFrameAckFailure();
|
|
72104
|
+
};
|
|
72105
|
+
const encoderFrameRate = Math.max(1, Math.round(fps));
|
|
72106
|
+
if (encoderMode === "ffmpeg") {
|
|
72107
|
+
if (typeof frameSocketUrl !== "string" || frameSocketUrl.trim() === "") {
|
|
72108
|
+
throw new Error("ffmpeg encoder mode requires frameSocketUrl");
|
|
72109
|
+
}
|
|
72110
|
+
ffmpegFrameSocket = createFfmpegFrameSocket(frameSocketUrl);
|
|
72111
|
+
console.log("[RenderVideo] Using ffmpeg frame socket encoder");
|
|
72112
|
+
} else {
|
|
72113
|
+
const muxerOptions = {
|
|
72114
|
+
target: new StreamTarget({
|
|
72115
|
+
onData: (data, position2) => {
|
|
72116
|
+
if (chunkWriteQueue === null) {
|
|
72117
|
+
throw new Error("Chunk write queue unavailable for WebCodecs encoder");
|
|
72118
|
+
}
|
|
72119
|
+
const b64 = toBase64(data);
|
|
72120
|
+
chunkWriteQueue.enqueue(() => window.__writeChunk(b64, position2));
|
|
72121
|
+
}
|
|
72122
|
+
}),
|
|
72123
|
+
video: {
|
|
72124
|
+
codec: "avc",
|
|
72125
|
+
width,
|
|
72126
|
+
height,
|
|
72127
|
+
frameRate: encoderFrameRate
|
|
72128
|
+
},
|
|
72129
|
+
fastStart: false
|
|
72130
|
+
// StreamTarget doesn't support fastStart
|
|
71495
72131
|
};
|
|
71496
|
-
|
|
71497
|
-
|
|
71498
|
-
|
|
71499
|
-
|
|
72132
|
+
if (hasAudio && audioBuffer !== null) {
|
|
72133
|
+
muxerOptions.audio = {
|
|
72134
|
+
codec: "aac",
|
|
72135
|
+
numberOfChannels: audioBuffer.numberOfChannels,
|
|
72136
|
+
sampleRate: audioBuffer.sampleRate
|
|
72137
|
+
};
|
|
71500
72138
|
}
|
|
71501
|
-
|
|
71502
|
-
|
|
71503
|
-
|
|
71504
|
-
|
|
71505
|
-
|
|
71506
|
-
|
|
71507
|
-
|
|
71508
|
-
|
|
71509
|
-
|
|
71510
|
-
|
|
72139
|
+
muxer = new Muxer(muxerOptions);
|
|
72140
|
+
const codecCandidates = [
|
|
72141
|
+
{ codec: "avc1.640028", hw: "prefer-software" },
|
|
72142
|
+
// High Profile Level 4.0 (prefer software in headless)
|
|
72143
|
+
{ codec: "avc1.4d0028", hw: "prefer-software" },
|
|
72144
|
+
// Main Profile Level 4.0
|
|
72145
|
+
{ codec: "avc1.420028", hw: "prefer-software" }
|
|
72146
|
+
// Baseline Profile Level 4.0
|
|
72147
|
+
];
|
|
72148
|
+
let encoderConfig = null;
|
|
72149
|
+
for (const candidate of codecCandidates) {
|
|
72150
|
+
const cfg = {
|
|
72151
|
+
codec: candidate.codec,
|
|
72152
|
+
width,
|
|
72153
|
+
height,
|
|
72154
|
+
bitrate,
|
|
72155
|
+
framerate: encoderFrameRate,
|
|
72156
|
+
hardwareAcceleration: candidate.hw
|
|
72157
|
+
};
|
|
72158
|
+
const support = await VideoEncoder.isConfigSupported(cfg);
|
|
72159
|
+
if (support.supported) {
|
|
72160
|
+
encoderConfig = cfg;
|
|
72161
|
+
break;
|
|
72162
|
+
}
|
|
71511
72163
|
}
|
|
71512
|
-
|
|
71513
|
-
|
|
71514
|
-
for (let frame = 0; frame < totalFrames; frame++) {
|
|
71515
|
-
const timeSec = trimStart + frame / fps;
|
|
71516
|
-
await seekVideo(
|
|
71517
|
-
video,
|
|
71518
|
-
timeSec,
|
|
71519
|
-
config.seekTimeoutMs ?? 4e3,
|
|
71520
|
-
config.seekRetryAttempts ?? 3,
|
|
71521
|
-
fps
|
|
71522
|
-
);
|
|
71523
|
-
await drawCurrentVideoFrameToCanvas(video, sourceCtx, sourceWidth, sourceHeight);
|
|
71524
|
-
videoTexture.source.update();
|
|
71525
|
-
app.render();
|
|
71526
|
-
const timestamp = Math.floor(frame / fps * 1e6);
|
|
71527
|
-
const frameDuration = Math.floor(1e6 / fps);
|
|
71528
|
-
const videoFrame = new VideoFrame(
|
|
71529
|
-
app.canvas,
|
|
71530
|
-
{ timestamp, duration: frameDuration }
|
|
71531
|
-
);
|
|
71532
|
-
const keyFrame = frame % (fps * 2) === 0;
|
|
71533
|
-
encoder.encode(videoFrame, { keyFrame });
|
|
71534
|
-
videoFrame.close();
|
|
71535
|
-
if (encoder.encodeQueueSize > 5) {
|
|
71536
|
-
await new Promise((resolve) => {
|
|
71537
|
-
encoder.addEventListener("dequeue", () => resolve(), { once: true });
|
|
71538
|
-
});
|
|
72164
|
+
if (!encoderConfig) {
|
|
72165
|
+
throw new Error(`No supported VideoEncoder codec found for ${width}x${height}`);
|
|
71539
72166
|
}
|
|
72167
|
+
encoder = new VideoEncoder({
|
|
72168
|
+
output: (chunk, metadata) => {
|
|
72169
|
+
if (muxer === null) {
|
|
72170
|
+
throw new Error("Muxer unavailable for VideoEncoder output");
|
|
72171
|
+
}
|
|
72172
|
+
muxer.addVideoChunk(chunk, metadata);
|
|
72173
|
+
},
|
|
72174
|
+
error: (err) => {
|
|
72175
|
+
throw new Error(`VideoEncoder error: ${err.message}`);
|
|
72176
|
+
}
|
|
72177
|
+
});
|
|
72178
|
+
encoder.configure(encoderConfig);
|
|
72179
|
+
}
|
|
72180
|
+
const rgbaFallbackCanvas = document.createElement("canvas");
|
|
72181
|
+
rgbaFallbackCanvas.width = width;
|
|
72182
|
+
rgbaFallbackCanvas.height = height;
|
|
72183
|
+
const rgbaFallbackCtx = rgbaFallbackCanvas.getContext("2d", { willReadFrequently: true });
|
|
72184
|
+
if (!rgbaFallbackCtx) throw new Error("Failed to create 2D context for ffmpeg frame readback fallback");
|
|
72185
|
+
const seekTimeoutMs = config.seekTimeoutMs ?? 4e3;
|
|
72186
|
+
const seekRetryAttempts = config.seekRetryAttempts ?? 3;
|
|
72187
|
+
const updateVideoProgress = (frame) => {
|
|
71540
72188
|
window.__RENDER_PROGRESS__ = Math.round((frame + 1) / totalFrames * 90);
|
|
71541
|
-
|
|
71542
|
-
}
|
|
71543
|
-
|
|
71544
|
-
|
|
71545
|
-
|
|
71546
|
-
|
|
71547
|
-
|
|
71548
|
-
|
|
71549
|
-
|
|
71550
|
-
|
|
71551
|
-
|
|
71552
|
-
|
|
71553
|
-
|
|
71554
|
-
|
|
71555
|
-
}
|
|
71556
|
-
|
|
71557
|
-
|
|
71558
|
-
|
|
71559
|
-
|
|
71560
|
-
|
|
71561
|
-
|
|
71562
|
-
|
|
71563
|
-
|
|
71564
|
-
|
|
72189
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72190
|
+
};
|
|
72191
|
+
const seekToFrame = async (_frame, timeSec) => {
|
|
72192
|
+
const seekHeartbeat = setInterval(() => {
|
|
72193
|
+
touchRenderHeartbeat("seeking");
|
|
72194
|
+
}, 5e3);
|
|
72195
|
+
try {
|
|
72196
|
+
await measureAsyncMetric(metrics, "seek", () => seekVideo(
|
|
72197
|
+
video,
|
|
72198
|
+
timeSec,
|
|
72199
|
+
seekTimeoutMs,
|
|
72200
|
+
seekRetryAttempts,
|
|
72201
|
+
fps
|
|
72202
|
+
));
|
|
72203
|
+
} finally {
|
|
72204
|
+
clearInterval(seekHeartbeat);
|
|
72205
|
+
}
|
|
72206
|
+
};
|
|
72207
|
+
let lastRgbaFrame = null;
|
|
72208
|
+
const captureFrame = async ({
|
|
72209
|
+
frame,
|
|
72210
|
+
duplicatePreviousFrame
|
|
72211
|
+
}) => {
|
|
72212
|
+
assertNoFfmpegFrameAckFailure();
|
|
72213
|
+
if (!duplicatePreviousFrame) {
|
|
72214
|
+
await measureAsyncMetric(metrics, "sourceSnapshot", () => drawCurrentVideoFrameToCanvas(video, sourceCtx, sourceWidth, sourceHeight));
|
|
72215
|
+
measureSyncMetric(metrics, "textureUpload", () => {
|
|
72216
|
+
videoTexture.source.update();
|
|
72217
|
+
});
|
|
72218
|
+
measureSyncMetric(metrics, "pixiRender", () => {
|
|
72219
|
+
app.render();
|
|
72220
|
+
});
|
|
72221
|
+
} else {
|
|
72222
|
+
metrics.markDuplicateFrame();
|
|
72223
|
+
}
|
|
72224
|
+
const activeFrameSocket = ffmpegFrameSocket;
|
|
72225
|
+
if (activeFrameSocket !== null) {
|
|
72226
|
+
let rgbaFrame;
|
|
72227
|
+
if (duplicatePreviousFrame) {
|
|
72228
|
+
if (lastRgbaFrame === null) {
|
|
72229
|
+
throw new Error("Cannot duplicate ffmpeg frame before a previous frame exists");
|
|
71565
72230
|
}
|
|
72231
|
+
rgbaFrame = lastRgbaFrame;
|
|
72232
|
+
} else {
|
|
72233
|
+
touchRenderHeartbeat("reading-frame");
|
|
72234
|
+
rgbaFrame = await measureAsyncMetric(metrics, "frameReadback", () => copyCanvasToRgba(
|
|
72235
|
+
app.canvas,
|
|
72236
|
+
width,
|
|
72237
|
+
height,
|
|
72238
|
+
rgbaFallbackCanvas,
|
|
72239
|
+
rgbaFallbackCtx
|
|
72240
|
+
));
|
|
72241
|
+
lastRgbaFrame = rgbaFrame;
|
|
72242
|
+
}
|
|
72243
|
+
touchRenderHeartbeat("piping-frame");
|
|
72244
|
+
let markFrameSent = null;
|
|
72245
|
+
const frameSentPromise = new Promise((resolve) => {
|
|
72246
|
+
markFrameSent = resolve;
|
|
71566
72247
|
});
|
|
71567
|
-
|
|
71568
|
-
|
|
71569
|
-
|
|
71570
|
-
|
|
71571
|
-
|
|
71572
|
-
|
|
71573
|
-
|
|
71574
|
-
|
|
71575
|
-
|
|
71576
|
-
|
|
71577
|
-
|
|
72248
|
+
const frameAckPromise = activeFrameSocket.sendFrame(rgbaFrame, frame, () => {
|
|
72249
|
+
markFrameSent == null ? void 0 : markFrameSent();
|
|
72250
|
+
}).catch((error) => {
|
|
72251
|
+
markFrameSent == null ? void 0 : markFrameSent();
|
|
72252
|
+
throw error;
|
|
72253
|
+
});
|
|
72254
|
+
trackFfmpegFrameAck(frameAckPromise);
|
|
72255
|
+
await measureAsyncMetric(metrics, "framePipe", () => frameSentPromise);
|
|
72256
|
+
} else {
|
|
72257
|
+
if (encoder === null) {
|
|
72258
|
+
throw new Error("VideoEncoder unavailable for WebCodecs render path");
|
|
72259
|
+
}
|
|
72260
|
+
measureSyncMetric(metrics, "encode", () => {
|
|
72261
|
+
const timestamp = Math.round(frame / fps * 1e6);
|
|
72262
|
+
const nextTimestamp = Math.round((frame + 1) / fps * 1e6);
|
|
72263
|
+
const frameDuration = Math.max(1, nextTimestamp - timestamp);
|
|
72264
|
+
const videoFrame = new VideoFrame(
|
|
72265
|
+
app.canvas,
|
|
72266
|
+
{ timestamp, duration: frameDuration }
|
|
72267
|
+
);
|
|
72268
|
+
const keyFrame = frame % keyFrameIntervalFrames === 0;
|
|
72269
|
+
encoder.encode(videoFrame, { keyFrame });
|
|
72270
|
+
videoFrame.close();
|
|
72271
|
+
});
|
|
72272
|
+
if (encoder.encodeQueueSize > 5) {
|
|
72273
|
+
await measureAsyncMetric(metrics, "encodeBackpressure", () => new Promise((resolve) => {
|
|
72274
|
+
encoder.addEventListener("dequeue", () => resolve(), { once: true });
|
|
72275
|
+
}));
|
|
72276
|
+
}
|
|
72277
|
+
}
|
|
72278
|
+
metrics.markFrameRendered();
|
|
72279
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72280
|
+
};
|
|
72281
|
+
const runSeekFromFrame = async (startFrame = 0, fallbackReason = captureModeResolution.fallbackReason) => {
|
|
72282
|
+
metrics.setCaptureMode(
|
|
72283
|
+
"seek",
|
|
72284
|
+
fallbackReason
|
|
72285
|
+
);
|
|
72286
|
+
await runSeekCaptureLoop({
|
|
72287
|
+
totalFrames,
|
|
72288
|
+
trimStart,
|
|
72289
|
+
fps,
|
|
72290
|
+
startFrame,
|
|
72291
|
+
seekToFrame,
|
|
72292
|
+
captureFrame,
|
|
72293
|
+
updateProgress: updateVideoProgress,
|
|
72294
|
+
touchHeartbeat: touchRenderHeartbeat
|
|
72295
|
+
});
|
|
72296
|
+
};
|
|
72297
|
+
const effectiveCaptureMode = captureModeResolution.effectiveMode;
|
|
72298
|
+
if (effectiveCaptureMode === "rvfc") {
|
|
72299
|
+
metrics.setCaptureMode("rvfc", null);
|
|
72300
|
+
try {
|
|
72301
|
+
await runPlaybackCaptureLoop({
|
|
72302
|
+
video,
|
|
72303
|
+
totalFrames,
|
|
72304
|
+
trimStart,
|
|
72305
|
+
trimEnd,
|
|
72306
|
+
fps,
|
|
72307
|
+
playbackRate: normalizeCapturePlaybackRate(capturePlaybackRate),
|
|
72308
|
+
maxFrameGap: normalizeMaxFrameGap(maxFrameGap),
|
|
72309
|
+
frameTimeoutMs: seekTimeoutMs,
|
|
72310
|
+
seekToFrame,
|
|
72311
|
+
captureFrame,
|
|
72312
|
+
updateProgress: updateVideoProgress,
|
|
72313
|
+
touchHeartbeat: touchRenderHeartbeat,
|
|
72314
|
+
markPlaybackWait: (durationMs) => {
|
|
72315
|
+
metrics.markDuration("playbackWait", durationMs);
|
|
71578
72316
|
}
|
|
71579
|
-
|
|
71580
|
-
|
|
71581
|
-
|
|
71582
|
-
|
|
71583
|
-
|
|
71584
|
-
|
|
71585
|
-
|
|
72317
|
+
});
|
|
72318
|
+
} catch (error) {
|
|
72319
|
+
if (!(error instanceof PlaybackCaptureFallbackError)) {
|
|
72320
|
+
throw error;
|
|
72321
|
+
}
|
|
72322
|
+
metrics.markPlaybackFallback(error.message);
|
|
72323
|
+
console.warn("[RenderVideo] playback capture fell back to seek", {
|
|
72324
|
+
startFrame: error.startFrame,
|
|
72325
|
+
reason: error.message
|
|
72326
|
+
});
|
|
72327
|
+
touchRenderHeartbeat("fallback-seek");
|
|
72328
|
+
await runSeekFromFrame(error.startFrame, error.message);
|
|
72329
|
+
}
|
|
72330
|
+
} else {
|
|
72331
|
+
await runSeekFromFrame(0);
|
|
72332
|
+
}
|
|
72333
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72334
|
+
if (encoderMode === "ffmpeg") {
|
|
72335
|
+
const backpressureHeartbeat = window.setInterval(() => {
|
|
72336
|
+
touchRenderHeartbeat("ffmpeg-backpressure");
|
|
72337
|
+
}, 5e3);
|
|
72338
|
+
try {
|
|
72339
|
+
await measureAsyncMetric(metrics, "framePipeBackpressure", () => flushFfmpegFrameAcks());
|
|
72340
|
+
} finally {
|
|
72341
|
+
window.clearInterval(backpressureHeartbeat);
|
|
72342
|
+
}
|
|
72343
|
+
ffmpegFrameSocket == null ? void 0 : ffmpegFrameSocket.close();
|
|
72344
|
+
window.__RENDER_PROGRESS__ = 99;
|
|
72345
|
+
window.__RENDER_STATUS__ = "done";
|
|
72346
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72347
|
+
touchRenderHeartbeat("waiting-ffmpeg-finalize");
|
|
72348
|
+
} else {
|
|
72349
|
+
if (encoder === null || muxer === null || chunkWriteQueue === null) {
|
|
72350
|
+
throw new Error("WebCodecs encoder resources were not initialized");
|
|
72351
|
+
}
|
|
72352
|
+
touchRenderHeartbeat("flushing-video");
|
|
72353
|
+
const videoFlushHeartbeat = setInterval(() => {
|
|
72354
|
+
touchRenderHeartbeat("flushing-video");
|
|
72355
|
+
}, 5e3);
|
|
72356
|
+
try {
|
|
72357
|
+
await measureAsyncMetric(metrics, "videoFlush", () => encoder.flush());
|
|
72358
|
+
} finally {
|
|
72359
|
+
clearInterval(videoFlushHeartbeat);
|
|
72360
|
+
}
|
|
72361
|
+
encoder.close();
|
|
72362
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72363
|
+
if (hasAudio && audioBuffer !== null) {
|
|
72364
|
+
window.__RENDER_PROGRESS__ = 91;
|
|
72365
|
+
touchRenderHeartbeat("encoding-audio");
|
|
72366
|
+
const trimmed = trimAudioBuffer(audioBuffer, trimStart, trimEnd);
|
|
72367
|
+
const audioEncoderConfig = {
|
|
72368
|
+
codec: "mp4a.40.2",
|
|
72369
|
+
numberOfChannels: trimmed.numberOfChannels,
|
|
72370
|
+
sampleRate: trimmed.sampleRate,
|
|
72371
|
+
bitrate: audioBitrate
|
|
72372
|
+
};
|
|
72373
|
+
const audioSupport = await AudioEncoder.isConfigSupported(audioEncoderConfig);
|
|
72374
|
+
console.log("[RenderAudio] AAC encoder supported:", audioSupport.supported);
|
|
72375
|
+
if (audioSupport.supported) {
|
|
72376
|
+
const activeMuxer = muxer;
|
|
72377
|
+
const audioEncoder = new AudioEncoder({
|
|
72378
|
+
output: (chunk, metadata) => {
|
|
72379
|
+
activeMuxer.addAudioChunk(chunk, metadata);
|
|
72380
|
+
},
|
|
72381
|
+
error: (err) => {
|
|
72382
|
+
throw new Error(`AudioEncoder error: ${err.message}`);
|
|
72383
|
+
}
|
|
71586
72384
|
});
|
|
71587
|
-
audioEncoder.
|
|
71588
|
-
|
|
71589
|
-
|
|
71590
|
-
|
|
71591
|
-
|
|
72385
|
+
audioEncoder.configure(audioEncoderConfig);
|
|
72386
|
+
const chunkSize = 1024;
|
|
72387
|
+
const firstChannel = trimmed.channels[0];
|
|
72388
|
+
if (firstChannel === void 0) {
|
|
72389
|
+
throw new Error("Decoded audio has no channel data");
|
|
72390
|
+
}
|
|
72391
|
+
const totalSamples = firstChannel.length;
|
|
72392
|
+
for (let offset = 0; offset < totalSamples; offset += chunkSize) {
|
|
72393
|
+
const frameSamples = Math.min(chunkSize, totalSamples - offset);
|
|
72394
|
+
const planarData = new Float32Array(trimmed.numberOfChannels * frameSamples);
|
|
72395
|
+
for (let ch = 0; ch < trimmed.numberOfChannels; ch++) {
|
|
72396
|
+
const channel = trimmed.channels[ch];
|
|
72397
|
+
if (channel === void 0) {
|
|
72398
|
+
throw new Error(`Decoded audio channel ${ch} is unavailable`);
|
|
72399
|
+
}
|
|
72400
|
+
planarData.set(
|
|
72401
|
+
channel.subarray(offset, offset + frameSamples),
|
|
72402
|
+
ch * frameSamples
|
|
72403
|
+
);
|
|
72404
|
+
}
|
|
72405
|
+
const audioData = new AudioData({
|
|
72406
|
+
format: "f32-planar",
|
|
72407
|
+
sampleRate: trimmed.sampleRate,
|
|
72408
|
+
numberOfFrames: frameSamples,
|
|
72409
|
+
numberOfChannels: trimmed.numberOfChannels,
|
|
72410
|
+
timestamp: Math.floor(offset / trimmed.sampleRate * 1e6),
|
|
72411
|
+
data: planarData
|
|
72412
|
+
});
|
|
72413
|
+
measureSyncMetric(metrics, "audioEncode", () => {
|
|
72414
|
+
audioEncoder.encode(audioData);
|
|
71592
72415
|
});
|
|
72416
|
+
audioData.close();
|
|
72417
|
+
if (audioEncoder.encodeQueueSize > 10) {
|
|
72418
|
+
await measureAsyncMetric(metrics, "audioEncode", () => new Promise((resolve) => {
|
|
72419
|
+
audioEncoder.addEventListener("dequeue", () => resolve(), { once: true });
|
|
72420
|
+
}));
|
|
72421
|
+
}
|
|
72422
|
+
touchRenderHeartbeat("encoding-audio");
|
|
72423
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72424
|
+
}
|
|
72425
|
+
touchRenderHeartbeat("flushing-audio");
|
|
72426
|
+
const audioFlushHeartbeat = setInterval(() => {
|
|
72427
|
+
touchRenderHeartbeat("flushing-audio");
|
|
72428
|
+
}, 5e3);
|
|
72429
|
+
try {
|
|
72430
|
+
await measureAsyncMetric(metrics, "audioFlush", () => audioEncoder.flush());
|
|
72431
|
+
} finally {
|
|
72432
|
+
clearInterval(audioFlushHeartbeat);
|
|
71593
72433
|
}
|
|
71594
|
-
|
|
72434
|
+
audioEncoder.close();
|
|
72435
|
+
console.log("[RenderAudio] Audio encoding complete");
|
|
72436
|
+
window.__RENDER_AUDIO_STATUS__ = "encoded";
|
|
72437
|
+
} else {
|
|
72438
|
+
console.warn("[RenderAudio] AAC not supported, exporting without audio");
|
|
72439
|
+
window.__RENDER_AUDIO_STATUS__ = "skipped:aac-unsupported";
|
|
71595
72440
|
}
|
|
71596
|
-
|
|
71597
|
-
await audioEncoder.flush();
|
|
71598
|
-
audioEncoder.close();
|
|
71599
|
-
console.log("[RenderAudio] Audio encoding complete");
|
|
71600
|
-
window.__RENDER_AUDIO_STATUS__ = "encoded";
|
|
71601
|
-
} else {
|
|
71602
|
-
console.warn("[RenderAudio] AAC not supported, exporting without audio");
|
|
71603
|
-
window.__RENDER_AUDIO_STATUS__ = "skipped:aac-unsupported";
|
|
72441
|
+
window.__RENDER_PROGRESS__ = 99;
|
|
71604
72442
|
}
|
|
71605
|
-
|
|
72443
|
+
touchRenderHeartbeat("finalizing-muxer");
|
|
72444
|
+
measureSyncMetric(metrics, "muxFinalize", () => {
|
|
72445
|
+
muxer.finalize();
|
|
72446
|
+
});
|
|
72447
|
+
await measureAsyncMetric(metrics, "muxFinalize", () => chunkWriteQueue.flush());
|
|
72448
|
+
window.__RENDER_PROGRESS__ = 100;
|
|
72449
|
+
window.__RENDER_STATUS__ = "done";
|
|
72450
|
+
window.__RENDER_METRICS__ = metrics.snapshot();
|
|
72451
|
+
touchRenderHeartbeat("done");
|
|
71606
72452
|
}
|
|
71607
|
-
touchRenderHeartbeat("finalizing-muxer");
|
|
71608
|
-
muxer.finalize();
|
|
71609
|
-
await chunkWriteQueue.flush();
|
|
71610
|
-
window.__RENDER_PROGRESS__ = 100;
|
|
71611
|
-
window.__RENDER_STATUS__ = "done";
|
|
71612
|
-
touchRenderHeartbeat("done");
|
|
71613
72453
|
app.destroy(true);
|
|
71614
72454
|
} catch (err) {
|
|
71615
72455
|
const message = err instanceof Error ? err.message : String(err);
|