Files

349 lines
10 KiB
TypeScript

import type { ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs/promises";
import { BrowserWindow } from "electron";
import {
persistPendingCursorTelemetry,
snapshotCursorTelemetryForPersistence,
} from "../cursor/telemetry";
import {
lastNativeCaptureDiagnostics,
nativeCaptureMicrophonePath,
nativeCaptureOutputBuffer,
nativeCaptureStopRequested,
nativeCaptureSystemAudioPath,
nativeCaptureTargetPath,
nativeScreenRecordingActive,
selectedSource,
setCurrentProjectPath,
setCurrentVideoPath,
setNativeCaptureMicrophonePath,
setNativeCaptureProcess,
setNativeCaptureStopRequested,
setNativeCaptureSystemAudioPath,
setNativeCaptureTargetPath,
setNativeScreenRecordingActive,
} from "../state";
import { moveFileWithOverwrite } from "../utils";
import {
getFileSizeIfPresent,
recordNativeCaptureDiagnostics,
validateRecordedVideo,
} from "./diagnostics";
import { emitRecordingInterrupted } from "./events";
import { getFinalMacCompanionAudioPath } from "./macCompanionAudio";
export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for ScreenCaptureKit recorder 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(
nativeCaptureOutputBuffer.trim() ||
`Native capture helper exited before recording started (code ${code ?? "unknown"})`,
),
);
};
const cleanup = () => {
clearTimeout(timer);
process.stdout.off("data", onStdout);
process.off("error", onError);
process.off("exit", onExit);
};
process.stdout.on("data", onStdout);
process.once("error", onError);
process.once("exit", onExit);
});
}
export function waitForNativeCaptureCommand(
process: ChildProcessWithoutNullStreams,
marker: "Recording paused" | "Recording resumed",
) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ScreenCaptureKit helper: ${marker}`));
}, 5000);
let stdoutBuffer = "";
const onStdout = (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
if (stdoutBuffer.includes(marker)) {
cleanup();
resolve();
}
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(
`Native capture helper exited before ${marker.toLowerCase()} (code ${code ?? "unknown"})`,
),
);
};
const cleanup = () => {
clearTimeout(timer);
process.stdout.off("data", onStdout);
process.off("error", onError);
process.off("exit", onExit);
};
process.stdout.on("data", onStdout);
process.once("error", onError);
process.once("exit", onExit);
});
}
export function waitForNativeCaptureStop(process: ChildProcessWithoutNullStreams) {
return new Promise<string>((resolve, reject) => {
const onClose = (code: number | null) => {
cleanup();
const match = nativeCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/);
if (match?.[1]) {
resolve(match[1].trim());
return;
}
if (code === 0 && nativeCaptureTargetPath) {
resolve(nativeCaptureTargetPath);
return;
}
reject(
new Error(
nativeCaptureOutputBuffer.trim() ||
`Native capture helper exited with code ${code ?? "unknown"}`,
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
process.off("close", onClose);
process.off("error", onError);
};
process.once("close", onClose);
process.once("error", onError);
});
}
export async function muxNativeMacRecordingWithAudio(
videoPath: string,
systemAudioPath?: string | null,
microphonePath?: string | null,
) {
console.log("[mac-mux] Optimization active: keeping tracks separate.");
// Optimization: instead of heavy FFmpeg muxing, we ensure audio sidecars
// are available alongside the video for the editor.
if (systemAudioPath) {
const finalSystemPath = getFinalMacCompanionAudioPath(videoPath, systemAudioPath, "system");
try {
const stat = await fs.stat(systemAudioPath);
if (stat.size > 0 && systemAudioPath !== finalSystemPath) {
await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
}
} catch (err) {
console.error(`[mac-mux] Failed to handle system audio:`, err);
}
}
if (microphonePath) {
const finalMicPath = getFinalMacCompanionAudioPath(videoPath, microphonePath, "mic");
try {
const stat = await fs.stat(microphonePath);
if (stat.size > 0 && microphonePath !== finalMicPath) {
await moveFileWithOverwrite(microphonePath, finalMicPath);
}
} catch (err) {
console.error(`[mac-mux] Failed to handle mic audio:`, err);
}
}
}
export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) {
process.once("close", () => {
const wasActive = nativeScreenRecordingActive;
setNativeCaptureProcess(null);
if (!wasActive || nativeCaptureStopRequested) {
return;
}
setNativeScreenRecordingActive(false);
console.log("[mac-finalize] Optimization active: skipping safety-net muxing.");
setNativeCaptureTargetPath(null);
setNativeCaptureStopRequested(false);
setNativeCaptureSystemAudioPath(null);
setNativeCaptureMicrophonePath(null);
const sourceName = selectedSource?.name ?? "Screen";
BrowserWindow.getAllWindows().forEach((window) => {
if (!window.isDestroyed()) {
window.webContents.send("recording-state-changed", {
recording: false,
sourceName,
});
}
});
const reason = nativeCaptureOutputBuffer.includes("WINDOW_UNAVAILABLE")
? "window-unavailable"
: "capture-stopped";
const message =
reason === "window-unavailable"
? "The selected window is no longer capturable. Please reselect a window."
: "Recording stopped unexpectedly.";
emitRecordingInterrupted(reason, message);
});
}
export async function finalizeStoredVideo(videoPath: string) {
console.log("[finalize] Optimization active: skipping safety-net muxing.");
let validation: { fileSizeBytes: number; durationSeconds: number | null };
try {
validation = await validateRecordedVideo(videoPath);
} catch (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();
setCurrentVideoPath(videoPath);
setCurrentProjectPath(null);
try {
await persistPendingCursorTelemetry(videoPath);
} catch (error) {
console.warn("[mac-stop] Failed to persist cursor telemetry:", 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: validation.fileSizeBytes,
});
}
return {
success: true,
path: videoPath,
message:
validation.durationSeconds !== null
? `Video stored successfully (${validation.fileSizeBytes} bytes, ${validation.durationSeconds.toFixed(2)}s)`
: `Video stored successfully`,
};
}
export async function recoverNativeMacCaptureOutput() {
const macDiagnostics =
lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit"
? lastNativeCaptureDiagnostics
: null;
const diagnosticsPath = macDiagnostics?.outputPath ?? null;
const candidatePath = nativeCaptureTargetPath ?? diagnosticsPath;
const systemAudioPath = nativeCaptureSystemAudioPath ?? macDiagnostics?.systemAudioPath ?? null;
const microphonePath = nativeCaptureMicrophonePath ?? macDiagnostics?.microphonePath ?? null;
if (!candidatePath) {
return null;
}
try {
if (systemAudioPath || microphonePath) {
try {
await muxNativeMacRecordingWithAudio(
candidatePath,
systemAudioPath,
microphonePath,
);
} catch (muxError) {
console.warn("Failed to mux audio during recovery:", muxError);
}
}
return await finalizeStoredVideo(candidatePath);
} catch (error) {
recordNativeCaptureDiagnostics({
backend: "mac-screencapturekit",
phase: "stop",
outputPath: candidatePath,
systemAudioPath,
microphonePath,
processOutput: nativeCaptureOutputBuffer.trim() || undefined,
fileSizeBytes: await getFileSizeIfPresent(candidatePath),
error: String(error),
});
return null;
}
}