mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
Merge branch 'webadderall:main' into main
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user