Files
Recordly/electron/ipc/recording/windows.ts

268 lines
6.8 KiB
TypeScript

import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { BrowserWindow } from "electron";
import { getWindowsCaptureExePath } from "../paths/binaries";
import {
selectedSource,
setWindowsCaptureProcess,
setWindowsCaptureStopRequested,
setWindowsNativeCaptureActive,
windowsCaptureOutputBuffer,
windowsCaptureStopRequested,
windowsCaptureTargetPath,
windowsNativeCaptureActive,
} from "../state";
import { AudioSyncAdjustment } from "../types";
import { moveFileWithOverwrite } from "../utils";
import { emitRecordingInterrupted } from "./events";
const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000;
export type NativeWindowsVideoPaddingResult = {
padded: boolean;
durationSeconds: number;
containerDurationSeconds: number;
targetDurationSeconds: number;
padDurationSeconds: number;
};
export type NativeWindowsAudioMuxResult = {
muxed: boolean;
videoDurationSeconds: number;
muxTimeoutMs: number;
audioInputs: string[];
audio: Record<
string,
{
path: string;
sizeBytes: number;
durationSeconds: number;
startDelayMs: number | null;
adjustment: AudioSyncAdjustment;
}
>;
outputPath?: string;
keptAudioSidecars?: boolean;
};
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
if (process.platform !== "win32") return false;
const os = await import("node:os");
const [major, , build] = os.release().split(".").map(Number);
const supported = major >= 10 && build >= 19041;
if (!supported) return false;
try {
await fs.access(getWindowsCaptureExePath(), fsConstants.X_OK);
} catch {
return false;
}
return true;
}
export function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for native Windows capture to start"));
}, 12000);
let stdoutBuffer = "";
const onStdout = (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (stdoutBuffer.includes("Recording started")) {
cleanup();
resolve();
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(
windowsCaptureOutputBuffer.trim() ||
`Native Windows capture exited before recording started (code ${code ?? "unknown"})`,
),
);
};
const cleanup = () => {
clearTimeout(timer);
proc.stdout.off("data", onStdout);
proc.off("error", onError);
proc.off("exit", onExit);
};
proc.stdout.on("data", onStdout);
proc.once("error", onError);
proc.once("exit", onExit);
});
}
export function waitForWindowsCaptureStop(
proc: ChildProcessWithoutNullStreams,
timeoutMs = WINDOWS_CAPTURE_STOP_TIMEOUT_MS,
) {
return new Promise<string>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
callback();
};
const timer = setTimeout(() => {
finish(() => {
try {
if (!proc.killed) proc.kill();
} catch {
// The process may already be gone; the caller only needs the timeout error.
}
reject(new Error("Timed out waiting for native Windows capture to stop"));
});
}, timeoutMs);
const onClose = (code: number | null) => {
finish(() => {
const match = windowsCaptureOutputBuffer.match(
/Recording stopped\. Output path: (.+)/,
);
if (match?.[1]) {
resolve(match[1].trim());
return;
}
if (code === 0 && windowsCaptureTargetPath) {
resolve(windowsCaptureTargetPath);
return;
}
reject(
new Error(
windowsCaptureOutputBuffer.trim() ||
`Native Windows capture exited with code ${code ?? "unknown"}`,
),
);
});
};
const onError = (error: Error) => {
finish(() => {
reject(error);
});
};
const cleanup = () => {
clearTimeout(timer);
proc.off("close", onClose);
proc.off("error", onError);
};
proc.once("close", onClose);
proc.once("error", onError);
});
}
export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStreams) {
proc.once("close", () => {
const wasActive = windowsNativeCaptureActive;
setWindowsCaptureProcess(null);
if (!wasActive || windowsCaptureStopRequested) {
return;
}
setWindowsNativeCaptureActive(false);
setWindowsCaptureStopRequested(false);
const sourceName = selectedSource?.name ?? "Screen";
BrowserWindow.getAllWindows().forEach((window) => {
if (!window.isDestroyed()) {
window.webContents.send("recording-state-changed", {
recording: false,
sourceName,
});
}
});
emitRecordingInterrupted("capture-stopped", "Recording stopped unexpectedly.");
});
}
export async function muxNativeWindowsVideoWithAudio(
videoPath: string,
systemAudioPath: string | null,
micAudioPath: string | null,
): Promise<NativeWindowsAudioMuxResult> {
const start = Date.now();
console.log("[PERF:MAIN] muxNativeWindowsVideoWithAudio: STARTED");
const audio: NativeWindowsAudioMuxResult["audio"] = {};
const audioInputs: string[] = [];
const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, "");
// Optimization: instead of heavy FFmpeg muxing, we just move the audio sidecars
// to their final companion paths so the editor can find them as separate tracks.
if (systemAudioPath) {
const finalSystemPath = `${videoPathWithoutExt}.system.wav`;
try {
const stat = await fs.stat(systemAudioPath);
if (stat.size > 0) {
if (systemAudioPath !== finalSystemPath) {
await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
}
audioInputs.push("system");
audio.system = {
path: finalSystemPath,
sizeBytes: stat.size,
durationSeconds: 0,
startDelayMs: null,
adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
};
}
} catch (err) {
console.error(`[mux-win] Failed to handle system audio:`, err);
}
}
if (micAudioPath) {
const finalMicPath = `${videoPathWithoutExt}.mic.wav`;
try {
const stat = await fs.stat(micAudioPath);
if (stat.size > 0) {
if (micAudioPath !== finalMicPath) {
await moveFileWithOverwrite(micAudioPath, finalMicPath);
}
audioInputs.push("mic");
audio.mic = {
path: finalMicPath,
sizeBytes: stat.size,
durationSeconds: 0,
startDelayMs: null,
adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
};
}
} catch (err) {
console.error(`[mux-win] Failed to handle mic audio:`, err);
}
}
console.log(`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`);
return {
muxed: false,
videoDurationSeconds: 0, // No longer needed here
muxTimeoutMs: 0,
audioInputs,
audio,
keptAudioSidecars: true,
};
}