Merge pull request #314 from soufian3hm/fix/windows-recording-validation

Fix invalid Windows recording finalization
This commit is contained in:
webadderall
2026-04-22 17:19:56 +10:00
committed by GitHub
7 changed files with 228 additions and 162 deletions
@@ -132,4 +132,15 @@ describe("getCompanionAudioFallbackPaths", () => {
micPath,
]);
});
it("rejects tiny MP4 container-only outputs before they reach the editor", async () => {
const videoPath = path.join(tempRoot, "recording-123.mp4");
await fs.writeFile(videoPath, Buffer.alloc(261));
const { validateRecordedVideo } = await import("./diagnostics");
await expect(validateRecordedVideo(videoPath)).rejects.toThrow(
"Recorded output is too small to contain playable video",
);
});
});
+8 -1
View File
@@ -7,6 +7,7 @@ import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from ".
import type { CompanionAudioCandidate, NativeCaptureDiagnostics } from "../types";
const execFileAsync = promisify(execFile);
export const MIN_VALID_RECORDED_VIDEO_BYTES = 1024;
export function recordNativeCaptureDiagnostics(
diagnostics: Omit<NativeCaptureDiagnostics, "timestamp">,
@@ -152,6 +153,12 @@ export async function validateRecordedVideo(videoPath: string) {
throw new Error(`Recorded output is empty: ${videoPath}`);
}
if (stat.size < MIN_VALID_RECORDED_VIDEO_BYTES) {
throw new Error(
`Recorded output is too small to contain playable video (${stat.size} bytes): ${videoPath}`,
);
}
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
@@ -173,7 +180,7 @@ export async function validateRecordedVideo(videoPath: string) {
}
const durationSeconds = parseFfmpegDurationSeconds(stderr);
if (durationSeconds !== null && durationSeconds <= 0) {
if (durationSeconds === null || durationSeconds <= 0) {
throw new Error(`Recorded output has an invalid duration: ${videoPath}`);
}
+47 -21
View File
@@ -3,12 +3,12 @@ import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { BrowserWindow } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
getAudioSyncAdjustment,
appendSyncedAudioFilter,
} from "../ffmpeg/filters";
import type { AudioSyncAdjustment } from "../types";
persistPendingCursorTelemetry,
snapshotCursorTelemetryForPersistence,
} from "../cursor/telemetry";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { appendSyncedAudioFilter, getAudioSyncAdjustment } from "../ffmpeg/filters";
import {
nativeScreenRecordingActive,
setNativeScreenRecordingActive,
@@ -27,20 +27,17 @@ import {
setCurrentProjectPath,
selectedSource,
} from "../state";
import { moveFileWithOverwrite, isAutoRecordingPath } from "../utils";
import type { AudioSyncAdjustment } from "../types";
import { isAutoRecordingPath, moveFileWithOverwrite } from "../utils";
import {
recordNativeCaptureDiagnostics,
getFileSizeIfPresent,
validateRecordedVideo,
getUsableCompanionAudioCandidates,
probeMediaDurationSeconds,
recordNativeCaptureDiagnostics,
validateRecordedVideo,
} from "./diagnostics";
import { probeMediaDurationSeconds } from "./diagnostics";
import { emitRecordingInterrupted } from "./events";
import { pruneAutoRecordings } from "./prune";
import {
snapshotCursorTelemetryForPersistence,
persistPendingCursorTelemetry,
} from "../cursor/telemetry";
import { muxNativeWindowsVideoWithAudio } from "./windows";
const execFileAsync = promisify(execFile);
@@ -259,9 +256,11 @@ export async function muxNativeMacRecordingWithAudio(
try {
await execFileAsync(ffmpegPath, args, { timeout: 120000, maxBuffer: 10 * 1024 * 1024 });
await validateRecordedVideo(mixedOutputPath);
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stderr?: string };
console.error("[mux] ffmpeg failed:", execError.stderr || execError.message);
console.error("[mux] failed:", execError.stderr || execError.message || String(error));
await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined);
throw error;
}
@@ -336,11 +335,35 @@ export async function finalizeStoredVideo(videoPath: string) {
}
}
let validation: { fileSizeBytes: number; durationSeconds: number | null } | null = null;
let validation: { fileSizeBytes: number; durationSeconds: number | null };
try {
validation = await validateRecordedVideo(videoPath);
} catch (error) {
console.warn("Video validation failed (proceeding anyway):", error);
if (
lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit" ||
lastNativeCaptureDiagnostics?.backend === "windows-wgc"
) {
recordNativeCaptureDiagnostics({
backend: lastNativeCaptureDiagnostics.backend,
phase: lastNativeCaptureDiagnostics.phase === "mux" ? "mux" : "stop",
sourceId: lastNativeCaptureDiagnostics.sourceId ?? null,
sourceType: lastNativeCaptureDiagnostics.sourceType ?? "unknown",
displayId: lastNativeCaptureDiagnostics.displayId ?? null,
displayBounds: lastNativeCaptureDiagnostics.displayBounds ?? null,
windowHandle: lastNativeCaptureDiagnostics.windowHandle ?? null,
helperPath: lastNativeCaptureDiagnostics.helperPath ?? null,
outputPath: videoPath,
systemAudioPath: lastNativeCaptureDiagnostics.systemAudioPath ?? null,
microphonePath: lastNativeCaptureDiagnostics.microphonePath ?? null,
osRelease: lastNativeCaptureDiagnostics.osRelease,
supported: lastNativeCaptureDiagnostics.supported,
helperExists: lastNativeCaptureDiagnostics.helperExists,
processOutput: lastNativeCaptureDiagnostics.processOutput,
fileSizeBytes: await getFileSizeIfPresent(videoPath),
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
snapshotCursorTelemetryForPersistence();
@@ -351,10 +374,13 @@ export async function finalizeStoredVideo(videoPath: string) {
await pruneAutoRecordings([videoPath]);
}
if (lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit") {
if (
lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit" ||
lastNativeCaptureDiagnostics?.backend === "windows-wgc"
) {
recordNativeCaptureDiagnostics({
backend: "mac-screencapturekit",
phase: "stop",
backend: lastNativeCaptureDiagnostics.backend,
phase: lastNativeCaptureDiagnostics.phase === "mux" ? "mux" : "stop",
sourceId: lastNativeCaptureDiagnostics.sourceId ?? null,
sourceType: lastNativeCaptureDiagnostics.sourceType ?? "unknown",
displayId: lastNativeCaptureDiagnostics.displayId ?? null,
@@ -368,7 +394,7 @@ export async function finalizeStoredVideo(videoPath: string) {
supported: lastNativeCaptureDiagnostics.supported,
helperExists: lastNativeCaptureDiagnostics.helperExists,
processOutput: lastNativeCaptureDiagnostics.processOutput,
fileSizeBytes: validation?.fileSizeBytes ?? null,
fileSizeBytes: validation.fileSizeBytes,
});
}
@@ -376,7 +402,7 @@ export async function finalizeStoredVideo(videoPath: string) {
success: true,
path: videoPath,
message:
validation?.durationSeconds !== null && validation !== null
validation.durationSeconds !== null
? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)`
: `Video stored successfully`,
};
+105 -95
View File
@@ -1,17 +1,17 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { BrowserWindow } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
getAudioSyncAdjustment,
appendSyncedAudioFilter,
normalizePauseSegments,
buildPausedAudioFilter,
getAudioSyncAdjustment,
normalizePauseSegments,
} from "../ffmpeg/filters";
import type { PauseSegment, AudioSyncAdjustment } from "../types";
import { getWindowsCaptureExePath } from "../paths/binaries";
import {
setWindowsCaptureProcess,
windowsCaptureOutputBuffer,
@@ -22,10 +22,10 @@ import {
setWindowsCaptureStopRequested,
selectedSource,
} from "../state";
import type { AudioSyncAdjustment, PauseSegment } from "../types";
import { moveFileWithOverwrite } from "../utils";
import { probeMediaDurationSeconds } from "./diagnostics";
import { probeMediaDurationSeconds, validateRecordedVideo } from "./diagnostics";
import { emitRecordingInterrupted } from "./events";
import { getWindowsCaptureExePath } from "../paths/binaries";
const execFileAsync = promisify(execFile);
@@ -220,100 +220,110 @@ export async function muxNativeWindowsVideoWithAudio(
durationDeltaMs: 0,
};
if (audioInputs.length === 2) {
const filterParts: string[] = [];
const systemPauseFilter = buildPausedAudioFilter(
"1:a",
"system_trimmed",
normalizedPauseSegments,
);
const micPauseFilter = buildPausedAudioFilter(
"2:a",
"mic_trimmed",
normalizedPauseSegments,
);
try {
if (audioInputs.length === 2) {
const filterParts: string[] = [];
const systemPauseFilter = buildPausedAudioFilter(
"1:a",
"system_trimmed",
normalizedPauseSegments,
);
const micPauseFilter = buildPausedAudioFilter(
"2:a",
"mic_trimmed",
normalizedPauseSegments,
);
if (systemPauseFilter) {
filterParts.push(systemPauseFilter);
}
if (micPauseFilter) {
filterParts.push(micPauseFilter);
if (systemPauseFilter) {
filterParts.push(systemPauseFilter);
}
if (micPauseFilter) {
filterParts.push(micPauseFilter);
}
const systemLabel = systemPauseFilter ? "[system_trimmed]" : "[1:a]";
const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]";
appendSyncedAudioFilter(filterParts, systemLabel, "s", systemAdjustment);
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment);
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
await execFileAsync(
ffmpegPath,
[
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
} else {
const pauseFilter = buildPausedAudioFilter(
"1:a",
"trimmed_audio",
normalizedPauseSegments,
);
const singleAdjustment = audioAdjustments.get(audioInputs[0]) ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
// Always route through the filter graph so that aresample=async=1 is
// applied. This corrects progressive clock drift between video and
// audio tracks that a simple duration comparison cannot detect.
const filterParts: string[] = [];
if (pauseFilter) {
filterParts.push(pauseFilter);
}
const srcLabel = pauseFilter ? "[trimmed_audio]" : "[1:a]";
appendSyncedAudioFilter(filterParts, srcLabel, "aout", singleAdjustment);
await execFileAsync(
ffmpegPath,
[
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
}
const systemLabel = systemPauseFilter ? "[system_trimmed]" : "[1:a]";
const micLabel = micPauseFilter ? "[mic_trimmed]" : "[2:a]";
appendSyncedAudioFilter(filterParts, systemLabel, "s", systemAdjustment);
appendSyncedAudioFilter(filterParts, micLabel, "m", micAdjustment);
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
await execFileAsync(
ffmpegPath,
[
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
} else {
const pauseFilter = buildPausedAudioFilter("1:a", "trimmed_audio", normalizedPauseSegments);
const singleAdjustment = audioAdjustments.get(audioInputs[0]) ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
// Always route through the filter graph so that aresample=async=1 is
// applied. This corrects progressive clock drift between video and
// audio tracks that a simple duration comparison cannot detect.
const filterParts: string[] = [];
if (pauseFilter) {
filterParts.push(pauseFilter);
}
const srcLabel = pauseFilter ? "[trimmed_audio]" : "[1:a]";
appendSyncedAudioFilter(filterParts, srcLabel, "aout", singleAdjustment);
await execFileAsync(
ffmpegPath,
[
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
await validateRecordedVideo(mixedOutputPath);
await moveFileWithOverwrite(mixedOutputPath, videoPath);
} catch (error) {
await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined);
throw error;
}
await moveFileWithOverwrite(mixedOutputPath, videoPath);
for (const audioPath of [systemAudioPath, micAudioPath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => undefined);
+41 -38
View File
@@ -84,6 +84,7 @@ import {
recordNativeCaptureDiagnostics,
getFileSizeIfPresent,
getCompanionAudioFallbackPaths,
validateRecordedVideo,
} from "../recording/diagnostics";
import { rememberApprovedLocalReadPath } from "../project/manager";
import {
@@ -105,7 +106,6 @@ import {
buildFfmpegCaptureArgs,
waitForFfmpegCaptureStart,
waitForFfmpegCaptureStop,
getDisplayBoundsForSource,
} from "../recording/ffmpeg";
import { resolveWindowsCaptureDisplay } from "../windowsCaptureSelection";
import {
@@ -189,11 +189,21 @@ export function registerRecordingHandlers(
const recordingsDir = await getRecordingsDir()
const timestamp = Date.now()
const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`)
const displayBounds = source?.id?.startsWith('window:') ? null : getDisplayBoundsForSource(source)
const resolvedDisplay = resolveWindowsCaptureDisplay(
source,
getScreen().getAllDisplays(),
getScreen().getPrimaryDisplay(),
)
const displayBounds = resolvedDisplay.bounds
const config: Record<string, unknown> = {
outputPath,
fps: 60,
displayId: resolvedDisplay.displayId,
displayX: Math.round(resolvedDisplay.bounds.x),
displayY: Math.round(resolvedDisplay.bounds.y),
displayW: Math.round(resolvedDisplay.bounds.width),
displayH: Math.round(resolvedDisplay.bounds.height),
}
if (options?.capturesSystemAudio) {
@@ -213,25 +223,6 @@ export function registerRecordingHandlers(
setWindowsMicAudioPath(micPath)
}
const windowId = parseWindowId(source?.id)
if (windowId && source?.id?.startsWith('window:')) {
config.windowHandle = windowId
} else {
const resolvedDisplay = resolveWindowsCaptureDisplay(
source,
getScreen().getAllDisplays(),
getScreen().getPrimaryDisplay(),
)
config.displayId = resolvedDisplay.displayId
// Monitor handle IDs can drift across Electron/Windows capture boundaries,
// so also provide display bounds for a coordinate-based native fallback.
config.displayX = Math.round(resolvedDisplay.bounds.x)
config.displayY = Math.round(resolvedDisplay.bounds.y)
config.displayW = Math.round(resolvedDisplay.bounds.width)
config.displayH = Math.round(resolvedDisplay.bounds.height)
}
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
phase: 'start',
@@ -560,18 +551,19 @@ export function registerRecordingHandlers(
setWindowsCaptureStopRequested(true)
proc.stdin.write('stop\n')
const tempVideoPath = await waitForWindowsCaptureStop(proc)
const finalVideoPath = preferredVideoPath ?? tempVideoPath
if (tempVideoPath !== finalVideoPath) {
await moveFileWithOverwrite(tempVideoPath, finalVideoPath)
}
const validation = await validateRecordedVideo(finalVideoPath)
setWindowsCaptureProcess(null)
setWindowsNativeCaptureActive(false)
setNativeScreenRecordingActive(false)
setWindowsCaptureTargetPath(null)
setWindowsCaptureStopRequested(false)
setWindowsCapturePaused(false)
const finalVideoPath = preferredVideoPath ?? tempVideoPath
if (tempVideoPath !== finalVideoPath) {
await moveFileWithOverwrite(tempVideoPath, finalVideoPath)
}
setWindowsPendingVideoPath(finalVideoPath)
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
@@ -580,7 +572,7 @@ export function registerRecordingHandlers(
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(finalVideoPath),
fileSizeBytes: validation.fileSizeBytes,
})
return { success: true, path: finalVideoPath }
} catch (error) {
@@ -599,6 +591,7 @@ export function registerRecordingHandlers(
if (fallbackPath) {
try {
await fs.access(fallbackPath)
const validation = await validateRecordedVideo(fallbackPath)
setWindowsPendingVideoPath(fallbackPath)
recordNativeCaptureDiagnostics({
backend: 'windows-wgc',
@@ -607,12 +600,12 @@ export function registerRecordingHandlers(
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(fallbackPath),
fileSizeBytes: validation.fileSizeBytes,
error: String(error),
})
return { success: true, path: fallbackPath }
} catch {
// File doesn't exist
// File is absent or failed validation.
}
}
@@ -623,6 +616,7 @@ export function registerRecordingHandlers(
systemAudioPath: windowsSystemAudioPath,
microphonePath: windowsMicAudioPath,
processOutput: windowsCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(fallbackPath),
error: String(error),
})
@@ -922,10 +916,10 @@ export function registerRecordingHandlers(
})
setWindowsSystemAudioPath(null)
setWindowsMicAudioPath(null)
try {
return await finalizeStoredVideo(videoPath)
} catch {
return { success: false, message: 'Failed to mux native Windows recording', error: String(error) }
return {
success: false,
message: 'Failed to finalize native Windows recording',
error: String(error),
}
}
})
@@ -1055,15 +1049,24 @@ export function registerRecordingHandlers(
return stat ? { path: fullPath, mtimeMs: stat.mtimeMs } : null
}),
)
const latestVideo = candidates
const sortedCandidates = candidates
.filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null)
.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]
.sort((left, right) => right.mtimeMs - left.mtimeMs)
if (!latestVideo) {
for (const candidate of sortedCandidates) {
try {
await validateRecordedVideo(candidate.path)
return { success: true, path: candidate.path }
} catch (error) {
console.warn("Skipping unusable recovered recording candidate:", candidate.path, error)
}
}
if (sortedCandidates.length === 0) {
return { success: false, message: 'No recorded video found' }
}
return { success: true, path: latestVideo.path }
return { success: false, message: 'No usable recorded video found' }
} catch (error) {
console.error('Failed to get video path:', error)
return { success: false, message: 'Failed to get video path', error: String(error) }
+1 -1
View File
@@ -36,7 +36,7 @@ export function resolveWindowsCaptureDisplay(
primaryDisplay;
return {
displayId: Number(matchedDisplay.id),
displayId: requestedOrPrimaryDisplayId,
bounds: matchedDisplay.bounds,
};
}
+15 -6
View File
@@ -435,7 +435,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}, []);
const storeMicrophoneSidecar = useCallback(
async (micFallbackBlobPromise: Promise<Blob | null> | null | undefined, finalPath: string) => {
async (
micFallbackBlobPromise: Promise<Blob | null> | null | undefined,
finalPath: string,
) => {
const micFallbackBlob = await micFallbackBlobPromise;
if (!micFallbackBlob) {
return;
@@ -649,9 +652,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
void logNativeCaptureDiagnostics("stop-native-screen-recording");
try {
const recoveredPath = await recoverNativeRecordingSession(
micFallbackBlobPromise,
);
const recoveredPath =
await recoverNativeRecordingSession(micFallbackBlobPromise);
if (recoveredPath) {
return;
}
@@ -674,10 +676,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (isNativeWindows) {
const muxResult =
await window.electronAPI.muxNativeWindowsRecording(pauseSegments);
if (!muxResult?.success) {
if (!muxResult?.success || !muxResult.path) {
void logNativeCaptureDiagnostics("mux-native-windows-recording");
const failureMessage = await buildNativeCaptureFailureMessage(
"mux-native-windows-recording",
muxResult?.message ||
"Failed to finalize the Windows recording, so the editor was not opened.",
);
await notifyRecordingFinalizationFailure(failureMessage);
return;
}
finalPath = muxResult?.path ?? result.path;
finalPath = muxResult.path;
}
await storeMicrophoneSidecar(micFallbackBlobPromise, finalPath);