Merge branch 'webadderall:main' into main

This commit is contained in:
Ewazer authored and GitHub committed 2026-04-20 13:35:21 +02:00
commit 72eca472a1
4 files changed
+541 -71

No files matched your search

@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import {
advanceFinalizationProgress,
type FinalizationProgressWatchdog,
getExportFinalizationIdleTimeoutMs,
getExportFinalizationTimeoutMs,
INITIAL_FINALIZATION_PROGRESS_STATE,
withFinalizationTimeout,
} from "./finalizationTimeout";
describe("finalizationTimeout", () => {
it("keeps non-audio finalization on the existing 10 minute timeout", () => {
expect(getExportFinalizationTimeoutMs({ workload: "default" })).toBe(600_000);
expect(
getExportFinalizationTimeoutMs({
workload: "default",
effectiveDurationSec: 7_200,
}),
).toBe(600_000);
});
it("gives audio finalization more headroom on longer exports", () => {
expect(
getExportFinalizationTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
}),
).toBe(1_200_000);
expect(
getExportFinalizationTimeoutMs({
workload: "audio",
effectiveDurationSec: 2_700,
}),
).toBe(1_950_000);
});
it("caps adaptive audio timeout growth", () => {
expect(
getExportFinalizationTimeoutMs({
workload: "audio",
effectiveDurationSec: 10_800,
}),
).toBe(2_700_000);
});
it("falls back to the base timeout for invalid audio durations", () => {
expect(
getExportFinalizationTimeoutMs({
workload: "audio",
effectiveDurationSec: 0,
}),
).toBe(600_000);
expect(
getExportFinalizationTimeoutMs({
workload: "audio",
effectiveDurationSec: Number.NaN,
}),
).toBe(600_000);
});
it("derives a bounded idle watchdog window from the total timeout", () => {
expect(
getExportFinalizationIdleTimeoutMs({
workload: "default",
}),
).toBe(150_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
}),
).toBe(300_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 2_700,
}),
).toBe(300_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 0,
}),
).toBe(150_000);
expect(
getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: Number.NaN,
}),
).toBe(150_000);
});
it("rejects when a progress-aware finalization stage stops reporting progress", async () => {
vi.useFakeTimers();
try {
const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
});
const pendingStage = withFinalizationTimeout({
promise: new Promise<never>(() => {}),
stage: "audio processing",
workload: "audio",
effectiveDurationSec: 1_200,
progressAware: true,
});
const rejection = pendingStage.then(
() => null,
(error) => (error instanceof Error ? error.message : String(error)),
);
await vi.advanceTimersByTimeAsync(idleTimeoutMs + 1);
await expect(rejection).resolves.toContain("without observable progress");
} finally {
vi.useRealTimers();
}
});
it("resets the idle watchdog when finalization progress continues", async () => {
vi.useFakeTimers();
try {
const idleTimeoutMs = getExportFinalizationIdleTimeoutMs({
workload: "audio",
effectiveDurationSec: 1_200,
});
let watchdog: FinalizationProgressWatchdog | null = null;
const pendingStage = withFinalizationTimeout({
promise: new Promise<never>(() => {}),
stage: "audio processing",
workload: "audio",
effectiveDurationSec: 1_200,
progressAware: true,
onWatchdogChanged: (nextWatchdog) => {
watchdog = nextWatchdog;
},
});
const rejection = pendingStage.then(
() => null,
(error) => (error instanceof Error ? error.message : String(error)),
);
await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000);
expect(watchdog).not.toBeNull();
watchdog?.refreshProgress();
await vi.advanceTimersByTimeAsync(idleTimeoutMs - 1_000);
const pendingSentinel = Symbol("pending");
await expect(Promise.race([rejection, Promise.resolve(pendingSentinel)])).resolves.toBe(
pendingSentinel,
);
await vi.advanceTimersByTimeAsync(1_001);
await expect(rejection).resolves.toContain("without observable progress");
} finally {
vi.useRealTimers();
}
});
it("only marks finalization as progressed when normalized progress increases", () => {
const initial = advanceFinalizationProgress({
renderProgress: 99,
audioProgress: 0.5,
state: INITIAL_FINALIZATION_PROGRESS_STATE,
});
expect(initial.progressed).toBe(true);
expect(initial.lastRenderProgress).toBe(99);
expect(initial.lastAudioProgress).toBe(0.5);
const repeated = advanceFinalizationProgress({
renderProgress: 99,
audioProgress: 0.5,
state: initial,
});
expect(repeated.progressed).toBe(false);
const advanced = advanceFinalizationProgress({
renderProgress: 100,
audioProgress: 0.5,
state: repeated,
});
expect(advanced.progressed).toBe(true);
expect(advanced.lastRenderProgress).toBe(100);
expect(advanced.lastAudioProgress).toBe(0.5);
});
it("ignores non-finite render progress without poisoning later updates", () => {
const invalid = advanceFinalizationProgress({
renderProgress: Number.NaN,
audioProgress: 0.25,
state: INITIAL_FINALIZATION_PROGRESS_STATE,
});
expect(invalid.progressed).toBe(true);
expect(invalid.lastRenderProgress).toBe(-1);
expect(invalid.lastAudioProgress).toBe(0.25);
const recovered = advanceFinalizationProgress({
renderProgress: 99,
audioProgress: 0.25,
state: invalid,
});
expect(recovered.progressed).toBe(true);
expect(recovered.lastRenderProgress).toBe(99);
expect(recovered.lastAudioProgress).toBe(0.25);
});
});
+181
View File
@@ -0,0 +1,181 @@
export type FinalizationTimeoutWorkload = "default" | "audio";
export type FinalizationProgressWatchdog = {
refreshProgress: () => void;
};
export type FinalizationProgressState = {
lastRenderProgress: number;
lastAudioProgress: number;
};
const BASE_FINALIZATION_TIMEOUT_MS = 10 * 60_000;
const AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS = 500;
const MAX_AUDIO_FINALIZATION_TIMEOUT_MS = 45 * 60_000;
const MIN_PROGRESS_IDLE_TIMEOUT_MS = 90_000;
const MAX_PROGRESS_IDLE_TIMEOUT_MS = 5 * 60_000;
const PROGRESS_IDLE_TIMEOUT_FRACTION = 0.25;
export const INITIAL_FINALIZATION_PROGRESS_STATE: FinalizationProgressState = {
lastRenderProgress: -1,
lastAudioProgress: -1,
};
export function getExportFinalizationTimeoutMs({
effectiveDurationSec,
workload = "default",
}: {
effectiveDurationSec?: number | null;
workload?: FinalizationTimeoutWorkload;
}): number {
if (workload !== "audio") {
return BASE_FINALIZATION_TIMEOUT_MS;
}
const safeEffectiveDurationSec =
typeof effectiveDurationSec === "number" ? effectiveDurationSec : Number.NaN;
if (!Number.isFinite(safeEffectiveDurationSec) || safeEffectiveDurationSec <= 0) {
return BASE_FINALIZATION_TIMEOUT_MS;
}
// Audio finalization work scales with the output timeline, so long exports need
// more headroom without making unrelated finalization hangs wait longer.
const adaptiveTimeoutMs =
BASE_FINALIZATION_TIMEOUT_MS +
safeEffectiveDurationSec * AUDIO_TIMEOUT_HEADROOM_PER_OUTPUT_SECOND_MS;
return Math.min(adaptiveTimeoutMs, MAX_AUDIO_FINALIZATION_TIMEOUT_MS);
}
export function getExportFinalizationIdleTimeoutMs({
effectiveDurationSec,
workload = "default",
}: {
effectiveDurationSec?: number | null;
workload?: FinalizationTimeoutWorkload;
}): number {
const totalTimeoutMs = getExportFinalizationTimeoutMs({
effectiveDurationSec,
workload,
});
return Math.min(
Math.max(
Math.floor(totalTimeoutMs * PROGRESS_IDLE_TIMEOUT_FRACTION),
MIN_PROGRESS_IDLE_TIMEOUT_MS,
),
MAX_PROGRESS_IDLE_TIMEOUT_MS,
);
}
export function advanceFinalizationProgress({
renderProgress,
audioProgress,
state,
}: {
renderProgress: number;
audioProgress?: number;
state: FinalizationProgressState;
}): FinalizationProgressState & { progressed: boolean } {
const normalizedRenderProgress =
typeof renderProgress === "number" && Number.isFinite(renderProgress)
? Math.max(0, Math.min(renderProgress, 100))
: null;
const normalizedAudioProgress =
typeof audioProgress === "number" && Number.isFinite(audioProgress)
? Math.max(0, Math.min(audioProgress, 1))
: null;
const nextRenderProgress =
normalizedRenderProgress === null
? state.lastRenderProgress
: Math.max(state.lastRenderProgress, normalizedRenderProgress);
const nextAudioProgress =
normalizedAudioProgress === null
? state.lastAudioProgress
: Math.max(state.lastAudioProgress, normalizedAudioProgress);
return {
progressed:
nextRenderProgress > state.lastRenderProgress ||
nextAudioProgress > state.lastAudioProgress,
lastRenderProgress: nextRenderProgress,
lastAudioProgress: nextAudioProgress,
};
}
export async function withFinalizationTimeout<T>({
promise,
stage,
effectiveDurationSec,
workload = "default",
progressAware = false,
onWatchdogChanged,
}: {
promise: Promise<T>;
stage: string;
effectiveDurationSec?: number | null;
workload?: FinalizationTimeoutWorkload;
progressAware?: boolean;
onWatchdogChanged?: (watchdog: FinalizationProgressWatchdog | null) => void;
}): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let idleTimeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutMs = getExportFinalizationTimeoutMs({
effectiveDurationSec,
workload,
});
const idleTimeoutMs = progressAware
? getExportFinalizationIdleTimeoutMs({
effectiveDurationSec,
workload,
})
: null;
const hasIdleWatchdog =
progressAware &&
typeof idleTimeoutMs === "number" &&
Number.isFinite(idleTimeoutMs) &&
idleTimeoutMs >= 0;
const resolvedIdleTimeoutMs = hasIdleWatchdog ? idleTimeoutMs : null;
const watchdog: FinalizationProgressWatchdog | null = hasIdleWatchdog
? {
refreshProgress: () => undefined,
}
: null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
const rejectWithMessage = (message: string) => {
reject(new Error(message));
};
if (watchdog && resolvedIdleTimeoutMs !== null) {
const refreshProgress = () => {
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(resolvedIdleTimeoutMs / 1000)} seconds without observable progress`,
);
}, resolvedIdleTimeoutMs);
};
watchdog.refreshProgress = refreshProgress;
onWatchdogChanged?.(watchdog);
refreshProgress();
}
timeoutId = setTimeout(() => {
rejectWithMessage(
`Export timed out during ${stage} after ${Math.ceil(timeoutMs / 60_000)} minutes`,
);
}, timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (idleTimeoutId) {
clearTimeout(idleTimeoutId);
}
onWatchdogChanged?.(null);
}
}
+80 -38
View File
@@ -22,6 +22,13 @@ import {
getWebCodecsEncodeQueueLimit,
getWebCodecsKeyFrameInterval,
} from "./exportTuning";
import {
advanceFinalizationProgress,
type FinalizationProgressWatchdog,
type FinalizationTimeoutWorkload,
INITIAL_FINALIZATION_PROGRESS_STATE,
withFinalizationTimeout,
} from "./finalizationTimeout";
import { FrameRenderer as ModernFrameRenderer } from "./modernFrameRenderer";
import {
getOrderedSupportedMp4EncoderCandidates,
@@ -132,7 +139,7 @@ export class ModernVideoExporter {
private lastNativeExportError: string | null = null;
private nativeH264Encoder: VideoEncoder | null = null;
private nativeEncoderError: Error | null = null;
private readonly FINALIZATION_TIMEOUT_MS = 600_000;
private effectiveDurationSec = 0;
private totalExportStartTimeMs = 0;
private metadataLoadTimeMs = 0;
private rendererInitTimeMs = 0;
@@ -149,6 +156,9 @@ export class ModernVideoExporter {
private nativeWriteTimeMs = 0;
private finalizationTimeMs = 0;
private processedFrameCount = 0;
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
private lastProgressSampleTimeMs = 0;
private lastProgressSampleFrame = 0;
@@ -161,7 +171,7 @@ export class ModernVideoExporter {
this.cleanup();
this.cancelled = false;
this.encoderError = null;
this.nativeEncoderError = null;
this.nativeEncoderError = null;
const backendPreference = this.config.backendPreference ?? "auto";
let useNativeEncoder = false;
this.lastNativeExportError = null;
@@ -256,13 +266,14 @@ export class ModernVideoExporter {
this.metadataLoadTimeMs = this.getNowMs() - stageStartedAt;
const nativeAudioPlan = this.buildNativeAudioPlan(videoInfo);
const shouldUseFfmpegAudioFallback =
!useNativeEncoder
&& nativeAudioPlan.audioMode !== "none"
&& !(await isAacAudioEncodingSupported());
!useNativeEncoder &&
nativeAudioPlan.audioMode !== "none" &&
!(await isAacAudioEncodingSupported());
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
this.effectiveDurationSec = effectiveDuration;
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
stageStartedAt = this.getNowMs();
@@ -439,7 +450,11 @@ export class ModernVideoExporter {
throw this.encoderError;
}
if (nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback && !this.cancelled) {
if (
nativeAudioPlan.audioMode !== "none" &&
!shouldUseFfmpegAudioFallback &&
!this.cancelled
) {
const demuxer = this.streamingDecoder.getDemuxer();
if (
demuxer ||
@@ -463,6 +478,8 @@ export class ModernVideoExporter {
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
}
}
@@ -471,6 +488,9 @@ export class ModernVideoExporter {
const blob = await this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
nativeAudioPlan.audioMode !== "none" && !shouldUseFfmpegAudioFallback
? "audio"
: "default",
);
this.finalizationTimeMs = this.getNowMs() - stageStartedAt;
@@ -628,27 +648,22 @@ export class ModernVideoExporter {
return lines.join("\n");
}
private async awaitWithFinalizationTimeout<T>(promise: Promise<T>, stage: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`Export timed out during ${stage} after ${Math.round(this.FINALIZATION_TIMEOUT_MS / 60_000)} minutes`,
),
);
}, this.FINALIZATION_TIMEOUT_MS);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
private async awaitWithFinalizationTimeout<T>(
promise: Promise<T>,
stage: string,
workload: FinalizationTimeoutWorkload = "default",
progressAware = false,
): Promise<T> {
return withFinalizationTimeout({
promise,
stage,
effectiveDurationSec: this.effectiveDurationSec,
workload,
progressAware,
onWatchdogChanged: (watchdog) => {
this.activeFinalizationProgressWatchdog = watchdog;
},
});
}
private getNativeVideoSourcePath(): string | null {
@@ -782,7 +797,10 @@ export class ModernVideoExporter {
return false;
}
if (typeof VideoEncoder === "undefined" || typeof VideoEncoder.isConfigSupported !== "function") {
if (
typeof VideoEncoder === "undefined" ||
typeof VideoEncoder.isConfigSupported !== "function"
) {
this.lastNativeExportError = `${NATIVE_EXPORT_ENGINE_NAME} export requires WebCodecs VideoEncoder support.`;
return false;
}
@@ -804,8 +822,7 @@ export class ModernVideoExporter {
return false;
}
} catch (error) {
this.lastNativeExportError =
error instanceof Error ? error.message : String(error);
this.lastNativeExportError = error instanceof Error ? error.message : String(error);
console.warn(
`[VideoExporter] ${NATIVE_EXPORT_ENGINE_NAME} encoder support check failed`,
error,
@@ -852,7 +869,8 @@ export class ModernVideoExporter {
.then((writeResult) => {
if (!writeResult.success && !this.cancelled) {
throw new Error(
writeResult.error || "Failed to write H.264 chunk to native encoder",
writeResult.error ||
"Failed to write H.264 chunk to native encoder",
);
}
})
@@ -880,8 +898,7 @@ export class ModernVideoExporter {
try {
encoder.configure(encoderConfig);
} catch (error) {
this.lastNativeExportError =
error instanceof Error ? error.message : String(error);
this.lastNativeExportError = error instanceof Error ? error.message : String(error);
try {
encoder.close();
} catch (closeError) {
@@ -923,8 +940,7 @@ export class ModernVideoExporter {
if (this.nativeEncoderError) throw this.nativeEncoderError;
}
while (
this.nativeH264Encoder.encodeQueueSize >=
ModernVideoExporter.NATIVE_ENCODER_QUEUE_LIMIT
this.nativeH264Encoder.encodeQueueSize >= ModernVideoExporter.NATIVE_ENCODER_QUEUE_LIMIT
) {
await new Promise<void>((r) => setTimeout(r, 2));
if (this.cancelled) return;
@@ -961,13 +977,14 @@ export class ModernVideoExporter {
this.config.sourceAudioFallbackPaths,
),
`${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`,
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
}
const sessionId = this.nativeExportSessionId;
this.nativeExportSessionId = null;
console.log(`[VideoExporter] Finalizing ${NATIVE_EXPORT_ENGINE_NAME} export`, {
sessionId,
audioMode: audioPlan.audioMode,
@@ -989,7 +1006,9 @@ export class ModernVideoExporter {
editedAudioMimeType,
}),
`${NATIVE_EXPORT_ENGINE_NAME} export finalization`,
audioPlan.audioMode === "none" ? "default" : "audio",
);
this.nativeExportSessionId = null;
if (!result.success) {
return {
@@ -1042,6 +1061,8 @@ export class ModernVideoExporter {
this.config.sourceAudioFallbackPaths,
),
"FFmpeg edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
@@ -1055,11 +1076,13 @@ export class ModernVideoExporter {
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments: audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"FFmpeg audio muxing",
"audio",
);
if (!result.success || !result.data) {
@@ -1137,6 +1160,19 @@ export class ModernVideoExporter {
renderProgress: number,
audioProgress?: number,
) {
const nextProgress = advanceFinalizationProgress({
renderProgress,
audioProgress,
state: {
lastRenderProgress: this.lastFinalizationRenderProgress,
lastAudioProgress: this.lastFinalizationAudioProgress,
},
});
if (nextProgress.progressed) {
this.activeFinalizationProgressWatchdog?.refreshProgress();
}
this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress;
this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress;
this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress);
}
@@ -1404,7 +1440,8 @@ export class ModernVideoExporter {
}
} catch (error) {
console.error("Muxing error:", error);
const muxingError = error instanceof Error ? error : new Error(String(error));
const muxingError =
error instanceof Error ? error : new Error(String(error));
if (!this.encoderError) {
this.encoderError = muxingError;
}
@@ -1578,6 +1615,11 @@ export class ModernVideoExporter {
this.nativeWriteTimeMs = 0;
this.finalizationTimeMs = 0;
this.processedFrameCount = 0;
this.activeFinalizationProgressWatchdog = null;
this.lastFinalizationRenderProgress =
INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
this.effectiveDurationSec = 0;
this.lastProgressSampleTimeMs = 0;
this.lastProgressSampleFrame = 0;
this.nativeWritePromises = new Set();
+68 -33
View File
@@ -13,6 +13,13 @@ import type {
ZoomTransitionEasing,
} from "@/components/video-editor/types";
import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder";
import {
advanceFinalizationProgress,
type FinalizationProgressWatchdog,
type FinalizationTimeoutWorkload,
INITIAL_FINALIZATION_PROGRESS_STATE,
withFinalizationTimeout,
} from "./finalizationTimeout";
import { FrameRenderer } from "./frameRenderer";
import type { SupportedMp4EncoderPath } from "./mp4Support";
import { VideoMuxer } from "./muxer";
@@ -95,7 +102,7 @@ export class VideoExporter {
private videoColorSpace: VideoColorSpaceInit | undefined;
private pendingMuxing: Promise<void> = Promise.resolve();
private chunkCount = 0;
private readonly FINALIZATION_TIMEOUT_MS = 600_000;
private effectiveDurationSec = 0;
private exportStartTimeMs = 0;
private progressSampleStartTimeMs = 0;
private progressSampleStartFrame = 0;
@@ -107,6 +114,9 @@ export class VideoExporter {
private nativeWriteError: Error | null = null;
private maxNativeWriteInFlight = 1;
private nativeEncoderError: Error | null = null;
private activeFinalizationProgressWatchdog: FinalizationProgressWatchdog | null = null;
private lastFinalizationRenderProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
private lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
constructor(config: VideoExporterConfig) {
this.config = config;
@@ -142,9 +152,9 @@ export class VideoExporter {
? await this.tryStartNativeVideoExport()
: false;
const shouldUseFfmpegAudioFallback =
!useNativeEncoder
&& audioPlan.audioMode !== "none"
&& !(await isAacAudioEncodingSupported());
!useNativeEncoder &&
audioPlan.audioMode !== "none" &&
!(await isAacAudioEncodingSupported());
if (!useNativeEncoder) {
await this.initializeEncoder();
@@ -211,6 +221,7 @@ export class VideoExporter {
this.config.trimRegions,
this.config.speedRegions,
);
this.effectiveDurationSec = effectiveDuration;
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
@@ -318,6 +329,8 @@ export class VideoExporter {
this.config.sourceAudioFallbackPaths,
),
"audio processing",
"audio",
true,
);
}
}
@@ -327,6 +340,7 @@ export class VideoExporter {
const blob = await this.awaitWithFinalizationTimeout(
this.muxer!.finalize(),
"muxer finalization",
hasAudio && !shouldUseFfmpegAudioFallback ? "audio" : "default",
);
if (shouldUseFfmpegAudioFallback) {
@@ -366,27 +380,22 @@ export class VideoExporter {
);
}
private async awaitWithFinalizationTimeout<T>(promise: Promise<T>, stage: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`Export timed out during ${stage} after ${Math.round(this.FINALIZATION_TIMEOUT_MS / 60_000)} minutes`,
),
);
}, this.FINALIZATION_TIMEOUT_MS);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
private async awaitWithFinalizationTimeout<T>(
promise: Promise<T>,
stage: string,
workload: FinalizationTimeoutWorkload = "default",
progressAware = false,
): Promise<T> {
return withFinalizationTimeout({
promise,
stage,
effectiveDurationSec: this.effectiveDurationSec,
workload,
progressAware,
onWatchdogChanged: (watchdog) => {
this.activeFinalizationProgressWatchdog = watchdog;
},
});
}
private getNativeVideoSourcePath(): string | null {
@@ -578,7 +587,8 @@ export class VideoExporter {
);
if (!writeResult.success && !this.cancelled) {
throw new Error(
writeResult.error || "Failed to write H.264 chunk to native encoder",
writeResult.error ||
"Failed to write H.264 chunk to native encoder",
);
}
})
@@ -603,8 +613,7 @@ export class VideoExporter {
try {
encoder.configure(encoderConfig);
} catch (error) {
this.nativeEncoderError =
error instanceof Error ? error : new Error(String(error));
this.nativeEncoderError = error instanceof Error ? error : new Error(String(error));
try {
encoder.close();
} catch (closeError) {
@@ -644,7 +653,7 @@ export class VideoExporter {
// Apply backpressure: don't queue too far ahead of FFmpeg's stdin pipe
while (
this.nativeH264Encoder.encodeQueueSize >=
Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE))
Math.max(1, Math.floor(this.config.maxEncodeQueue ?? DEFAULT_MAX_ENCODE_QUEUE))
) {
await new Promise<void>((r) => setTimeout(r, 2));
if (this.cancelled) return;
@@ -693,14 +702,14 @@ export class VideoExporter {
this.config.sourceAudioFallbackPaths,
),
"native edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
}
const sessionId = this.nativeExportSessionId;
this.nativeExportSessionId = null;
const result = await this.awaitWithFinalizationTimeout(
window.electronAPI.nativeVideoExportFinish(sessionId, {
audioMode: audioPlan.audioMode,
@@ -714,7 +723,9 @@ export class VideoExporter {
editedAudioMimeType,
}),
"native export finalization",
audioPlan.audioMode === "none" ? "default" : "audio",
);
this.nativeExportSessionId = null;
if (!result.success || !result.data) {
return {
@@ -761,6 +772,8 @@ export class VideoExporter {
this.config.sourceAudioFallbackPaths,
),
"ffmpeg edited audio rendering",
"audio",
true,
);
editedAudioBuffer = await audioBlob.arrayBuffer();
editedAudioMimeType = audioBlob.type || null;
@@ -774,11 +787,13 @@ export class VideoExporter {
audioPlan.audioMode === "copy-source" || audioPlan.audioMode === "trim-source"
? audioPlan.audioSourcePath
: null,
trimSegments: audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
trimSegments:
audioPlan.audioMode === "trim-source" ? audioPlan.trimSegments : undefined,
editedAudioData: editedAudioBuffer,
editedAudioMimeType,
}),
"ffmpeg audio muxing",
"audio",
);
if (!result.success || !result.data) {
@@ -870,6 +885,19 @@ export class VideoExporter {
renderProgress: number,
audioProgress?: number,
) {
const nextProgress = advanceFinalizationProgress({
renderProgress,
audioProgress,
state: {
lastRenderProgress: this.lastFinalizationRenderProgress,
lastAudioProgress: this.lastFinalizationAudioProgress,
},
});
if (nextProgress.progressed) {
this.activeFinalizationProgressWatchdog?.refreshProgress();
}
this.lastFinalizationRenderProgress = nextProgress.lastRenderProgress;
this.lastFinalizationAudioProgress = nextProgress.lastAudioProgress;
this.reportProgress(totalFrames, totalFrames, "finalizing", renderProgress, audioProgress);
}
@@ -984,7 +1012,8 @@ export class VideoExporter {
}
} catch (error) {
console.error("Muxing error:", error);
const muxingError = error instanceof Error ? error : new Error(String(error));
const muxingError =
error instanceof Error ? error : new Error(String(error));
if (!this.encoderError) {
this.encoderError = muxingError;
}
@@ -1122,11 +1151,17 @@ export class VideoExporter {
}
this.muxer = null;
this.audioProcessor?.cancel();
this.audioProcessor = null;
this.activeFinalizationProgressWatchdog = null;
this.lastFinalizationRenderProgress =
INITIAL_FINALIZATION_PROGRESS_STATE.lastRenderProgress;
this.lastFinalizationAudioProgress = INITIAL_FINALIZATION_PROGRESS_STATE.lastAudioProgress;
this.encodeQueue = 0;
this.pendingMuxing = Promise.resolve();
this.nativePendingWrite = Promise.resolve();
this.chunkCount = 0;
this.effectiveDurationSec = 0;
this.encoderError = null;
this.videoDescription = undefined;
this.videoColorSpace = undefined;