@codingfactory/mediables-vue 2.7.2 → 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.
Files changed (52) hide show
  1. package/dist/{PixiFrameExporter-n220y4aZ.cjs → PixiFrameExporter-COcgeYmj.cjs} +2 -2
  2. package/dist/{PixiFrameExporter-n220y4aZ.cjs.map → PixiFrameExporter-COcgeYmj.cjs.map} +1 -1
  3. package/dist/{PixiFrameExporter-C5RSaXvT.js → PixiFrameExporter-SiG3q5-_.js} +2 -2
  4. package/dist/{PixiFrameExporter-C5RSaXvT.js.map → PixiFrameExporter-SiG3q5-_.js.map} +1 -1
  5. package/dist/components/video/ui-v2/AssetRail.vue.d.ts +2 -0
  6. package/dist/components/video/ui-v2/AssetRailItem.vue.d.ts +7 -0
  7. package/dist/components/video/ui-v2/AudioMixerPanel.vue.d.ts +33 -0
  8. package/dist/components/video/ui-v2/CaptionSrtPanel.vue.d.ts +9 -0
  9. package/dist/components/video/ui-v2/DesktopInspectorSections.vue.d.ts +21 -0
  10. package/dist/components/video/ui-v2/DraftRecoveryBanner.vue.d.ts +12 -0
  11. package/dist/components/video/ui-v2/KeyframePresetPanel.vue.d.ts +26 -0
  12. package/dist/components/video/ui-v2/MobileClipSummary.vue.d.ts +4 -0
  13. package/dist/components/video/ui-v2/MobileToolPicker.vue.d.ts +3 -0
  14. package/dist/components/video/ui-v2/SplitPanelLayout.vue.d.ts +9 -5
  15. package/dist/components/video/ui-v2/TimelineTrackHeader.vue.d.ts +2 -0
  16. package/dist/composables/useClientVideoExport.d.ts +27 -0
  17. package/dist/composables/useLiveStream.d.ts +2 -2
  18. package/dist/composables/useRadialMenu.d.ts +1 -1
  19. package/dist/composables/useVideoEditor.d.ts +119 -7
  20. package/dist/composables/useVideoFilters.d.ts +4 -4
  21. package/dist/index-B6oyn6Pa.cjs +350 -0
  22. package/dist/index-B6oyn6Pa.cjs.map +1 -0
  23. package/dist/index-BGexNz7s.js +32993 -0
  24. package/dist/index-BGexNz7s.js.map +1 -0
  25. package/dist/mediables-vue.cjs +1 -1
  26. package/dist/mediables-vue.mjs +1 -1
  27. package/dist/render-page/assets/{index-aDRQNAjC.js → index-jZGmiMRr.js} +972 -159
  28. package/dist/render-page/index.html +1 -1
  29. package/dist/services/VideoJobClient.d.ts +1 -1
  30. package/dist/style.css +1 -1
  31. package/dist/types/api.d.ts +14 -0
  32. package/dist/types/video.d.ts +164 -5
  33. package/dist/video/project/audioMixerSchema.d.ts +152 -0
  34. package/dist/video/project/captionSrt.d.ts +23 -0
  35. package/dist/video/project/draftRecovery.d.ts +86 -0
  36. package/dist/video/project/exportPresets.d.ts +172 -0
  37. package/dist/video/project/index.d.ts +22 -0
  38. package/dist/video/project/keyframeAutomation.d.ts +91 -0
  39. package/dist/video/project/mediaSourceAnalysis.d.ts +127 -0
  40. package/dist/video/project/mediaSourceCache.d.ts +47 -0
  41. package/dist/video/project/recipeMigration.d.ts +26 -0
  42. package/dist/video/project/timelineSelection.d.ts +23 -0
  43. package/dist/video/project/timelineTransactions.d.ts +129 -0
  44. package/dist/video/project/visualLayerSchema.d.ts +238 -0
  45. package/dist/video-engine/adapters/AudioManager.d.ts +9 -0
  46. package/dist/video-engine/adapters/MediablesCompositionAdapter.d.ts +3 -0
  47. package/dist/video-engine/adapters/TextOverlayManager.d.ts +4 -2
  48. package/package.json +1 -1
  49. package/dist/index-B_7DfcKr.js +0 -29339
  50. package/dist/index-B_7DfcKr.js.map +0 -1
  51. package/dist/index-Dx7DOxwK.cjs +0 -342
  52. package/dist/index-Dx7DOxwK.cjs.map +0 -1
