fix(export): enable native speed timelines

This commit is contained in:
wiiiii123
2026-05-08 02:28:35 +07:00
parent ef1954a24b
commit eab365d115
4 changed files with 238 additions and 31 deletions
+32
View File
@@ -59,6 +59,7 @@ import {
getNativeStaticLayoutSourceProxyBitrate,
getNvidiaCudaAudioExportSkipReason,
getNvidiaCudaAutoStallTimeoutMs,
hasNativeStaticLayoutProgressAdvanced,
hasNvidiaGpuDeviceInGpuInfo,
mapNvidiaCudaWrapperProgressPercentage,
muxExportedVideoAudioBuffer,
@@ -1134,6 +1135,37 @@ describe("mapNvidiaCudaWrapperProgressPercentage", () => {
});
});
describe("hasNativeStaticLayoutProgressAdvanced", () => {
it("treats repeated preparation heartbeats as stalled until real progress arrives", () => {
const previous = { currentFrame: 0, percentage: 2.5 };
expect(
hasNativeStaticLayoutProgressAdvanced(
{ currentFrame: 0, totalFrames: 100, percentage: 2.5, stage: "preparing" },
previous,
),
).toBe(false);
expect(
hasNativeStaticLayoutProgressAdvanced(
{ currentFrame: 0, totalFrames: 100, percentage: 2.7, stage: "preparing" },
previous,
),
).toBe(true);
expect(
hasNativeStaticLayoutProgressAdvanced(
{ currentFrame: 1, totalFrames: 100, percentage: 2.5 },
previous,
),
).toBe(true);
expect(
hasNativeStaticLayoutProgressAdvanced(
{ currentFrame: 100, totalFrames: 100, percentage: 97.25, stage: "finalizing" },
{ currentFrame: 100, percentage: 97.25 },
),
).toBe(true);
});
});
describe("parseNativeVideoMetadataProbeOutput", () => {
it("parses FFmpeg input metadata with video and audio streams", () => {
const metadata = parseNativeVideoMetadataProbeOutput(`
+55 -6
View File
@@ -635,6 +635,7 @@ async function persistNvidiaCudaExportDiagnostics(params: {
stderr: string;
stdout: string;
summary: NvidiaCudaExportSummary | null;
timedOut: boolean;
}) {
if (!shouldPersistNvidiaCudaExportDiagnostics()) {
return;
@@ -651,6 +652,7 @@ async function persistNvidiaCudaExportDiagnostics(params: {
outputPath: params.outputPath,
exitCode: params.code,
signal: params.signal,
timedOut: params.timedOut,
args: params.args,
summary: params.summary,
};
@@ -852,6 +854,24 @@ export function mapNvidiaCudaWrapperProgressPercentage(progress: NativeStaticLay
return progress.percentage;
}
export function hasNativeStaticLayoutProgressAdvanced(
progress: { currentFrame: number; percentage: number; stage?: string },
previous: { currentFrame: number; percentage: number; stage?: string },
) {
const currentFrame = Math.max(0, Math.floor(progress.currentFrame));
const percentage =
typeof progress.percentage === "number" && Number.isFinite(progress.percentage)
? progress.percentage
: 0;
if (currentFrame > previous.currentFrame) {
return true;
}
if (percentage > previous.percentage + 0.1) {
return true;
}
return progress.stage === "finalizing" && previous.stage !== "finalizing";
}
function startNativeStaticLayoutExportPowerGuard() {
try {
const blockerId = powerSaveBlocker.start("prevent-app-suspension");
@@ -2706,6 +2726,11 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
let stderr = "";
let stderrLineBuffer = "";
let lastProgressPercentage = 0;
let lastProgressForStallGuard: {
currentFrame: number;
percentage: number;
stage?: string;
} = { currentFrame: -1, percentage: -1 };
let stallTimedOut = false;
let settled = false;
const timeout = setTimeout(() => {
@@ -2734,11 +2759,9 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
armStallTimeout();
});
child.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString();
armStallTimeout();
stderr += text;
stderrLineBuffer += text;
const lines = stderrLineBuffer.split(/\r?\n/);
@@ -2759,6 +2782,20 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
: undefined;
const mappedPercentage = mapNvidiaCudaWrapperProgressPercentage(progress);
lastProgressPercentage = Math.max(lastProgressPercentage, mappedPercentage);
const progressForStallGuard = {
currentFrame: progress.currentFrame,
percentage: mappedPercentage,
stage: progress.stage,
};
if (
hasNativeStaticLayoutProgressAdvanced(
progressForStallGuard,
lastProgressForStallGuard,
)
) {
lastProgressForStallGuard = progressForStallGuard;
armStallTimeout();
}
onProgress?.({
...progress,
percentage: lastProgressPercentage,
@@ -2825,13 +2862,14 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
stderr,
stdout,
summary,
timedOut: stallTimedOut,
});
if (code !== 0 || !summary?.success) {
const suffix = signal ? ` (signal ${signal})` : "";
reject(
new Error(
(stallTimedOut && stallTimeoutMs
? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without output`
? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without progress`
: stderr.trim()) ||
stdout.trim() ||
`Experimental NVIDIA CUDA exporter exited with code ${code ?? "unknown"}${suffix}`,
@@ -2884,6 +2922,11 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let lastProgressForStallGuard: {
currentFrame: number;
percentage: number;
stage?: string;
} = { currentFrame: -1, percentage: -1 };
let stallTimedOut = false;
let settled = false;
const timeout = setTimeout(() => {
@@ -2912,11 +2955,9 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
armStallTimeout();
});
child.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString();
armStallTimeout();
stderr += text;
stderrLineBuffer += text;
const lines = stderrLineBuffer.split(/\r?\n/);
@@ -2926,6 +2967,14 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
if (!progress) {
continue;
}
if (hasNativeStaticLayoutProgressAdvanced(progress, lastProgressForStallGuard)) {
lastProgressForStallGuard = {
currentFrame: progress.currentFrame,
percentage: progress.percentage,
stage: progress.stage,
};
armStallTimeout();
}
const elapsedMs = Math.max(0, getNowMs() - startedAt);
onProgress?.({
...progress,
@@ -2983,7 +3032,7 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
reject(
new Error(
(stallTimedOut && stallTimeoutMs
? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without output`
? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without progress`
: stderr.trim()) ||
stdout.trim() ||
`Experimental Windows GPU exporter exited with code ${code ?? "unknown"}${suffix}`,
@@ -261,7 +261,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
});
});
it("reports video backgrounds before speed timeline gating", () => {
it("reports video backgrounds while speed can use native timeline maps", () => {
const exporter = createExporter({
wallpaper: "file:///C:/Recordly/background.webm",
speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }],
@@ -303,7 +303,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toEqual([
"odd-output-dimensions",
"unsupported-background-video",
"native-speed-timeline-validation-pending",
"unsupported-annotation-overlay",
"unsupported-caption-overlay",
"unsupported-webcam-source",
@@ -427,6 +426,24 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBe("native-timeline-requires-windows-gpu");
});
it("requires the Windows GPU compositor for speed timelines", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
const exporter = createExporter({ experimentalNativeExport: false, speedRegions });
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
59,
),
).toBe("native-timeline-requires-windows-gpu");
});
it("uses speed timeline duration during native static-layout preflight", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
@@ -436,12 +453,59 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3);
});
it("skips native speed timelines while compositor validation is pending", () => {
it("builds native timeline maps for the editor speed range endpoints", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 2_000, speed: 0.25 },
{ id: "speed-2", startMs: 4_000, endMs: 5_000, speed: 30 },
];
const exporter = createExporter({ speedRegions });
expect(
exporter
.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)
.map((segment) => segment.speed),
).toEqual([1, 0.25, 1, 30, 1]);
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
63.033,
),
).toBeNull();
});
it("allows native speed timelines through the Windows GPU timeline map", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
const exporter = createExporter({ speedRegions });
expect(exporter.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)).toEqual([
{
sourceStartMs: 0,
sourceEndMs: 1_000,
outputStartMs: 0,
outputEndMs: 1_000,
speed: 1,
},
{
sourceStartMs: 1_000,
sourceEndMs: 4_000,
outputStartMs: 1_000,
outputEndMs: 3_000,
speed: 1.5,
},
{
sourceStartMs: 4_000,
sourceEndMs: 60_000,
outputStartMs: 3_000,
outputEndMs: 59_000,
speed: 1,
},
]);
expect(
exporter.getNativeStaticLayoutSkipReason(
{
@@ -451,12 +515,12 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
60,
),
).toBe("native-speed-timeline-validation-pending");
).toBeNull();
});
it("rejects native static-layout when speed edits do not have a native timeline map", () => {
it("rejects native static-layout when speed edits are outside the editor speed range", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 3 },
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 31 },
];
const exporter = createExporter({ speedRegions });
@@ -469,10 +533,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
58,
),
).toBe("native-speed-timeline-validation-pending");
).toBe("unsupported-native-speed-timeline");
});
it("keeps speed-only projects on the renderer path even when audio and video share filtergraph segments", () => {
it("allows speed-only projects when audio and video share filtergraph segments", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -494,10 +558,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
59,
),
).toBe("native-speed-timeline-validation-pending");
).toBeNull();
});
it("keeps slow-speed timelines on the renderer path until native duplication is revalidated", () => {
it("allows slow-speed timelines through native frame duplication", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
];
@@ -519,10 +583,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
63,
),
).toBe("native-speed-timeline-validation-pending");
).toBeNull();
});
it("keeps slow-speed webcam timelines on the renderer path while native source-time mapping is pending", () => {
it("allows slow-speed webcam timelines through native source-time mapping", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
];
@@ -543,10 +607,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
63,
),
).toBe("native-speed-timeline-validation-pending");
).toBeNull();
});
it("skips native speed timelines even with a resolvable webcam source", () => {
it("allows native speed timelines with a resolvable webcam source", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -574,6 +638,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
59,
),
).toBe("native-speed-timeline-validation-pending");
).toBeNull();
});
});
+72 -10
View File
@@ -165,6 +165,8 @@ type NativeAudioPlan =
};
const FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE = 48_000;
const MIN_NATIVE_STATIC_LAYOUT_SPEED = 0.25;
const MAX_NATIVE_STATIC_LAYOUT_SPEED = 30;
type NativeStaticLayoutTimelineSegment = {
sourceStartMs: number;
@@ -174,6 +176,14 @@ type NativeStaticLayoutTimelineSegment = {
speed: number;
};
function canUseNativeStaticLayoutSpeed(speed: number): boolean {
return (
Number.isFinite(speed) &&
speed >= MIN_NATIVE_STATIC_LAYOUT_SPEED &&
speed <= MAX_NATIVE_STATIC_LAYOUT_SPEED
);
}
function buildNativeStaticLayoutTimelineSegments(
segments: Array<{ startMs: number; endMs: number; speed: number }>,
): NativeStaticLayoutTimelineSegment[] {
@@ -1008,11 +1018,63 @@ export class ModernVideoExporter {
}
private buildNativeStaticLayoutSourceSegments(sourceDurationMs: number) {
return buildEditedTrackSourceSegments(
sourceDurationMs,
this.config.trimRegions ?? [],
this.config.speedRegions ?? [],
);
if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) {
return [];
}
const speedRegions = this.config.speedRegions ?? [];
if (
speedRegions.some(
(region) =>
!Number.isFinite(region.startMs) ||
!Number.isFinite(region.endMs) ||
!canUseNativeStaticLayoutSpeed(region.speed),
)
) {
return [];
}
const normalizedSpeedRegions = speedRegions
.map((region) => ({
startMs: Math.max(0, Math.min(region.startMs, sourceDurationMs)),
endMs: Math.max(0, Math.min(region.endMs, sourceDurationMs)),
speed: region.speed,
}))
.filter((region) => region.endMs - region.startMs > 0.5);
const sourceSegments: Array<{ startMs: number; endMs: number; speed: number }> = [];
for (const keptRange of this.buildNativeTrimSegments(sourceDurationMs)) {
const boundaries = new Set<number>([keptRange.startMs, keptRange.endMs]);
for (const speedRegion of normalizedSpeedRegions) {
const startMs = Math.max(keptRange.startMs, speedRegion.startMs);
const endMs = Math.min(keptRange.endMs, speedRegion.endMs);
if (endMs - startMs > 0.5) {
boundaries.add(startMs);
boundaries.add(endMs);
}
}
const orderedBoundaries = [...boundaries].sort((left, right) => left - right);
for (let index = 0; index < orderedBoundaries.length - 1; index += 1) {
const startMs = orderedBoundaries[index] ?? 0;
const endMs = orderedBoundaries[index + 1] ?? 0;
if (endMs - startMs <= 0.5) {
continue;
}
const midpointMs = startMs + (endMs - startMs) / 2;
const speedRegion = normalizedSpeedRegions.find(
(region) => midpointMs >= region.startMs && midpointMs < region.endMs,
);
sourceSegments.push({
startMs,
endMs,
speed: speedRegion?.speed ?? 1,
});
}
}
return sourceSegments;
}
private buildNativeStaticLayoutVideoTimelineSegments(
@@ -1244,9 +1306,6 @@ export class ModernVideoExporter {
if (isVideoWallpaperSource(configuredWallpaper)) {
reasons.push("unsupported-background-video");
}
if (speedRegions.length > 0) {
reasons.push("native-speed-timeline-validation-pending");
}
const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0;
const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap(
@@ -1257,11 +1316,14 @@ export class ModernVideoExporter {
reasons.push("native-timeline-requires-windows-gpu");
}
if (
speedRegions.length === 0 &&
needsTimelineMap &&
this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo).length === 0
) {
reasons.push("unsupported-native-trim-timeline");
reasons.push(
speedRegions.length > 0
? "unsupported-native-speed-timeline"
: "unsupported-native-trim-timeline",
);
}
if (hasZoomRegions && this.config.experimentalNativeExport !== true) {
reasons.push("native-zoom-requires-windows-gpu");