mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
268 lines
6.8 KiB
TypeScript
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,
|
|
};
|
|
}
|