mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
fix(export): enable native speed timelines
This commit is contained in:
@@ -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(`
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user