fix(export): gate native speed timelines

This commit is contained in:
wiiiii123
2026-05-08 00:39:14 +07:00
parent 6241ad11b9
commit 254783b15f
4 changed files with 189 additions and 17 deletions
+29
View File
@@ -55,6 +55,7 @@ import {
buildNativeVideoAudioMuxArgs,
canCopyAudioCodecIntoMp4,
getExperimentalNvidiaCudaExportSkipReason,
getNativeGpuCompositorStallTimeoutMs,
getNativeStaticLayoutSourceProxyBitrate,
getNvidiaCudaAudioExportSkipReason,
getNvidiaCudaAutoStallTimeoutMs,
@@ -333,6 +334,34 @@ describe("getNvidiaCudaAutoStallTimeoutMs", () => {
});
});
describe("getNativeGpuCompositorStallTimeoutMs", () => {
it("guards Windows GPU compositor stalls by default", () => {
expect(getNativeGpuCompositorStallTimeoutMs()).toBe(120_000);
});
it("allows the Windows GPU stall guard to be disabled or tuned", () => {
const envName = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS";
const originalValue = process.env[envName];
try {
process.env[envName] = "0";
expect(getNativeGpuCompositorStallTimeoutMs()).toBeNull();
process.env[envName] = "5000";
expect(getNativeGpuCompositorStallTimeoutMs()).toBe(10_000);
process.env[envName] = "45000";
expect(getNativeGpuCompositorStallTimeoutMs()).toBe(45_000);
} finally {
if (originalValue === undefined) {
delete process.env[envName];
} else {
process.env[envName] = originalValue;
}
}
});
});
describe("hasNvidiaGpuDeviceInGpuInfo", () => {
it("detects NVIDIA GPUs by vendor id or device strings", () => {
expect(
+123 -6
View File
@@ -48,6 +48,8 @@ const NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXP
const NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY";
const NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS";
const DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS = 120_000;
const NATIVE_GPU_STALL_TIMEOUT_ENV = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS";
const DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS = 120_000;
const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_REFERENCE_PIXEL_RATE = 1920 * 1080 * 30;
const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_1080P30_BITRATE = 24_000_000;
const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE = 80_000_000;
@@ -597,10 +599,15 @@ function isNvidiaCudaTimestampAlignedSummary(summary: NvidiaCudaExportSummary) {
});
}
function shouldPersistNativeExportDiagnostics() {
const rawValue = process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS?.trim().toLowerCase();
return rawValue !== "0" && rawValue !== "off" && rawValue !== "false";
}
function shouldPersistNvidiaCudaExportDiagnostics() {
return (
process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1" ||
process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS === "1"
shouldPersistNativeExportDiagnostics() ||
process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1"
);
}
@@ -698,6 +705,61 @@ async function persistNvidiaCudaExportDiagnostics(params: {
}
}
async function persistWindowsGpuExportDiagnostics(params: {
args: string[];
code: number | null;
elapsedMs: number;
outputPath: string;
signal: NodeJS.Signals | null;
startedAtIso: string;
stderr: string;
stdout: string;
summary: WindowsGpuExportSummary | null;
timedOut: boolean;
}) {
if (!shouldPersistNativeExportDiagnostics()) {
return;
}
const diagnosticsDirectory = path.join(app.getPath("userData"), "native-export-diagnostics");
const filePrefix = `${Date.now()}-windows-d3d11-compositor`;
const manifest = {
backend: "windows-d3d11-compositor",
startedAt: params.startedAtIso,
completedAt: new Date().toISOString(),
elapsedMs: Number(params.elapsedMs.toFixed(2)),
outputPath: params.outputPath,
exitCode: params.code,
signal: params.signal,
timedOut: params.timedOut,
args: params.args,
summary: params.summary,
};
try {
await fs.mkdir(diagnosticsDirectory, { recursive: true });
await Promise.allSettled([
fs.writeFile(
path.join(diagnosticsDirectory, `${filePrefix}.manifest.json`),
`${JSON.stringify(manifest, null, 2)}\n`,
),
fs.writeFile(
path.join(diagnosticsDirectory, `${filePrefix}.stdout.log`),
params.stdout,
),
fs.writeFile(
path.join(diagnosticsDirectory, `${filePrefix}.stderr.log`),
params.stderr,
),
]);
} catch (error) {
console.warn(
"[native-static-layout-export] Failed to persist Windows GPU diagnostics",
error,
);
}
}
export function parseWindowsGpuExportProgressLine(
line: string,
): NativeStaticLayoutExportProgress | null {
@@ -1868,7 +1930,7 @@ function isNvidiaCudaForceVideoOnlyEnabled() {
export function getNvidiaCudaAutoStallTimeoutMs(
autoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive(),
) {
if (!autoCandidateActive) {
if (!autoCandidateActive && !isExplicitNvidiaCudaExportEnabled()) {
return null;
}
@@ -1885,6 +1947,20 @@ export function getNvidiaCudaAutoStallTimeoutMs(
return DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS;
}
export function getNativeGpuCompositorStallTimeoutMs() {
const rawValue = process.env[NATIVE_GPU_STALL_TIMEOUT_ENV]?.trim();
if (rawValue === "0" || rawValue?.toLowerCase() === "off") {
return null;
}
const parsed = Number(rawValue);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(10_000, Math.round(parsed));
}
return DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS;
}
export async function getExperimentalNvidiaCudaExportSkipReason(
options: NativeStaticLayoutExportOptions,
) {
@@ -2787,7 +2863,9 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
const args = buildExperimentalWindowsGpuStaticLayoutArgs(options, outputPath);
const startedAt = getNowMs();
const startedAtIso = new Date().toISOString();
const timeoutMs = Math.max(15 * 60 * 1000, options.durationSec * 1000);
const stallTimeoutMs = getNativeGpuCompositorStallTimeoutMs();
return await new Promise<{
elapsedMs: number;
@@ -2806,17 +2884,39 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
let stdout = "";
let stderr = "";
let stderrLineBuffer = "";
let stallTimedOut = false;
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
child.kill("SIGKILL");
}, timeoutMs);
let stallTimeout: ReturnType<typeof setTimeout> | null = null;
const clearStallTimeout = () => {
if (stallTimeout) {
clearTimeout(stallTimeout);
stallTimeout = null;
}
};
const armStallTimeout = () => {
if (!stallTimeoutMs) {
return;
}
clearStallTimeout();
stallTimeout = setTimeout(() => {
if (settled) return;
stallTimedOut = true;
child.kill("SIGKILL");
}, stallTimeoutMs);
};
armStallTimeout();
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/);
@@ -2846,28 +2946,45 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
session.currentProcess = null;
}
clearTimeout(timeout);
clearStallTimeout();
reject(error);
});
child.once("close", (code, signal) => {
child.once("close", async (code, signal) => {
if (settled) return;
settled = true;
if (session.currentProcess === child) {
session.currentProcess = null;
}
clearTimeout(timeout);
clearStallTimeout();
if (session.terminating) {
reject(new Error("Native static layout export was cancelled"));
return;
}
const elapsedMs = getNowMs() - startedAt;
const summary = parseWindowsGpuExportSummary(stdout);
await persistWindowsGpuExportDiagnostics({
args,
code,
elapsedMs,
outputPath,
signal,
startedAtIso,
stderr,
stdout,
summary,
timedOut: stallTimedOut,
});
if (code !== 0 || !summary?.success) {
const suffix = signal ? ` (signal ${signal})` : "";
reject(
new Error(
stderr.trim() ||
(stallTimedOut && stallTimeoutMs
? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without output`
: stderr.trim()) ||
stdout.trim() ||
`Experimental Windows GPU exporter exited with code ${code ?? "unknown"}${suffix}`,
),
@@ -2876,7 +2993,7 @@ async function runExperimentalWindowsGpuStaticLayoutExport(
}
resolve({
elapsedMs: getNowMs() - startedAt,
elapsedMs,
stdout,
stderr,
summary,
@@ -256,6 +256,24 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
});
});
it("reports video backgrounds before speed timeline gating", () => {
const exporter = createExporter({
wallpaper: "file:///C:/Recordly/background.webm",
speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }],
});
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
59,
),
).toBe("unsupported-background-video");
});
it("materializes uploaded data-url image backgrounds for native static-layout", async () => {
const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]);
const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBytes).toString("base64")}`;
@@ -380,7 +398,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3);
});
it("allows native speed timelines when audio needs offline rendering", () => {
it("skips native speed timelines while compositor validation is pending", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -395,7 +413,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
60,
),
).toBeNull();
).toBe("native-speed-timeline-validation-pending");
});
it("rejects native static-layout when speed edits do not have a native timeline map", () => {
@@ -413,10 +431,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
58,
),
).toBe("unsupported-native-speed-timeline");
).toBe("native-speed-timeline-validation-pending");
});
it("allows speed-only native static-layout when audio and video share filtergraph segments", () => {
it("keeps speed-only projects on the renderer path even when audio and video share filtergraph segments", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -438,10 +456,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
59,
),
).toBeNull();
).toBe("native-speed-timeline-validation-pending");
});
it("allows slow-speed native static-layout when a timeline map is available", () => {
it("keeps slow-speed timelines on the renderer path until native duplication is revalidated", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
];
@@ -463,10 +481,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
63,
),
).toBeNull();
).toBe("native-speed-timeline-validation-pending");
});
it("allows slow-speed native timelines with webcam when audio renders offline", () => {
it("keeps slow-speed webcam timelines on the renderer path while native source-time mapping is pending", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
];
@@ -487,10 +505,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
63,
),
).toBeNull();
).toBe("native-speed-timeline-validation-pending");
});
it("allows native speed timelines with a resolvable webcam source", () => {
it("skips native speed timelines even with a resolvable webcam source", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
];
@@ -518,6 +536,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
videoInfo,
59,
),
).toBeNull();
).toBe("native-speed-timeline-validation-pending");
});
});
+8
View File
@@ -1237,6 +1237,14 @@ export class ModernVideoExporter {
}
const speedRegions = this.config.speedRegions ?? [];
const configuredWallpaper = this.config.wallpaper?.trim() ?? "";
if (isVideoWallpaperSource(configuredWallpaper)) {
return "unsupported-background-video";
}
if (speedRegions.length > 0) {
return "native-speed-timeline-validation-pending";
}
const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0;
const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap(
videoInfo,