@@ -71050,6 +71050,548 @@ function resetTime(video) {
71050
71050
  } catch {
71051
71051
  }
71052
71052
  }
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
+ }
71053
71595
  const PENDING_SEEK_DRAIN_TIMEOUT_MS = 1e3;
71054
71596
  function resolveFitMode(recipe) {
71055
71597
  var _a;
@@ -71110,6 +71652,31 @@ function waitForMilliseconds(milliseconds) {
71110
71652
  window.setTimeout(resolve, milliseconds);
71111
71653
  });
71112
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
+ }
71113
71680
  function touchRenderHeartbeat(stage) {
71114
71681
  window.__RENDER_HEARTBEAT__ = Date.now();
71115
71682
  if (stage !== void 0) {
@@ -71357,6 +71924,8 @@ async function main() {
71357
71924
  window.__RENDER_AUDIO_STATUS__ = null;
71358
71925
  window.__RENDER_HEARTBEAT__ = Date.now();
71359
71926
  window.__RENDER_STAGE__ = "initializing";
71927
+ window.__RENDER_METRICS__ = null;
71928
+ window.__RENDER_RUNTIME__ = collectRenderRuntimeInfo();
71360
71929
  try {
71361
71930
  const config = window.__RENDER_CONFIG__;
71362
71931
  if (!config) throw new Error("No __RENDER_CONFIG__ found on window");
@@ -71370,8 +71939,13 @@ async function main() {
71370
71939
  recipeFrameRate,
71371
71940
  frameRateSource,
71372
71941
  bitrate = 5e6,
71373
- audioBitrate = 128e3
71942
+ audioBitrate = 128e3,
71943
+ captureMode,
71944
+ capturePlaybackRate,
71945
+ maxFrameGap,
71946
+ frameSocketUrl
71374
71947
  } = config;
71948
+ const encoderMode = resolveEncoderMode(config);
71375
71949
  const trimStart = resolveTrimStart(recipe);
71376
71950
  const video = document.getElementById("source-video");
71377
71951
  touchRenderHeartbeat("loading-source");
@@ -71384,6 +71958,24 @@ async function main() {
71384
71958
  const exportDuration = Math.max(0, trimEnd - trimStart);
71385
71959
  const totalFrames = Math.max(1, Math.ceil(exportDuration * fps));
71386
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__);
71387
71979
  console.log("[Render] Timing resolved", {
71388
71980
  fps,
71389
71981
  frameRateSource: frameRateSource ?? "unknown",
@@ -71393,11 +71985,17 @@ async function main() {
71393
71985
  trimEnd,
71394
71986
  exportDuration,
71395
71987
  totalFrames,
71396
- keyFrameIntervalFrames
71988
+ keyFrameIntervalFrames,
71989
+ encoderMode,
71990
+ requestedCaptureMode: captureModeResolution.requestedMode,
71991
+ captureMode: captureModeResolution.effectiveMode,
71992
+ captureFallbackReason: captureModeResolution.fallbackReason
71397
71993
  });
71398
- const hasAudioEncoder = typeof AudioEncoder !== "undefined";
71994
+ const hasAudioEncoder = encoderMode === "webcodecs" && typeof AudioEncoder !== "undefined";
71399
71995
  console.log("[RenderAudio] AudioEncoder available:", hasAudioEncoder);
71400
- if (!hasAudioEncoder) {
71996
+ if (encoderMode === "ffmpeg") {
71997
+ window.__RENDER_AUDIO_STATUS__ = "external:ffmpeg";
71998
+ } else if (!hasAudioEncoder) {
71401
71999
  window.__RENDER_AUDIO_STATUS__ = "skipped:AudioEncoder-unavailable";
71402
72000
  }
71403
72001
  const audioPromise = hasAudioEncoder ? decodeAudioFromSource(sourceUrl) : Promise.resolve(null);
@@ -71463,180 +72061,395 @@ async function main() {
71463
72061
  }
71464
72062
  sprite.filters = filterInstances;
71465
72063
  const audioBuffer = await audioPromise;
71466
- const hasAudio = !!audioBuffer && audioBuffer.numberOfChannels > 0 && audioBuffer.length > 0;
72064
+ const hasAudio = encoderMode === "webcodecs" && !!audioBuffer && audioBuffer.numberOfChannels > 0 && audioBuffer.length > 0;
71467
72065
  console.log("[RenderAudio] hasAudio:", hasAudio, audioBuffer ? { channels: audioBuffer.numberOfChannels, length: audioBuffer.length } : "null");
71468
- if (!hasAudio && hasAudioEncoder) {
72066
+ if (encoderMode === "webcodecs" && !hasAudio && hasAudioEncoder) {
71469
72067
  window.__RENDER_AUDIO_STATUS__ = "skipped:decode-failed";
71470
72068
  }
71471
- const chunkWriteQueue = createChunkWriteQueue();
71472
- const muxerOptions = {
71473
- target: new StreamTarget({
71474
- onData: (data, position2) => {
71475
- const b64 = toBase64(data);
71476
- chunkWriteQueue.enqueue(() => window.__writeChunk(b64, position2));
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));
71477
72080
  }
71478
- }),
71479
- video: {
71480
- codec: "avc",
71481
- width,
71482
- height,
71483
- frameRate: fps
71484
- },
71485
- fastStart: false
71486
- // 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);
71487
72088
  };
71488
- if (hasAudio) {
71489
- muxerOptions.audio = {
71490
- codec: "aac",
71491
- numberOfChannels: audioBuffer.numberOfChannels,
71492
- sampleRate: audioBuffer.sampleRate
71493
- };
71494
- }
71495
- const muxer = new Muxer(muxerOptions);
71496
- const codecCandidates = [
71497
- { codec: "avc1.640028", hw: "prefer-software" },
71498
- // High Profile Level 4.0 (prefer software in headless)
71499
- { codec: "avc1.4d0028", hw: "prefer-software" },
71500
- // Main Profile Level 4.0
71501
- { codec: "avc1.420028", hw: "prefer-software" }
71502
- // Baseline Profile Level 4.0
71503
- ];
71504
- let encoderConfig = null;
71505
- for (const candidate of codecCandidates) {
71506
- const cfg = {
71507
- codec: candidate.codec,
71508
- width,
71509
- height,
71510
- bitrate,
71511
- framerate: fps,
71512
- hardwareAcceleration: candidate.hw
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
71513
72131
  };
71514
- const support = await VideoEncoder.isConfigSupported(cfg);
71515
- if (support.supported) {
71516
- encoderConfig = cfg;
71517
- break;
72132
+ if (hasAudio && audioBuffer !== null) {
72133
+ muxerOptions.audio = {
72134
+ codec: "aac",
72135
+ numberOfChannels: audioBuffer.numberOfChannels,
72136
+ sampleRate: audioBuffer.sampleRate
72137
+ };
71518
72138
  }
71519
- }
71520
- if (!encoderConfig) {
71521
- throw new Error(`No supported VideoEncoder codec found for ${width}x${height}`);
71522
- }
71523
- const encoder = new VideoEncoder({
71524
- output: (chunk, metadata) => {
71525
- muxer.addVideoChunk(chunk, metadata);
71526
- },
71527
- error: (err) => {
71528
- throw new Error(`VideoEncoder error: ${err.message}`);
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
+ }
71529
72163
  }
71530
- });
71531
- encoder.configure(encoderConfig);
71532
- touchRenderHeartbeat("priming-first-frame");
71533
- await seekVideo(
71534
- video,
71535
- trimStart,
71536
- config.seekTimeoutMs ?? 4e3,
71537
- config.seekRetryAttempts ?? 3,
71538
- fps
71539
- );
71540
- for (let frame = 0; frame < totalFrames; frame++) {
71541
- const timeSec = trimStart + frame / fps;
71542
- await seekVideo(
71543
- video,
71544
- timeSec,
71545
- config.seekTimeoutMs ?? 4e3,
71546
- config.seekRetryAttempts ?? 3,
71547
- fps
71548
- );
71549
- await drawCurrentVideoFrameToCanvas(video, sourceCtx, sourceWidth, sourceHeight);
71550
- videoTexture.source.update();
71551
- app.render();
71552
- const timestamp = Math.round(frame / fps * 1e6);
71553
- const nextTimestamp = Math.round((frame + 1) / fps * 1e6);
71554
- const frameDuration = Math.max(1, nextTimestamp - timestamp);
71555
- const videoFrame = new VideoFrame(
71556
- app.canvas,
71557
- { timestamp, duration: frameDuration }
71558
- );
71559
- const keyFrame = frame % keyFrameIntervalFrames === 0;
71560
- encoder.encode(videoFrame, { keyFrame });
71561
- videoFrame.close();
71562
- if (encoder.encodeQueueSize > 5) {
71563
- await new Promise((resolve) => {
71564
- encoder.addEventListener("dequeue", () => resolve(), { once: true });
71565
- });
72164
+ if (!encoderConfig) {
72165
+ throw new Error(`No supported VideoEncoder codec found for ${width}x${height}`);
71566
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) => {
71567
72188
  window.__RENDER_PROGRESS__ = Math.round((frame + 1) / totalFrames * 90);
71568
- touchRenderHeartbeat("encoding-video");
71569
- }
71570
- touchRenderHeartbeat("flushing-video");
71571
- await encoder.flush();
71572
- encoder.close();
71573
- if (hasAudio) {
71574
- window.__RENDER_PROGRESS__ = 91;
71575
- touchRenderHeartbeat("encoding-audio");
71576
- const trimmed = trimAudioBuffer(audioBuffer, trimStart, trimEnd);
71577
- const audioEncoderConfig = {
71578
- codec: "mp4a.40.2",
71579
- numberOfChannels: trimmed.numberOfChannels,
71580
- sampleRate: trimmed.sampleRate,
71581
- bitrate: audioBitrate
71582
- };
71583
- const audioSupport = await AudioEncoder.isConfigSupported(audioEncoderConfig);
71584
- console.log("[RenderAudio] AAC encoder supported:", audioSupport.supported);
71585
- if (audioSupport.supported) {
71586
- const audioEncoder = new AudioEncoder({
71587
- output: (chunk, metadata) => {
71588
- muxer.addAudioChunk(chunk, metadata);
71589
- },
71590
- error: (err) => {
71591
- throw new Error(`AudioEncoder error: ${err.message}`);
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");
71592
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;
71593
72247
  });
71594
- audioEncoder.configure(audioEncoderConfig);
71595
- const chunkSize = 1024;
71596
- const totalSamples = trimmed.channels[0].length;
71597
- for (let offset = 0; offset < totalSamples; offset += chunkSize) {
71598
- const frameSamples = Math.min(chunkSize, totalSamples - offset);
71599
- const planarData = new Float32Array(trimmed.numberOfChannels * frameSamples);
71600
- for (let ch = 0; ch < trimmed.numberOfChannels; ch++) {
71601
- planarData.set(
71602
- trimmed.channels[ch].subarray(offset, offset + frameSamples),
71603
- ch * frameSamples
71604
- );
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);
71605
72316
  }
71606
- const audioData = new AudioData({
71607
- format: "f32-planar",
71608
- sampleRate: trimmed.sampleRate,
71609
- numberOfFrames: frameSamples,
71610
- numberOfChannels: trimmed.numberOfChannels,
71611
- timestamp: Math.floor(offset / trimmed.sampleRate * 1e6),
71612
- data: planarData
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
+ }
71613
72384
  });
71614
- audioEncoder.encode(audioData);
71615
- audioData.close();
71616
- if (audioEncoder.encodeQueueSize > 10) {
71617
- await new Promise((resolve) => {
71618
- audioEncoder.addEventListener("dequeue", () => resolve(), { once: true });
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);
71619
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);
71620
72433
  }
71621
- touchRenderHeartbeat("encoding-audio");
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";
71622
72440
  }
71623
- touchRenderHeartbeat("flushing-audio");
71624
- await audioEncoder.flush();
71625
- audioEncoder.close();
71626
- console.log("[RenderAudio] Audio encoding complete");
71627
- window.__RENDER_AUDIO_STATUS__ = "encoded";
71628
- } else {
71629
- console.warn("[RenderAudio] AAC not supported, exporting without audio");
71630
- window.__RENDER_AUDIO_STATUS__ = "skipped:aac-unsupported";
72441
+ window.__RENDER_PROGRESS__ = 99;
71631
72442
  }
71632
- window.__RENDER_PROGRESS__ = 99;
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");
71633
72452
  }
71634
- touchRenderHeartbeat("finalizing-muxer");
71635
- muxer.finalize();
71636
- await chunkWriteQueue.flush();
71637
- window.__RENDER_PROGRESS__ = 100;
71638
- window.__RENDER_STATUS__ = "done";
71639
- touchRenderHeartbeat("done");
71640
72453
  app.destroy(true);
71641
72454
  } catch (err) {
71642
72455
  const message = err instanceof Error ? err.message : String(err);