@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.
Files changed (53) hide show
  1. package/dist/PixiFrameExporter-COcgeYmj.cjs +2 -0
  2. package/dist/{PixiFrameExporter-CqKXaoCX.cjs.map → PixiFrameExporter-COcgeYmj.cjs.map} +1 -1
  3. package/dist/{PixiFrameExporter-BcywK0MP.js → PixiFrameExporter-SiG3q5-_.js} +60 -59
  4. package/dist/{PixiFrameExporter-BcywK0MP.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-y90zwXpc.js → index-jZGmiMRr.js} +991 -151
  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/PixiFrameExporter-CqKXaoCX.cjs +0 -2
  50. package/dist/index-DNo3Kr1t.cjs +0 -342
  51. package/dist/index-DNo3Kr1t.cjs.map +0 -1
  52. package/dist/index-DpkdpuMs.js +0 -29327
  53. 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 HAVE_CURRENT_DATA = 2;
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 (!video.seeking && Math.abs(video.currentTime - timeSec) < 1e-3) {
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 hasAudioEncoder = typeof AudioEncoder !== "undefined";
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 (!hasAudioEncoder) {
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
- const muxerOptions = {
71455
- target: new StreamTarget({
71456
- onData: (data, position2) => {
71457
- const b64 = toBase64(data);
71458
- 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));
71459
72080
  }
71460
- }),
71461
- video: {
71462
- codec: "avc",
71463
- width,
71464
- height,
71465
- frameRate: fps
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
- if (hasAudio) {
71471
- muxerOptions.audio = {
71472
- codec: "aac",
71473
- numberOfChannels: audioBuffer.numberOfChannels,
71474
- sampleRate: audioBuffer.sampleRate
71475
- };
71476
- }
71477
- const muxer = new Muxer(muxerOptions);
71478
- const codecCandidates = [
71479
- { codec: "avc1.640028", hw: "prefer-software" },
71480
- // High Profile Level 4.0 (prefer software in headless)
71481
- { codec: "avc1.4d0028", hw: "prefer-software" },
71482
- // Main Profile Level 4.0
71483
- { codec: "avc1.420028", hw: "prefer-software" }
71484
- // Baseline Profile Level 4.0
71485
- ];
71486
- let encoderConfig = null;
71487
- for (const candidate of codecCandidates) {
71488
- const cfg = {
71489
- codec: candidate.codec,
71490
- width,
71491
- height,
71492
- bitrate,
71493
- framerate: fps,
71494
- 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
71495
72131
  };
71496
- const support = await VideoEncoder.isConfigSupported(cfg);
71497
- if (support.supported) {
71498
- encoderConfig = cfg;
71499
- break;
72132
+ if (hasAudio && audioBuffer !== null) {
72133
+ muxerOptions.audio = {
72134
+ codec: "aac",
72135
+ numberOfChannels: audioBuffer.numberOfChannels,
72136
+ sampleRate: audioBuffer.sampleRate
72137
+ };
71500
72138
  }
71501
- }
71502
- if (!encoderConfig) {
71503
- throw new Error(`No supported VideoEncoder codec found for ${width}x${height}`);
71504
- }
71505
- const encoder = new VideoEncoder({
71506
- output: (chunk, metadata) => {
71507
- muxer.addVideoChunk(chunk, metadata);
71508
- },
71509
- error: (err) => {
71510
- 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
+ }
71511
72163
  }
71512
- });
71513
- encoder.configure(encoderConfig);
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
- touchRenderHeartbeat("encoding-video");
71542
- }
71543
- touchRenderHeartbeat("flushing-video");
71544
- await encoder.flush();
71545
- encoder.close();
71546
- if (hasAudio) {
71547
- window.__RENDER_PROGRESS__ = 91;
71548
- touchRenderHeartbeat("encoding-audio");
71549
- const trimmed = trimAudioBuffer(audioBuffer, trimStart, trimEnd);
71550
- const audioEncoderConfig = {
71551
- codec: "mp4a.40.2",
71552
- numberOfChannels: trimmed.numberOfChannels,
71553
- sampleRate: trimmed.sampleRate,
71554
- bitrate: audioBitrate
71555
- };
71556
- const audioSupport = await AudioEncoder.isConfigSupported(audioEncoderConfig);
71557
- console.log("[RenderAudio] AAC encoder supported:", audioSupport.supported);
71558
- if (audioSupport.supported) {
71559
- const audioEncoder = new AudioEncoder({
71560
- output: (chunk, metadata) => {
71561
- muxer.addAudioChunk(chunk, metadata);
71562
- },
71563
- error: (err) => {
71564
- 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");
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
- audioEncoder.configure(audioEncoderConfig);
71568
- const chunkSize = 1024;
71569
- const totalSamples = trimmed.channels[0].length;
71570
- for (let offset = 0; offset < totalSamples; offset += chunkSize) {
71571
- const frameSamples = Math.min(chunkSize, totalSamples - offset);
71572
- const planarData = new Float32Array(trimmed.numberOfChannels * frameSamples);
71573
- for (let ch = 0; ch < trimmed.numberOfChannels; ch++) {
71574
- planarData.set(
71575
- trimmed.channels[ch].subarray(offset, offset + frameSamples),
71576
- ch * frameSamples
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
- const audioData = new AudioData({
71580
- format: "f32-planar",
71581
- sampleRate: trimmed.sampleRate,
71582
- numberOfFrames: frameSamples,
71583
- numberOfChannels: trimmed.numberOfChannels,
71584
- timestamp: Math.floor(offset / trimmed.sampleRate * 1e6),
71585
- 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
+ }
71586
72384
  });
71587
- audioEncoder.encode(audioData);
71588
- audioData.close();
71589
- if (audioEncoder.encodeQueueSize > 10) {
71590
- await new Promise((resolve) => {
71591
- 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);
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
- 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";
71595
72440
  }
71596
- touchRenderHeartbeat("flushing-audio");
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
- 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");
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);