refactor: split handlers.ts into focused sub-modules

handlers.ts was ~5967 lines. Extracted into 22 focused modules:

- ipc/types.ts — shared TypeScript interfaces and types
- ipc/constants.ts — module-level constants
- ipc/state.ts — all mutable state with typed setters
- ipc/utils.ts — shared low-level utilities (getScreen, normalizePath, etc.)
- ipc/ffmpeg/binary.ts — ffmpeg binary resolution
- ipc/ffmpeg/filters.ts — audio sync/filter builders
- ipc/captions/parser.ts — SRT/Whisper JSON parsers
- ipc/captions/whisper.ts — Whisper model download/status
- ipc/captions/generate.ts — auto-caption generation
- ipc/paths/binaries.ts — native binary path resolution
- ipc/cursor/monitor.ts — cursor monitor process management
- ipc/cursor/telemetry.ts — cursor sampling and telemetry
- ipc/cursor/bounds.ts — window bounds capture and resolution
- ipc/cursor/interaction.ts — mouse hook and interaction capture
- ipc/recording/events.ts — recording lifecycle events
- ipc/recording/diagnostics.ts — media validation and diagnostics
- ipc/recording/prune.ts — auto-recording cleanup
- ipc/recording/ffmpeg.ts — FFmpeg screen capture
- ipc/recording/windows.ts — Windows native capture (WGC)
- ipc/recording/mac.ts — Mac ScreenCaptureKit integration
- ipc/export/native-video.ts — native video export sessions
- ipc/project/session.ts — recording session manifests
- ipc/project/manager.ts — project library and file management

handlers.ts reduced from 5967 → 2930 lines (registerIpcHandlers + helpers only)
This commit is contained in:
webadderall
2026-04-17 19:57:26 +10:00
parent d86425a83e
commit 099ce2bbb5
24 changed files with 4947 additions and 4392 deletions
+248
View File
@@ -0,0 +1,248 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { app } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { getBundledWhisperExecutableCandidates } from "../paths/binaries";
import { parseWhisperJsonCues, parseSrtCues, shouldRetryWhisperWithoutJson } from "./parser";
import { normalizeVideoSourcePath } from "../utils";
import { resolveRecordingSession } from "../project/session";
const execFileAsync = promisify(execFile);
export async function ensureReadableFile(filePath: string, description: string) {
await fs.access(filePath, fsConstants.R_OK);
if (description === "whisper executable") {
try {
await fs.access(filePath, fsConstants.X_OK);
} catch {
throw new Error("The selected Whisper executable is not marked as executable.");
}
}
}
export async function isExecutableFile(filePath: string) {
try {
await fs.access(filePath, fsConstants.R_OK | fsConstants.X_OK);
return true;
} catch {
return false;
}
}
export async function resolveWhisperExecutablePath(preferredPath?: string | null) {
const candidatePaths = [
preferredPath?.trim() || null,
...getBundledWhisperExecutableCandidates(),
process.env["WHISPER_CPP_PATH"]?.trim() || null,
process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cli" : null,
process.platform === "darwin" ? "/usr/local/bin/whisper-cli" : null,
process.platform === "darwin" ? "/opt/homebrew/bin/whisper-cpp" : null,
process.platform === "darwin" ? "/usr/local/bin/whisper-cpp" : null,
].filter((value): value is string => Boolean(value));
for (const candidate of candidatePaths) {
const normalized = path.resolve(candidate);
if (await isExecutableFile(normalized)) {
return normalized;
}
}
const pathCommand = process.platform === "win32" ? "where" : "which";
const binaryNames =
process.platform === "win32"
? ["whisper-cli.exe", "whisper.exe", "main.exe"]
: ["whisper-cli", "whisper-cpp", "whisper", "main"];
for (const binaryName of binaryNames) {
const result = spawnSync(pathCommand, [binaryName], { encoding: "utf-8" });
if (result.status === 0) {
const resolvedPath = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (resolvedPath && (await isExecutableFile(resolvedPath))) {
return resolvedPath;
}
}
}
throw new Error(
"No Whisper runtime was found. Recordly looked for a bundled binary first, then checked common system install locations.",
);
}
export async function resolveCaptionAudioCandidates(videoPath: string) {
const candidates: Array<{ path: string; label: string }> = [];
const seenPaths = new Set<string>();
const pushCandidate = (candidatePath: string | null | undefined, label: string) => {
const normalizedCandidatePath = normalizeVideoSourcePath(candidatePath);
if (!normalizedCandidatePath || seenPaths.has(normalizedCandidatePath)) {
return;
}
seenPaths.add(normalizedCandidatePath);
candidates.push({ path: normalizedCandidatePath, label });
};
pushCandidate(videoPath, "recording");
const requestedRecordingSession = await resolveRecordingSession(videoPath);
pushCandidate(requestedRecordingSession?.webcamPath, "linked webcam recording");
return candidates;
}
export async function extractCaptionAudioSource(options: {
videoPath: string;
ffmpegPath: string;
wavPath: string;
}) {
const candidates = await resolveCaptionAudioCandidates(options.videoPath);
const attemptedCandidates: Array<{
path: string;
label: string;
readable: boolean;
extractedAudio: boolean;
error?: string;
}> = [];
for (const candidate of candidates) {
try {
await ensureReadableFile(candidate.path, "video file");
await execFileAsync(
options.ffmpegPath,
[
"-y",
"-i",
candidate.path,
"-map",
"0:a:0",
"-vn",
"-ac",
"1",
"-ar",
"16000",
"-c:a",
"pcm_s16le",
options.wavPath,
],
{ timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 },
);
attemptedCandidates.push({ ...candidate, readable: true, extractedAudio: true });
return candidate;
} catch (error) {
attemptedCandidates.push({
...candidate,
readable: true,
extractedAudio: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
console.warn(
"[auto-captions] No audio source candidate could be extracted:",
attemptedCandidates,
);
throw new Error(
"No audio was found to transcribe in the saved recording file. Captions need an audio track. If this recording should have contained sound, the recording was saved without an audio stream.",
);
}
export async function generateAutoCaptionsFromVideo(options: {
videoPath: string;
whisperExecutablePath?: string;
whisperModelPath: string;
language?: string;
}) {
const ffmpegPath = getFfmpegBinaryPath();
const normalizedVideoPath = normalizeVideoSourcePath(options.videoPath);
if (!normalizedVideoPath) {
throw new Error("Missing source video path.");
}
const whisperExecutablePath = await resolveWhisperExecutablePath(options.whisperExecutablePath);
const whisperModelPath = path.resolve(options.whisperModelPath);
await ensureReadableFile(whisperExecutablePath, "whisper executable");
await ensureReadableFile(whisperModelPath, "whisper model");
const tempBase = path.join(
app.getPath("temp"),
`recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
const wavPath = `${tempBase}.wav`;
const outputBase = `${tempBase}-whisper`;
const srtPath = `${outputBase}.srt`;
const jsonPath = `${outputBase}.json`;
try {
const audioSource = await extractCaptionAudioSource({
videoPath: normalizedVideoPath,
ffmpegPath,
wavPath,
});
const language =
options.language && options.language.trim() ? options.language.trim() : "auto";
const whisperBaseArgs = [
"-m",
whisperModelPath,
"-f",
wavPath,
"-osrt",
"-of",
outputBase,
"-l",
language,
"-np",
];
let jsonEnabled = true;
try {
await execFileAsync(whisperExecutablePath, [...whisperBaseArgs, "-ojf"], {
timeout: 30 * 60 * 1000,
maxBuffer: 20 * 1024 * 1024,
});
} catch (error) {
if (!shouldRetryWhisperWithoutJson(error)) {
throw error;
}
jsonEnabled = false;
console.warn(
"[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:",
error,
);
await execFileAsync(whisperExecutablePath, whisperBaseArgs, {
timeout: 30 * 60 * 1000,
maxBuffer: 20 * 1024 * 1024,
});
}
const timedCues = jsonEnabled
? parseWhisperJsonCues(await fs.readFile(jsonPath, "utf-8"))
: [];
const cues =
timedCues.length > 0 ? timedCues : parseSrtCues(await fs.readFile(srtPath, "utf-8"));
if (cues.length === 0) {
throw new Error("Whisper completed, but no caption cues were produced.");
}
return {
cues,
audioSourceLabel: audioSource.label,
};
} finally {
await Promise.allSettled([
fs.rm(wavPath, { force: true }),
fs.rm(srtPath, { force: true }),
fs.rm(jsonPath, { force: true }),
]);
}
}
+183
View File
@@ -0,0 +1,183 @@
import type {
CaptionCuePayload,
CaptionWordPayload,
WhisperJsonSegment,
WhisperJsonToken,
} from "../types";
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function buildCaptionTextFromWords(words: CaptionWordPayload[]): string {
return words
.map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
.join("")
.trim();
}
export function parseWhisperJsonWords(tokens: unknown): CaptionWordPayload[] {
if (!Array.isArray(tokens)) {
return [];
}
const words: CaptionWordPayload[] = [];
let nextLeadingSpace = false;
for (const token of tokens) {
if (!token || typeof token !== "object") {
continue;
}
const tokenData = token as WhisperJsonToken;
const tokenText = typeof tokenData.text === "string" ? tokenData.text : "";
if (!tokenText) {
continue;
}
const tokenStartMs = isFiniteNumber(tokenData.offsets?.from)
? Math.round(tokenData.offsets.from)
: null;
const tokenEndMs = isFiniteNumber(tokenData.offsets?.to)
? Math.round(tokenData.offsets.to)
: null;
const parts = tokenText.match(/\s+|[^\s]+/g) ?? [];
for (const part of parts) {
if (/^\s+$/.test(part)) {
nextLeadingSpace = words.length > 0;
continue;
}
if (tokenStartMs == null || tokenEndMs == null || tokenEndMs <= tokenStartMs) {
return [];
}
const previousWord = words.length > 0 ? words[words.length - 1] : null;
if (!previousWord || nextLeadingSpace) {
words.push({
text: part,
startMs: tokenStartMs,
endMs: tokenEndMs,
...(words.length > 0 && nextLeadingSpace ? { leadingSpace: true } : {}),
});
} else {
previousWord.text += part;
previousWord.endMs = Math.max(previousWord.endMs, tokenEndMs);
}
nextLeadingSpace = false;
}
}
return words.filter((word) => word.text.trim().length > 0);
}
export function parseWhisperJsonCues(content: string): CaptionCuePayload[] {
try {
const parsed = JSON.parse(content) as {
transcription?: unknown;
};
if (!Array.isArray(parsed.transcription)) {
return [];
}
return parsed.transcription
.map((segment, index) => {
if (!segment || typeof segment !== "object") {
return null;
}
const segmentData = segment as WhisperJsonSegment;
const startMs = isFiniteNumber(segmentData.offsets?.from)
? Math.round(segmentData.offsets.from)
: null;
const endMs = isFiniteNumber(segmentData.offsets?.to)
? Math.round(segmentData.offsets.to)
: null;
const segmentText =
typeof segmentData.text === "string" ? segmentData.text.trim() : "";
if (startMs == null || endMs == null || endMs <= startMs) {
return null;
}
const words = parseWhisperJsonWords(segmentData.tokens);
const text = words.length > 0 ? buildCaptionTextFromWords(words) : segmentText;
if (!text) {
return null;
}
return {
id: `caption-${index + 1}`,
startMs,
endMs,
text,
...(words.length > 0 ? { words } : {}),
};
})
.filter((cue): cue is CaptionCuePayload => cue != null);
} catch (error) {
console.warn("[auto-captions] Failed to parse Whisper JSON output:", error);
return [];
}
}
export function parseSrtTimestamp(value: string): number | null {
const match = value.trim().match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) {
return null;
}
const [, hours, minutes, seconds, milliseconds] = match;
return (
Number(hours) * 60 * 60 * 1000 +
Number(minutes) * 60 * 1000 +
Number(seconds) * 1000 +
Number(milliseconds)
);
}
export function parseSrtCues(content: string): CaptionCuePayload[] {
return content
.split(/\r?\n\r?\n/)
.map((block, index) => {
const lines = block.split(/\r?\n/).map((line) => line.trim());
const timingLine = lines.find((line) => line.includes("-->"));
if (!timingLine) {
return null;
}
const [rawStart, rawEnd] = timingLine.split("-->").map((part) => part.trim());
const startMs = parseSrtTimestamp(rawStart);
const endMs = parseSrtTimestamp(rawEnd);
if (startMs == null || endMs == null || endMs <= startMs) {
return null;
}
const text = lines
.slice(lines.indexOf(timingLine) + 1)
.filter((line) => line.length > 0)
.join("\n")
.trim();
if (!text) {
return null;
}
return {
id: `caption-${index + 1}`,
startMs,
endMs,
text,
};
})
.filter((cue): cue is CaptionCuePayload => cue != null);
}
export function shouldRetryWhisperWithoutJson(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /unknown argument|output-json-full|output-json|ojf|\boj\b/i.test(message);
}
+146
View File
@@ -0,0 +1,146 @@
import { createWriteStream } from "node:fs";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { get as httpsGet } from "node:https";
import type Electron from "electron";
import { WHISPER_MODEL_DIR, WHISPER_MODEL_DOWNLOAD_URL, WHISPER_SMALL_MODEL_PATH } from "../constants";
export function sendWhisperModelDownloadProgress(
webContents: Electron.WebContents,
payload: {
status: "idle" | "downloading" | "downloaded" | "error";
progress: number;
path?: string | null;
error?: string;
},
) {
webContents.send("whisper-small-model-download-progress", payload);
}
export async function getWhisperSmallModelStatus() {
try {
await fs.access(WHISPER_SMALL_MODEL_PATH, fsConstants.R_OK);
return {
success: true,
exists: true,
path: WHISPER_SMALL_MODEL_PATH,
};
} catch {
return {
success: true,
exists: false,
path: null,
};
}
}
export function downloadFileWithProgress(
url: string,
destinationPath: string,
onProgress: (progress: number) => void,
): Promise<void> {
const request = (currentUrl: string, redirectCount = 0): Promise<void> => {
return new Promise((resolve, reject) => {
const req = httpsGet(currentUrl, (response) => {
const statusCode = response.statusCode ?? 0;
const location = response.headers.location;
if (statusCode >= 300 && statusCode < 400 && location) {
response.resume();
if (redirectCount >= 5) {
reject(new Error("Too many redirects while downloading Whisper model."));
return;
}
const nextUrl = new URL(location, currentUrl).toString();
void request(nextUrl, redirectCount + 1)
.then(resolve)
.catch(reject);
return;
}
if (statusCode < 200 || statusCode >= 300) {
response.resume();
reject(new Error(`Whisper model download failed with status ${statusCode}.`));
return;
}
const totalBytes = Number.parseInt(
String(response.headers["content-length"] ?? "0"),
10,
);
let downloadedBytes = 0;
const fileStream = createWriteStream(destinationPath);
response.on("data", (chunk: Buffer) => {
downloadedBytes += chunk.length;
if (Number.isFinite(totalBytes) && totalBytes > 0) {
onProgress(Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)));
}
});
response.on("error", (error) => {
fileStream.destroy(error);
});
fileStream.on("error", (error) => {
response.destroy(error);
reject(error);
});
fileStream.on("finish", () => {
onProgress(100);
resolve();
});
response.pipe(fileStream);
});
req.on("error", reject);
});
};
return request(url);
}
export async function downloadWhisperSmallModel(webContents: Electron.WebContents): Promise<string> {
await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true });
const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`;
sendWhisperModelDownloadProgress(webContents, {
status: "downloading",
progress: 0,
path: null,
});
try {
await fs.rm(tempPath, { force: true });
await downloadFileWithProgress(WHISPER_MODEL_DOWNLOAD_URL, tempPath, (progress) => {
sendWhisperModelDownloadProgress(webContents, {
status: "downloading",
progress,
path: null,
});
});
await fs.rename(tempPath, WHISPER_SMALL_MODEL_PATH);
sendWhisperModelDownloadProgress(webContents, {
status: "downloaded",
progress: 100,
path: WHISPER_SMALL_MODEL_PATH,
});
return WHISPER_SMALL_MODEL_PATH;
} catch (error) {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
sendWhisperModelDownloadProgress(webContents, {
status: "error",
progress: 0,
path: null,
error: String(error),
});
throw error;
}
}
export async function deleteWhisperSmallModel(): Promise<void> {
await fs.rm(WHISPER_SMALL_MODEL_PATH, { force: true });
}
+30
View File
@@ -0,0 +1,30 @@
import path from "node:path";
import { USER_DATA_PATH } from "../appPaths";
export const PROJECT_FILE_EXTENSION = "recordly";
export const LEGACY_PROJECT_FILE_EXTENSIONS = ["openscreen"];
export const PROJECTS_DIRECTORY_NAME = "Projects";
export const PROJECT_THUMBNAIL_SUFFIX = ".preview.png";
export const RECENT_PROJECTS_FILE = path.join(USER_DATA_PATH, "recent-projects.json");
export const MAX_RECENT_PROJECTS = 16;
export const SHORTCUTS_FILE = path.join(USER_DATA_PATH, "shortcuts.json");
export const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, "recordings-settings.json");
export const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, "countdown-settings.json");
export const AUTO_RECORDING_PREFIX = "recording-";
export const AUTO_RECORDING_RETENTION_COUNT = 20;
export const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
export const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env["VITE_DEV_SERVER_URL"]);
export const RECORDING_SESSION_MANIFEST_SUFFIX = ".recordly-session.json";
export const WHISPER_MODEL_DOWNLOAD_URL =
"https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin";
export const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper");
export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin");
export const COMPANION_AUDIO_LAYOUTS = [
{ platform: "mac" as const, systemSuffix: ".system.m4a", micSuffix: ".mic.m4a" },
{ platform: "win" as const, systemSuffix: ".system.wav", micSuffix: ".mic.wav" },
{ platform: "mac" as const, systemSuffix: ".system.webm", micSuffix: ".mic.webm" },
];
export const CURSOR_TELEMETRY_VERSION = 2;
export const CURSOR_SAMPLE_INTERVAL_MS = 33;
export const MAX_CURSOR_SAMPLES = 60 * 60 * 30; // 1 hour @ 30Hz
+260
View File
@@ -0,0 +1,260 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { NativeMacWindowSource, WindowBounds, SelectedSource } from "../types";
import {
selectedSource,
setSelectedWindowBounds,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
windowBoundsCaptureInterval,
setWindowBoundsCaptureInterval,
cachedNativeMacWindowSources,
setCachedNativeMacWindowSources,
cachedNativeMacWindowSourcesAtMs,
setCachedNativeMacWindowSourcesAtMs,
} from "../state";
import { parseWindowId } from "../utils";
import { ensureNativeWindowListBinary } from "../paths/binaries";
const execFileAsync = promisify(execFile);
export async function getNativeMacWindowSources(options?: { maxAgeMs?: number }) {
if (process.platform !== "darwin") {
return [] as NativeMacWindowSource[];
}
const maxAgeMs = options?.maxAgeMs ?? 5000;
const now = Date.now();
if (cachedNativeMacWindowSources && now - cachedNativeMacWindowSourcesAtMs < maxAgeMs) {
return cachedNativeMacWindowSources;
}
const binaryPath = await ensureNativeWindowListBinary();
const { stdout } = await execFileAsync(binaryPath, [], {
timeout: 30000,
maxBuffer: 10 * 1024 * 1024,
});
const parsed = JSON.parse(stdout);
if (!Array.isArray(parsed)) {
return [] as NativeMacWindowSource[];
}
const entries = parsed.filter((entry: unknown): entry is NativeMacWindowSource => {
if (!entry || typeof entry !== "object") {
return false;
}
const candidate = entry as Partial<NativeMacWindowSource>;
return typeof candidate.id === "string" && typeof candidate.name === "string";
});
setCachedNativeMacWindowSources(entries);
setCachedNativeMacWindowSourcesAtMs(now);
return entries;
}
export function getWindowBoundsFromNativeSource(
source?: NativeMacWindowSource | null,
): WindowBounds | null {
if (!source) {
return null;
}
const { x, y, width, height } = source;
if (
typeof x !== "number" ||
!Number.isFinite(x) ||
typeof y !== "number" ||
!Number.isFinite(y) ||
typeof width !== "number" ||
!Number.isFinite(width) ||
typeof height !== "number" ||
!Number.isFinite(height)
) {
return null;
}
if (width <= 0 || height <= 0) {
return null;
}
return { x, y, width, height };
}
export async function resolveMacWindowBounds(source: SelectedSource): Promise<WindowBounds | null> {
const windowId = parseWindowId(source.id);
if (!windowId) {
return null;
}
try {
const nativeSources = await getNativeMacWindowSources({ maxAgeMs: 250 });
const matchedSource = nativeSources.find((entry) => parseWindowId(entry.id) === windowId);
return getWindowBoundsFromNativeSource(matchedSource);
} catch {
return null;
}
}
export function parseXwininfoBounds(stdout: string): WindowBounds | null {
const absX = stdout.match(/Absolute upper-left X:\s+(-?\d+)/);
const absY = stdout.match(/Absolute upper-left Y:\s+(-?\d+)/);
const width = stdout.match(/Width:\s+(\d+)/);
const height = stdout.match(/Height:\s+(\d+)/);
if (!absX || !absY || !width || !height) {
return null;
}
return {
x: Number.parseInt(absX[1], 10),
y: Number.parseInt(absY[1], 10),
width: Number.parseInt(width[1], 10),
height: Number.parseInt(height[1], 10),
};
}
export async function resolveLinuxWindowBounds(source: SelectedSource): Promise<WindowBounds | null> {
const windowId = parseWindowId(source?.id);
if (windowId) {
try {
const { stdout } = await execFileAsync("xwininfo", ["-id", String(windowId)], {
timeout: 1500,
});
const bounds = parseXwininfoBounds(stdout);
if (bounds && bounds.width > 0 && bounds.height > 0) {
return bounds;
}
} catch {
// fall back to title lookup below
}
}
const windowTitle =
typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
if (!windowTitle) {
return null;
}
try {
const { stdout } = await execFileAsync("xwininfo", ["-name", windowTitle], {
timeout: 1500,
});
const bounds = parseXwininfoBounds(stdout);
return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null;
} catch {
return null;
}
}
export async function resolveWindowsWindowBounds(source: SelectedSource): Promise<WindowBounds | null> {
const windowId = parseWindowId(source?.id);
const windowTitle =
typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim();
if (!windowId && !windowTitle) {
return null;
}
const script = [
"param([string]$windowId, [string]$windowTitle)",
'Add-Type -TypeDefinition @"',
"using System;",
"using System.Runtime.InteropServices;",
"public static class RecordlyWindowBounds {",
" [StructLayout(LayoutKind.Sequential)]",
" public struct RECT {",
" public int Left;",
" public int Top;",
" public int Right;",
" public int Bottom;",
" }",
' [DllImport("user32.dll")]',
" [return: MarshalAs(UnmanagedType.Bool)]",
" public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);",
"}",
'"@',
"$handle = [Int64]0",
"if ($windowId) {",
" $handle = [Int64]$windowId",
"}",
"if ($handle -le 0 -and $windowTitle) {",
' $matchingProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $windowTitle -or $_.MainWindowTitle -like "*$windowTitle*" } | Select-Object -First 1',
" if ($matchingProcess) {",
" $handle = $matchingProcess.MainWindowHandle.ToInt64()",
" }",
"}",
"if ($handle -le 0) {",
" exit 1",
"}",
"$rect = New-Object RecordlyWindowBounds+RECT",
"if (-not [RecordlyWindowBounds]::GetWindowRect([IntPtr]$handle, [ref]$rect)) {",
" exit 1",
"}",
"@{ x = $rect.Left; y = $rect.Top; width = $rect.Right - $rect.Left; height = $rect.Bottom - $rect.Top } | ConvertTo-Json -Compress",
].join("\n");
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-Command", script, String(windowId ?? ""), windowTitle],
{ timeout: 1500 },
);
const bounds = JSON.parse(stdout) as WindowBounds;
return bounds && bounds.width > 0 && bounds.height > 0 ? bounds : null;
} catch {
return null;
}
}
export function stopInteractionCapture() {
if (interactionCaptureCleanup) {
interactionCaptureCleanup();
setInteractionCaptureCleanup(null);
}
}
export function stopWindowBoundsCapture() {
if (windowBoundsCaptureInterval) {
clearInterval(windowBoundsCaptureInterval);
setWindowBoundsCaptureInterval(null);
}
setSelectedWindowBounds(null);
}
async function refreshSelectedWindowBounds() {
if (!selectedSource?.id?.startsWith("window:")) {
setSelectedWindowBounds(null);
return;
}
let bounds: WindowBounds | null = null;
if (process.platform === "darwin") {
bounds = await resolveMacWindowBounds(selectedSource);
} else if (process.platform === "win32") {
bounds = await resolveWindowsWindowBounds(selectedSource);
} else if (process.platform === "linux") {
bounds = await resolveLinuxWindowBounds(selectedSource);
}
setSelectedWindowBounds(bounds);
}
export function startWindowBoundsCapture() {
stopWindowBoundsCapture();
if (
!["darwin", "win32", "linux"].includes(process.platform) ||
!selectedSource?.id?.startsWith("window:")
) {
return;
}
void refreshSelectedWindowBounds();
setWindowBoundsCaptureInterval(setInterval(() => {
void refreshSelectedWindowBounds();
}, 250));
}
+219
View File
@@ -0,0 +1,219 @@
import { createRequire } from "node:module";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
cursorCaptureStartTimeMs,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
setHasLoggedInteractionHookFailure,
lastLeftClick,
setLastLeftClick,
setLinuxCursorScreenPoint,
} from "../state";
import {
getNormalizedCursorPoint,
getHookCursorScreenPoint,
pushCursorSample,
} from "./telemetry";
const nodeRequire = createRequire(import.meta.url);
export function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 {
if (typeof rawButton !== "number" || !Number.isFinite(rawButton)) {
return 1;
}
if (rawButton === 2 || rawButton === 39) {
return 2;
}
if (rawButton === 3 || rawButton === 38) {
return 3;
}
return 1;
}
export function getHookMouseButton(event: HookMouseEvent | null | undefined): 1 | 2 | 3 {
return normalizeHookMouseButton(
event?.button ?? event?.mouseButton ?? event?.data?.button ?? event?.data?.mouseButton,
);
}
export function stopInteractionCapture() {
if (interactionCaptureCleanup) {
interactionCaptureCleanup();
setInteractionCaptureCleanup(null);
}
}
function isUiohookLike(value: unknown): value is UiohookLike {
const candidate = value as Partial<UiohookLike> | null;
return typeof candidate?.on === "function" && typeof candidate?.start === "function";
}
function loadUiohookModule() {
const moduleExports = nodeRequire("uiohook-napi") as UiohookModuleNamespace;
const defaultExport = moduleExports.default;
if (moduleExports.uIOhook) {
return moduleExports.uIOhook;
}
if (moduleExports.uiohook) {
return moduleExports.uiohook;
}
if (moduleExports.Uiohook) {
return moduleExports.Uiohook;
}
if (isUiohookLike(defaultExport)) {
return defaultExport;
}
if (defaultExport?.uIOhook) {
return defaultExport.uIOhook;
}
if (defaultExport?.uiohook) {
return defaultExport.uiohook;
}
if (defaultExport?.Uiohook) {
return defaultExport.Uiohook;
}
return null;
}
export async function startInteractionCapture() {
if (!isCursorCaptureActive) {
return;
}
if (!["darwin", "win32", "linux"].includes(process.platform)) {
return;
}
try {
const hook = loadUiohookModule();
console.log(
"[CursorTelemetry] hook loaded:",
!!hook,
"has.on:",
typeof hook?.on,
"has.start:",
typeof hook?.start,
);
if (!isCursorCaptureActive) {
return;
}
if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") {
console.log("[CursorTelemetry] hook unusable — aborting interaction capture");
return;
}
const onMouseDown = (event: HookMouseEvent) => {
if (!isCursorCaptureActive) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = Date.now() - cursorCaptureStartTimeMs;
const button = getHookMouseButton(event);
let interactionType: CursorInteractionType = "click";
if (button === 2) {
interactionType = "right-click";
} else if (button === 3) {
interactionType = "middle-click";
} else {
const thresholdMs = 350;
const distance = lastLeftClick
? Math.hypot(point.cx - lastLeftClick.cx, point.cy - lastLeftClick.cy)
: Number.POSITIVE_INFINITY;
if (
lastLeftClick &&
timeMs - lastLeftClick.timeMs <= thresholdMs &&
distance <= 0.04
) {
interactionType = "double-click";
}
setLastLeftClick({ timeMs, cx: point.cx, cy: point.cy });
}
pushCursorSample(point.cx, point.cy, timeMs, interactionType);
};
const onMouseUp = () => {
if (!isCursorCaptureActive) {
return;
}
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
const timeMs = Date.now() - cursorCaptureStartTimeMs;
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
};
const onMouseMove = (event: HookMouseEvent) => {
if (process.platform !== "linux" || !isCursorCaptureActive) {
return;
}
const point = getHookCursorScreenPoint(event);
if (!point) {
return;
}
setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() });
};
hook.on("mousedown", onMouseDown);
hook.on("mouseup", onMouseUp);
hook.on("mousemove", onMouseMove);
hook.start();
setInteractionCaptureCleanup(() => {
try {
if (typeof hook.off === "function") {
hook.off("mousedown", onMouseDown);
hook.off("mouseup", onMouseUp);
hook.off("mousemove", onMouseMove);
} else if (typeof hook.removeListener === "function") {
hook.removeListener("mousedown", onMouseDown);
hook.removeListener("mouseup", onMouseUp);
hook.removeListener("mousemove", onMouseMove);
}
} catch {
// ignore listener cleanup errors
}
try {
if (typeof hook.stop === "function") {
hook.stop();
}
} catch {
// ignore hook shutdown errors
}
});
} catch (error) {
if (!hasLoggedInteractionHookFailure) {
setHasLoggedInteractionHookFailure(true);
console.warn("[CursorTelemetry] Global interaction capture unavailable:", error);
}
}
}
+136
View File
@@ -0,0 +1,136 @@
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import type { CursorVisualType } from "../types";
import {
currentCursorVisualType,
nativeCursorMonitorOutputBuffer,
nativeCursorMonitorProcess,
setCurrentCursorVisualType,
setNativeCursorMonitorOutputBuffer,
setNativeCursorMonitorProcess,
} from "../state";
import { getCursorMonitorExePath, ensureNativeCursorMonitorBinary } from "../paths/binaries";
export function emitCursorStateChanged(cursorType: CursorVisualType) {
const { BrowserWindow } = require("electron") as typeof import("electron");
BrowserWindow.getAllWindows().forEach((window) => {
if (!window.isDestroyed()) {
window.webContents.send("cursor-state-changed", { cursorType });
}
});
}
export function handleCursorMonitorStdout(chunk: Buffer) {
setNativeCursorMonitorOutputBuffer(nativeCursorMonitorOutputBuffer + chunk.toString());
const lines = nativeCursorMonitorOutputBuffer.split(/\r?\n/);
setNativeCursorMonitorOutputBuffer(lines.pop() ?? "");
for (const line of lines) {
const match = line.match(/^STATE:(.+)$/);
if (!match) continue;
const next = match[1].trim() as CursorVisualType;
if (
next === "arrow" ||
next === "text" ||
next === "pointer" ||
next === "crosshair" ||
next === "open-hand" ||
next === "closed-hand" ||
next === "resize-ew" ||
next === "resize-ns" ||
next === "not-allowed"
) {
if (currentCursorVisualType !== next) {
setCurrentCursorVisualType(next);
// sampleCursorStateChange is called from cursor/telemetry.ts via the handler
emitCursorStateChanged(next);
}
}
}
}
export function stopNativeCursorMonitor() {
setCurrentCursorVisualType("arrow");
if (!nativeCursorMonitorProcess) {
return;
}
try {
nativeCursorMonitorProcess.stdin.write("stop\n");
} catch {
// ignore stop signal issues
}
try {
nativeCursorMonitorProcess.kill();
} catch {
// ignore kill issues
}
setNativeCursorMonitorProcess(null);
setNativeCursorMonitorOutputBuffer("");
}
export async function startNativeCursorMonitor() {
stopNativeCursorMonitor();
if (process.platform !== "darwin" && process.platform !== "win32") {
setCurrentCursorVisualType("arrow");
return;
}
try {
let helperPath: string;
if (process.platform === "win32") {
helperPath = getCursorMonitorExePath();
try {
// Use F_OK on Windows — X_OK is meaningless and can give false positives
await fs.access(helperPath, fsConstants.F_OK);
} catch {
console.warn("Windows cursor monitor helper missing:", helperPath);
setCurrentCursorVisualType("arrow");
return;
}
} else {
helperPath = await ensureNativeCursorMonitorBinary();
}
setNativeCursorMonitorOutputBuffer("");
setCurrentCursorVisualType("arrow");
let proc: ReturnType<typeof spawn> | null;
try {
proc = spawn(helperPath, [], {
stdio: ["pipe", "pipe", "pipe"],
});
} catch (spawnError) {
console.warn("Failed to spawn cursor monitor:", spawnError);
setNativeCursorMonitorProcess(null);
setCurrentCursorVisualType("arrow");
return;
}
setNativeCursorMonitorProcess(proc as Parameters<typeof setNativeCursorMonitorProcess>[0]);
proc.once("error", (error) => {
console.warn("Native cursor monitor process error:", error);
setNativeCursorMonitorProcess(null);
setNativeCursorMonitorOutputBuffer("");
setCurrentCursorVisualType("arrow");
});
if (proc.stdout) proc.stdout.on("data", handleCursorMonitorStdout);
proc.once("close", () => {
setNativeCursorMonitorProcess(null);
setNativeCursorMonitorOutputBuffer("");
setCurrentCursorVisualType("arrow");
});
} catch (error) {
console.warn("Failed to start native cursor monitor:", error);
setNativeCursorMonitorProcess(null);
setNativeCursorMonitorOutputBuffer("");
setCurrentCursorVisualType("arrow");
}
}
+173
View File
@@ -0,0 +1,173 @@
import fs from "node:fs/promises";
import { getTelemetryPathForVideo, getScreen } from "../utils";
import {
CURSOR_TELEMETRY_VERSION,
MAX_CURSOR_SAMPLES,
CURSOR_SAMPLE_INTERVAL_MS,
} from "../constants";
import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } from "../types";
import {
cursorCaptureInterval,
setCursorCaptureInterval,
cursorCaptureStartTimeMs,
activeCursorSamples,
pendingCursorSamples,
setPendingCursorSamples,
isCursorCaptureActive,
currentCursorVisualType,
linuxCursorScreenPoint,
selectedSource,
selectedWindowBounds,
} from "../state";
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
setCursorCaptureInterval(null);
}
}
export function getNormalizedCursorPoint() {
const fallbackCursor = getScreen().getCursorScreenPoint();
const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null;
const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000;
const primarySf =
process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1;
const cursor = isLinuxCacheFresh
? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf }
: fallbackCursor;
const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null;
if (windowBounds) {
const sf =
process.platform !== "darwin"
? getScreen().getDisplayNearestPoint({
x: windowBounds.x / primarySf,
y: windowBounds.y / primarySf,
}).scaleFactor || 1
: 1;
const width = Math.max(1, windowBounds.width / sf);
const height = Math.max(1, windowBounds.height / sf);
return {
cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1),
cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1),
};
}
const sourceDisplayId = Number(selectedSource?.display_id);
const sourceDisplay = Number.isFinite(sourceDisplayId)
? (getScreen()
.getAllDisplays()
.find((display) => display.id === sourceDisplayId) ?? null)
: null;
const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor);
const bounds = display.bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
return { cx, cy };
}
export function getHookCursorScreenPoint(
event: { x?: number; y?: number; data?: { x?: number; y?: number; screenX?: number; screenY?: number }; screenX?: number; screenY?: number } | null | undefined,
): { x: number; y: number } | null {
const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX;
const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY;
if (
typeof rawX !== "number" ||
!Number.isFinite(rawX) ||
typeof rawY !== "number" ||
!Number.isFinite(rawY)
) {
return null;
}
return { x: rawX, y: rawY };
}
export function pushCursorSample(
cx: number,
cy: number,
timeMs: number,
interactionType: CursorInteractionType = "move",
cursorType?: CursorVisualType,
) {
activeCursorSamples.push({
timeMs: Math.max(0, timeMs),
cx,
cy,
interactionType,
cursorType: cursorType ?? currentCursorVisualType,
} as CursorTelemetryPoint);
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift();
}
}
export function sampleCursorPoint() {
const point = getNormalizedCursorPoint();
if (!point) {
return;
}
pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move");
}
export async function persistPendingCursorTelemetry(videoPath: string) {
const telemetryPath = getTelemetryPathForVideo(videoPath);
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
null,
2,
),
"utf-8",
);
}
setPendingCursorSamples([]);
}
export function snapshotCursorTelemetryForPersistence() {
if (activeCursorSamples.length === 0) {
return;
}
if (pendingCursorSamples.length === 0) {
setPendingCursorSamples([...activeCursorSamples]);
return;
}
const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1;
setPendingCursorSamples([
...pendingCursorSamples,
...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs),
]);
}
export function startCursorSampling() {
stopCursorCapture();
setCursorCaptureInterval(
setInterval(() => {
if (isCursorCaptureActive) {
sampleCursorPoint();
}
}, CURSOR_SAMPLE_INTERVAL_MS),
);
}
// Re-export for consumers that use it from this module
export { getTelemetryPathForVideo } from "../utils";
export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants";
+463
View File
@@ -0,0 +1,463 @@
import type { ChildProcessByStdio } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { Readable, Writable } from "node:stream";
import { app } from "electron";
import type { WebContents } from "electron";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { buildTrimmedSourceAudioFilter, getEditedAudioExtension, getNativeVideoInputByteSize, getPreferredNativeVideoEncoders, buildNativeVideoExportArgs, parseAvailableFfmpegEncoders } from "../nativeVideoExport";
import type { NativeExportEncodingMode, NativeVideoExportFinishOptions } from "../nativeVideoExport";
import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../state";
const execFileAsync = promisify(execFile);
export type NativeVideoExportSession = {
ffmpegProcess: ChildProcessByStdio<Writable, null, Readable>;
outputPath: string;
inputByteSize: number;
inputMode: "rawvideo" | "h264-stream";
maxQueuedWriteBytes: number;
stderrOutput: string;
encoderName: string;
processError: Error | null;
stdinError: Error | null;
terminating: boolean;
writeSequence: Promise<void>;
completionPromise: Promise<void>;
sender: WebContents | null;
pendingWriteRequestIds: Set<number>;
};
export const nativeVideoExportSessions = new Map<string, NativeVideoExportSession>();
export function cleanupNativeVideoExportSessions() {
for (const [sessionId, session] of nativeVideoExportSessions) {
session.terminating = true;
try {
if (!session.ffmpegProcess.stdin.destroyed) {
session.ffmpegProcess.stdin.destroy();
}
} catch {
/* stream may already be closed */
}
try {
session.ffmpegProcess.kill("SIGKILL");
} catch {
/* process may already be exited */
}
nativeVideoExportSessions.delete(sessionId);
}
}
export function getNativeVideoExportMaxQueuedWriteBytes(inputByteSize: number) {
if (inputByteSize === 0) return 8 * 1024 * 1024;
return Math.min(64 * 1024 * 1024, Math.max(16 * 1024 * 1024, inputByteSize * 4));
}
export function isHardwareAcceleratedVideoEncoder(encoderName: string) {
return /(videotoolbox|nvenc|qsv|amf|mf)/i.test(encoderName);
}
export async function removeTemporaryExportFile(filePath: string | null | undefined) {
if (!filePath) {
return;
}
try {
await fs.rm(filePath, { force: true });
} catch {
// Ignore cleanup failures for temp export artifacts.
}
}
export function getNativeVideoExportSessionError(session: NativeVideoExportSession, fallback: string) {
return (
session.stdinError?.message ||
session.processError?.message ||
session.stderrOutput.trim() ||
fallback
);
}
export function sendNativeVideoExportWriteFrameResult(
sender: WebContents | null | undefined,
sessionId: string,
requestId: number,
result: { success: boolean; error?: string },
) {
if (!sender || sender.isDestroyed()) {
return;
}
sender.send("native-video-export-write-frame-result", {
sessionId,
requestId,
...result,
});
}
export function settleNativeVideoExportWriteFrameRequest(
sessionId: string,
session: NativeVideoExportSession,
requestId: number,
result: { success: boolean; error?: string },
) {
session.pendingWriteRequestIds.delete(requestId);
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, result);
}
export function flushNativeVideoExportPendingWriteRequests(
sessionId: string,
session: NativeVideoExportSession,
error: string,
) {
for (const requestId of session.pendingWriteRequestIds) {
sendNativeVideoExportWriteFrameResult(session.sender, sessionId, requestId, {
success: false,
error,
});
}
session.pendingWriteRequestIds.clear();
}
export function isIgnorableNativeVideoExportStreamError(error: Error | null | undefined): boolean {
if (!error) {
return false;
}
const errno = error as NodeJS.ErrnoException;
return (
errno.code === "EPIPE" ||
errno.code === "ERR_STREAM_DESTROYED" ||
/broken pipe|stream destroyed|eof/i.test(error.message)
);
}
export async function waitForNativeVideoExportDrain(session: NativeVideoExportSession) {
if (
session.stdinError ||
session.processError ||
session.ffmpegProcess.stdin.destroyed ||
session.ffmpegProcess.stdin.writableEnded ||
!session.ffmpegProcess.stdin.writable ||
session.ffmpegProcess.stdin.writableLength <= 0
) {
return;
}
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(
new Error("Timed out while waiting for native export writer backpressure to clear"),
);
}, 15000);
const cleanup = () => {
clearTimeout(timeout);
session.ffmpegProcess.stdin.off("drain", handleDrain);
session.ffmpegProcess.stdin.off("error", handleError);
session.ffmpegProcess.off("close", handleClose);
};
const handleDrain = () => {
cleanup();
resolve();
};
const handleError = (error: Error) => {
cleanup();
reject(error);
};
const handleClose = () => {
cleanup();
reject(
new Error(
getNativeVideoExportSessionError(
session,
"Native video export writer closed before draining",
),
),
);
};
session.ffmpegProcess.stdin.once("drain", handleDrain);
session.ffmpegProcess.stdin.once("error", handleError);
session.ffmpegProcess.once("close", handleClose);
});
}
export function getNativeVideoExportFrameLength(frameData: Uint8Array | ArrayBuffer) {
return frameData.byteLength;
}
export async function writeNativeVideoExportFrame(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
if (session.inputMode !== "h264-stream" && getNativeVideoExportFrameLength(frameData) !== session.inputByteSize) {
throw new Error(
`Native video export expected ${session.inputByteSize} bytes per frame but received ${getNativeVideoExportFrameLength(frameData)}`,
);
}
if (
session.stdinError ||
session.processError ||
session.ffmpegProcess.stdin.destroyed ||
session.ffmpegProcess.stdin.writableEnded ||
!session.ffmpegProcess.stdin.writable
) {
throw new Error(
getNativeVideoExportSessionError(
session,
"Native video export encoder is not accepting frames",
),
);
}
const frameBuffer =
frameData instanceof ArrayBuffer
? Buffer.from(frameData)
: Buffer.from(frameData.buffer, frameData.byteOffset, frameData.byteLength);
try {
session.ffmpegProcess.stdin.write(frameBuffer);
} catch (error) {
session.stdinError = error instanceof Error ? error : new Error(String(error));
throw session.stdinError;
}
if (session.ffmpegProcess.stdin.writableLength >= session.maxQueuedWriteBytes) {
try {
await waitForNativeVideoExportDrain(session);
} catch (error) {
session.stdinError = error instanceof Error ? error : new Error(String(error));
throw session.stdinError;
}
}
}
export async function enqueueNativeVideoExportFrameWrite(
session: NativeVideoExportSession,
frameData: Uint8Array | ArrayBuffer,
) {
const writePromise = session.writeSequence.then(async () => {
if (session.terminating) {
throw new Error("Native video export session was cancelled");
}
await writeNativeVideoExportFrame(session, frameData);
});
session.writeSequence = writePromise.catch(() => undefined);
await writePromise;
}
export async function getAvailableNativeVideoEncoders(ffmpegPath: string) {
const { stdout } = await execFileAsync(ffmpegPath, ["-hide_banner", "-encoders"], {
timeout: 15000,
maxBuffer: 20 * 1024 * 1024,
});
return parseAvailableFfmpegEncoders(stdout);
}
export async function probeNativeVideoEncoder(
ffmpegPath: string,
encoderName: string,
encodingMode: NativeExportEncodingMode,
) {
const outputPath = path.join(
app.getPath("temp"),
`recordly-export-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
);
const args = buildNativeVideoExportArgs(
encoderName,
{
width: 64,
height: 64,
frameRate: 1,
bitrate: 1_500_000,
encodingMode,
},
outputPath,
);
return new Promise<boolean>((resolve) => {
const process = spawn(ffmpegPath, args, {
stdio: ["pipe", "ignore", "pipe"],
});
let stderrOutput = "";
const timeout = setTimeout(() => {
try {
process.kill("SIGKILL");
} catch {
// ignore
}
resolve(false);
}, 15000);
process.stderr.on("data", (chunk: Buffer) => {
stderrOutput += chunk.toString();
});
process.on("close", (code) => {
clearTimeout(timeout);
void removeTemporaryExportFile(outputPath);
if (code !== 0 && stderrOutput.trim().length > 0) {
console.warn(
`[native-export] Encoder probe failed for ${encoderName}:`,
stderrOutput.trim(),
);
}
resolve(code === 0);
});
process.stdin.end(Buffer.alloc(getNativeVideoInputByteSize(64, 64), 0));
});
}
export async function resolveNativeVideoEncoder(
ffmpegPath: string,
encodingMode: NativeExportEncodingMode,
) {
if (cachedNativeVideoEncoder?.ffmpegPath === ffmpegPath) {
return cachedNativeVideoEncoder.encoderName;
}
const availableEncoders = await getAvailableNativeVideoEncoders(ffmpegPath);
const candidates = [
...new Set([...getPreferredNativeVideoEncoders(process.platform), "libx264"]),
];
for (const encoderName of candidates) {
if (!availableEncoders.has(encoderName)) {
continue;
}
if (await probeNativeVideoEncoder(ffmpegPath, encoderName, encodingMode)) {
setCachedNativeVideoEncoder({ ffmpegPath, encoderName });
return encoderName;
}
}
throw new Error("No usable FFmpeg encoder was available for native export");
}
export async function muxNativeVideoExportAudio(
videoPath: string,
options: NativeVideoExportFinishOptions,
) {
const audioMode = options.audioMode ?? "none";
if (audioMode === "none") {
return videoPath;
}
const ffmpegPath = getFfmpegBinaryPath();
const tempArtifacts: string[] = [];
let audioInputPath = options.audioSourcePath ?? null;
if (audioMode === "edited-track") {
if (!options.editedAudioData) {
throw new Error("Edited audio data is missing for native export");
}
const extension = getEditedAudioExtension(options.editedAudioMimeType);
audioInputPath = path.join(
app.getPath("temp"),
`recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`,
);
await fs.writeFile(audioInputPath, Buffer.from(options.editedAudioData));
tempArtifacts.push(audioInputPath);
}
if (!audioInputPath) {
return videoPath;
}
const outputPath = path.join(
path.dirname(videoPath),
`${path.basename(videoPath, path.extname(videoPath))}-final.mp4`,
);
const args = [
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
videoPath,
"-i",
audioInputPath,
];
if (audioMode === "trim-source") {
const filter = buildTrimmedSourceAudioFilter(options.trimSegments ?? []);
if (filter) {
args.push("-filter_complex", filter, "-map", "0:v:0", "-map", "[aout]");
} else {
args.push("-map", "0:v:0", "-map", "1:a:0");
}
} else {
args.push("-map", "0:v:0", "-map", "1:a:0");
}
args.push(
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"+faststart",
outputPath,
);
try {
await execFileAsync(ffmpegPath, args, {
timeout: 15 * 60 * 1000,
maxBuffer: 20 * 1024 * 1024,
});
await removeTemporaryExportFile(videoPath);
return outputPath;
} finally {
await Promise.allSettled(
tempArtifacts.map((artifactPath) => removeTemporaryExportFile(artifactPath)),
);
}
}
export async function muxExportedVideoAudioBuffer(
videoData: ArrayBuffer,
options: NativeVideoExportFinishOptions,
) {
const tempVideoPath = path.join(
app.getPath("temp"),
`recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
);
try {
await fs.writeFile(tempVideoPath, Buffer.from(videoData));
const finalizedPath = await muxNativeVideoExportAudio(tempVideoPath, options);
const muxedData = await fs.readFile(finalizedPath);
return new Uint8Array(muxedData);
} finally {
await Promise.allSettled([
removeTemporaryExportFile(tempVideoPath),
removeTemporaryExportFile(`${tempVideoPath}.muxed.mp4`),
removeTemporaryExportFile(
path.join(
path.dirname(tempVideoPath),
`${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
),
),
]);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { app } from "electron";
const nodeRequire = createRequire(import.meta.url);
export function loadFfmpegStatic(): string | null {
const moduleExports = nodeRequire("ffmpeg-static");
if (typeof moduleExports === "string") {
return moduleExports;
}
if (typeof moduleExports?.default === "string") {
return moduleExports.default as string;
}
return null;
}
export function resolveSystemFfmpegBinaryPath(): string | null {
const locator = process.platform === "win32" ? "where" : "which";
const result = spawnSync(locator, ["ffmpeg"], {
encoding: "utf-8",
windowsHide: true,
});
if (result.status === 0) {
const candidate = result.stdout
.split(/\r?\n/)
.map((line: string) => line.trim())
.find((line: string) => line.length > 0);
if (candidate) {
return candidate;
}
}
// Fallback: check common install paths directly (Electron's shell may lack full PATH)
if (process.platform !== "win32") {
const commonPaths = [
"/opt/homebrew/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/usr/bin/ffmpeg",
];
for (const p of commonPaths) {
if (existsSync(p)) {
return p;
}
}
}
return null;
}
export function getFfmpegBinaryPath(): string {
const ffmpegStatic = loadFfmpegStatic();
if (ffmpegStatic && typeof ffmpegStatic === "string") {
const bundledPath = app.isPackaged
? ffmpegStatic.replace(/\.asar([/\\])/, ".asar.unpacked$1")
: ffmpegStatic;
if (existsSync(bundledPath)) {
return bundledPath;
}
}
const systemFfmpeg = resolveSystemFfmpegBinaryPath();
if (systemFfmpeg) {
return systemFfmpeg;
}
throw new Error(
"FFmpeg binary is unavailable. Install ffmpeg-static for this platform or make ffmpeg available on PATH.",
);
}
+194
View File
@@ -0,0 +1,194 @@
import type { AudioSyncAdjustment, PauseSegment } from "../types";
export function buildAtempoFilters(tempoRatio: number): string[] {
if (!Number.isFinite(tempoRatio) || tempoRatio <= 0) {
return [];
}
const filters: string[] = [];
let remaining = tempoRatio;
while (remaining < 0.5) {
filters.push("atempo=0.5");
remaining /= 0.5;
}
while (remaining > 2) {
filters.push("atempo=2.0");
remaining /= 2.0;
}
if (Math.abs(remaining - 1) > 0.0005) {
filters.push(`atempo=${remaining.toFixed(6)}`);
}
return filters;
}
export function getAudioSyncAdjustment(
videoDuration: number,
audioDuration: number,
): AudioSyncAdjustment {
if (
!Number.isFinite(videoDuration) ||
!Number.isFinite(audioDuration) ||
videoDuration <= 0 ||
audioDuration <= 0
) {
return { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 };
}
const durationDeltaMs = Math.round((videoDuration - audioDuration) * 1000);
const absDeltaMs = Math.abs(durationDeltaMs);
if (absDeltaMs <= 50) {
return { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs };
}
const tempoRatio = Math.max(0.5, Math.min(2, audioDuration / videoDuration));
const relativeDelta = absDeltaMs / Math.max(videoDuration * 1000, 1);
if (relativeDelta <= 0.03 || absDeltaMs <= 1500 || durationDeltaMs < 0) {
return { mode: "tempo", delayMs: 0, tempoRatio, durationDeltaMs };
}
return { mode: "delay", delayMs: durationDeltaMs, tempoRatio: 1, durationDeltaMs };
}
export function appendSyncedAudioFilter(
filterParts: string[],
inputLabel: string,
outputLabel: string,
adjustment: AudioSyncAdjustment,
) {
const filters: string[] = [];
if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
filters.push(`adelay=${adjustment.delayMs}|${adjustment.delayMs}`);
}
if (adjustment.mode === "tempo") {
filters.push(...buildAtempoFilters(adjustment.tempoRatio));
}
filters.push("aresample=async=1:first_pts=0", "asetpts=PTS-STARTPTS");
filterParts.push(`${inputLabel}${filters.join(",")}[${outputLabel}]`);
}
export function formatFfmpegSeconds(milliseconds: number): string {
return (milliseconds / 1000).toFixed(3);
}
export function normalizePauseSegments(
pauseSegments: PauseSegment[] | undefined,
): PauseSegment[] {
if (!Array.isArray(pauseSegments) || pauseSegments.length === 0) {
return [];
}
const normalized = pauseSegments
.map((segment) => {
const startMs = Number(segment?.startMs);
const endMs = Number(segment?.endMs);
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return null;
}
const clampedStart = Math.max(0, Math.round(startMs));
const clampedEnd = Math.max(0, Math.round(endMs));
if (clampedEnd <= clampedStart) {
return null;
}
return { startMs: clampedStart, endMs: clampedEnd };
})
.filter((segment): segment is PauseSegment => !!segment)
.sort((left, right) => left.startMs - right.startMs);
if (normalized.length <= 1) {
return normalized;
}
const merged: PauseSegment[] = [{ ...normalized[0] }];
for (const segment of normalized.slice(1)) {
const previous = merged[merged.length - 1];
if (segment.startMs <= previous.endMs) {
previous.endMs = Math.max(previous.endMs, segment.endMs);
} else {
merged.push({ ...segment });
}
}
return merged;
}
export function buildPausedAudioFilter(
inputLabel: string,
outputLabel: string,
pauseSegments: PauseSegment[],
): string | null {
if (pauseSegments.length === 0) {
return null;
}
const activeSegments: Array<{ startMs: number; endMs?: number }> = [];
let cursorMs = 0;
for (const pauseSegment of pauseSegments) {
if (pauseSegment.startMs > cursorMs) {
activeSegments.push({ startMs: cursorMs, endMs: pauseSegment.startMs });
}
cursorMs = Math.max(cursorMs, pauseSegment.endMs);
}
activeSegments.push({ startMs: cursorMs });
const filterParts: string[] = [];
const segmentLabels: string[] = [];
activeSegments.forEach((segment, index) => {
if (typeof segment.endMs === "number" && segment.endMs <= segment.startMs) {
return;
}
const segmentLabel = `${outputLabel}_part${index}`;
const trimArgs =
typeof segment.endMs === "number"
? `start=${formatFfmpegSeconds(segment.startMs)}:end=${formatFfmpegSeconds(segment.endMs)}`
: `start=${formatFfmpegSeconds(segment.startMs)}`;
filterParts.push(`[${inputLabel}]atrim=${trimArgs},asetpts=PTS-STARTPTS[${segmentLabel}]`);
segmentLabels.push(`[${segmentLabel}]`);
});
if (segmentLabels.length === 0) {
return null;
}
if (segmentLabels.length === 1) {
filterParts.push(`${segmentLabels[0]}anull[${outputLabel}]`);
} else {
filterParts.push(
`${segmentLabels.join("")}concat=n=${segmentLabels.length}:v=0:a=1[${outputLabel}]`,
);
}
return filterParts.join(";");
}
export function parseFfmpegDurationSeconds(stderr: string): number | null {
const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
if (![hours, minutes, seconds].every(Number.isFinite)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
+337 -4392
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
import { spawnSync } from "node:child_process";
import { existsSync, constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
import {
nativeHelperMigrationPromise,
setNativeHelperMigrationPromise,
} from "../state";
/**
* Resolve a path within the app bundle, handling asar unpacking in production.
* Files listed in asarUnpack are extracted to app.asar.unpacked/ and must be
* accessed via that path instead of the asar virtual filesystem.
*/
export function resolveUnpackedAppPath(...segments: string[]): string {
const base = app.getAppPath();
const resolved = path.join(base, ...segments);
if (app.isPackaged) {
return resolved.replace(/\.asar([/\\])/, ".asar.unpacked$1");
}
return resolved;
}
export function getNativeCaptureHelperSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitRecorder.swift");
}
export function getNativeArchTag(): string {
if (process.platform === "darwin") {
return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
}
if (process.platform === "win32") {
return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
}
if (process.platform === "linux") {
return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
}
return `${process.platform}-${process.arch}`;
}
export function getPrebundledNativeHelperPath(binaryName: string): string {
return resolveUnpackedAppPath("electron", "native", "bin", getNativeArchTag(), binaryName);
}
export function resolvePreferredWindowsNativeHelperPath(
helperDirectory: string,
binaryName: string,
): string {
const buildOutputPath = resolveUnpackedAppPath(
"electron",
"native",
helperDirectory,
"build",
"Release",
binaryName,
);
const prebundledPath = getPrebundledNativeHelperPath(binaryName);
if (existsSync(buildOutputPath)) {
return buildOutputPath;
}
if (existsSync(prebundledPath)) {
return prebundledPath;
}
return buildOutputPath;
}
export function getBundledWhisperExecutableCandidates(): string[] {
const binaryNames =
process.platform === "win32"
? ["whisper-cli.exe", "whisper-cpp.exe", "whisper.exe", "main.exe"]
: ["whisper-cli", "whisper-cpp", "whisper", "main"];
return binaryNames.map((binaryName) => getPrebundledNativeHelperPath(binaryName));
}
export function getNativeCaptureHelperBinaryPath(): string {
return path.join(app.getPath("userData"), "native-tools", "recordly-screencapturekit-helper");
}
export function getSystemCursorHelperSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "SystemCursorAssets.swift");
}
export function getSystemCursorHelperBinaryPath(): string {
return path.join(app.getPath("userData"), "native-tools", "recordly-system-cursors");
}
export function getNativeCursorMonitorSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "NativeCursorMonitor.swift");
}
export function getNativeCursorMonitorBinaryPath(): string {
return path.join(app.getPath("userData"), "native-tools", "recordly-native-cursor-monitor");
}
export function getNativeWindowListSourcePath(): string {
return resolveUnpackedAppPath("electron", "native", "ScreenCaptureKitWindowList.swift");
}
export function getNativeWindowListBinaryPath(): string {
return path.join(app.getPath("userData"), "native-tools", "recordly-window-list");
}
export function getWindowsCaptureExePath(): string {
return resolvePreferredWindowsNativeHelperPath("wgc-capture", "wgc-capture.exe");
}
export function getCursorMonitorExePath(): string {
return resolvePreferredWindowsNativeHelperPath("cursor-monitor", "cursor-monitor.exe");
}
async function migrateLegacyNativeHelperBinaries(): Promise<void> {
const legacyToCurrentPaths: Array<[string, string]> = [
[
path.join(app.getPath("userData"), "native-tools", "openscreen-screencapturekit-helper"),
getNativeCaptureHelperBinaryPath(),
],
[
path.join(app.getPath("userData"), "native-tools", "openscreen-window-list"),
getNativeWindowListBinaryPath(),
],
[
path.join(app.getPath("userData"), "native-tools", "openscreen-system-cursors"),
getSystemCursorHelperBinaryPath(),
],
[
path.join(app.getPath("userData"), "native-tools", "openscreen-native-cursor-monitor"),
getNativeCursorMonitorBinaryPath(),
],
];
for (const [legacyPath, currentPath] of legacyToCurrentPaths) {
if (legacyPath === currentPath || existsSync(currentPath) || !existsSync(legacyPath)) {
continue;
}
try {
await fs.mkdir(path.dirname(currentPath), { recursive: true });
await fs.rename(legacyPath, currentPath);
} catch (error) {
console.warn("[native-tools] Failed to migrate helper binary", {
legacyPath,
currentPath,
error,
});
}
}
}
export async function ensureNativeHelperMigration(): Promise<void> {
if (!nativeHelperMigrationPromise) {
setNativeHelperMigrationPromise(
migrateLegacyNativeHelperBinaries().catch((error) => {
setNativeHelperMigrationPromise(null);
throw error;
}),
);
}
return nativeHelperMigrationPromise!;
}
export async function ensureSwiftHelperBinary(
sourcePath: string,
binaryPath: string,
label: string,
prebundledBinaryName?: string,
): Promise<string> {
if (prebundledBinaryName) {
const prebundledPath = getPrebundledNativeHelperPath(prebundledBinaryName);
try {
await fs.access(prebundledPath, fsConstants.X_OK);
return prebundledPath;
} catch {
if (app.isPackaged) {
throw new Error(
`${label} is missing from this app build (${prebundledPath}). Reinstall or update the app.`,
);
}
}
}
const helperDir = path.dirname(binaryPath);
await fs.mkdir(helperDir, { recursive: true });
let shouldCompile = false;
try {
const [sourceStat, binaryStat] = await Promise.all([
fs.stat(sourcePath),
fs.stat(binaryPath).catch(() => null),
]);
shouldCompile = !binaryStat || sourceStat.mtimeMs > binaryStat.mtimeMs;
} catch (error) {
throw new Error(`${label} source is unavailable: ${String(error)}`);
}
if (!shouldCompile) {
return binaryPath;
}
const result = spawnSync("swiftc", ["-O", sourcePath, "-o", binaryPath], {
encoding: "utf8",
timeout: 120000,
});
if (result.status !== 0) {
const details = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
throw new Error(details || `Failed to compile ${label}`);
}
return binaryPath;
}
export async function ensureNativeCaptureHelperBinary(): Promise<string> {
await ensureNativeHelperMigration();
return ensureSwiftHelperBinary(
getNativeCaptureHelperSourcePath(),
getNativeCaptureHelperBinaryPath(),
"native ScreenCaptureKit helper",
"recordly-screencapturekit-helper",
);
}
export async function ensureNativeWindowListBinary(): Promise<string> {
await ensureNativeHelperMigration();
return ensureSwiftHelperBinary(
getNativeWindowListSourcePath(),
getNativeWindowListBinaryPath(),
"native ScreenCaptureKit window list helper",
"recordly-window-list",
);
}
export async function ensureNativeCursorMonitorBinary(): Promise<string> {
await ensureNativeHelperMigration();
return ensureSwiftHelperBinary(
getNativeCursorMonitorSourcePath(),
getNativeCursorMonitorBinaryPath(),
"native cursor monitor helper",
"recordly-native-cursor-monitor",
);
}
+337
View File
@@ -0,0 +1,337 @@
import { constants as fsConstants } from "node:fs";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { app } from "electron";
import { RECORDINGS_DIR, USER_DATA_PATH } from "../../appPaths";
import {
PROJECT_FILE_EXTENSION,
LEGACY_PROJECT_FILE_EXTENSIONS,
PROJECTS_DIRECTORY_NAME,
PROJECT_THUMBNAIL_SUFFIX,
RECENT_PROJECTS_FILE,
MAX_RECENT_PROJECTS,
RECORDINGS_SETTINGS_FILE,
} from "../constants";
import type { ProjectLibraryEntry, RecordingSessionData } from "../types";
import {
currentProjectPath,
setCurrentProjectPath,
setCurrentVideoPath,
setCurrentRecordingSession,
approvedLocalReadPaths,
setCustomRecordingsDir,
setRecordingsDirLoaded,
} from "../state";
import {
normalizePath,
normalizeVideoSourcePath,
getRecordingsDir,
} from "../utils";
export { normalizePath, normalizeVideoSourcePath };
export function getAssetRootPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, "assets");
}
return path.join(app.getAppPath(), "public");
}
export function isPathInsideDirectory(candidatePath: string, directoryPath: string) {
const normalizedDirectoryPath = normalizePath(directoryPath);
return (
candidatePath === normalizedDirectoryPath ||
candidatePath.startsWith(`${normalizedDirectoryPath}${path.sep}`)
);
}
export function isAllowedLocalReadPath(candidatePath: string) {
const allowedPrefixes = [RECORDINGS_DIR, USER_DATA_PATH, getAssetRootPath(), app.getPath("temp")];
return (
existsSync(candidatePath) ||
allowedPrefixes.some((prefix) => isPathInsideDirectory(candidatePath, prefix)) ||
approvedLocalReadPaths.has(candidatePath)
);
}
export async function rememberApprovedLocalReadPath(filePath?: string | null) {
const normalizedPath = normalizeVideoSourcePath(filePath);
if (!normalizedPath) {
return;
}
const resolvedPath = normalizePath(normalizedPath);
approvedLocalReadPaths.add(resolvedPath);
try {
approvedLocalReadPaths.add(await fs.realpath(resolvedPath));
} catch {
// Ignore missing files; the eventual read will surface the real error.
}
}
export async function replaceApprovedSessionLocalReadPaths(filePaths: Array<string | null | undefined>) {
approvedLocalReadPaths.clear();
await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath)));
}
export async function resolveProjectMediaSources(project: unknown): Promise<
| { success: true; videoPath: string; webcamPath: string | null }
| { success: false; message: string }
> {
if (!project || typeof project !== "object") {
return { success: false, message: "Invalid project file format" };
}
const rawVideoPath = (project as { videoPath?: unknown }).videoPath;
if (typeof rawVideoPath !== "string") {
return { success: false, message: "Project file is missing a video path" };
}
const normalizedVideoPath = normalizeVideoSourcePath(rawVideoPath);
if (!normalizedVideoPath) {
return { success: false, message: "Project file is missing a valid video path" };
}
try {
await fs.access(normalizedVideoPath, fsConstants.F_OK);
} catch {
return {
success: false,
message: `Project video file not found: ${normalizedVideoPath}`,
};
}
const rawWebcamPath =
typeof (project as { editor?: { webcam?: { sourcePath?: unknown } } }).editor?.webcam
?.sourcePath === "string"
? ((project as { editor?: { webcam?: { sourcePath?: string } } }).editor?.webcam
?.sourcePath ?? null)
: null;
const normalizedWebcamPath = normalizeVideoSourcePath(rawWebcamPath);
if (!normalizedWebcamPath) {
return {
success: true,
videoPath: normalizedVideoPath,
webcamPath: null,
};
}
try {
await fs.access(normalizedWebcamPath, fsConstants.F_OK);
return {
success: true,
videoPath: normalizedVideoPath,
webcamPath: normalizedWebcamPath,
};
} catch {
return {
success: true,
videoPath: normalizedVideoPath,
webcamPath: null,
};
}
}
export async function getProjectsDir() {
const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME);
await fs.mkdir(projectsDir, { recursive: true });
return projectsDir;
}
export async function persistRecordingsDirectorySetting(nextDir: string) {
setCustomRecordingsDir(path.resolve(nextDir));
setRecordingsDirLoaded(true);
await fs.writeFile(
RECORDINGS_SETTINGS_FILE,
JSON.stringify({ recordingsDir: path.resolve(nextDir) }, null, 2),
"utf-8",
);
}
export function hasProjectFileExtension(filePath: string) {
const extension = path.extname(filePath).replace(/^\./, "").toLowerCase();
return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension);
}
export function getProjectThumbnailPath(projectPath: string) {
return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`;
}
export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
const thumbnailPath = getProjectThumbnailPath(projectPath);
if (!thumbnailDataUrl) {
await fs.rm(thumbnailPath, { force: true }).catch(() => undefined);
return null;
}
const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/);
if (!match) {
throw new Error("Project thumbnail must be a PNG data URL.");
}
await fs.writeFile(thumbnailPath, Buffer.from(match[1], "base64"));
return thumbnailPath;
}
export async function loadRecentProjectPaths() {
try {
const content = await fs.readFile(RECENT_PROJECTS_FILE, "utf-8");
const parsed = JSON.parse(content) as { paths?: unknown };
return Array.isArray(parsed.paths)
? parsed.paths.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
: [];
} catch {
return [];
}
}
export async function saveRecentProjectPaths(paths: string[]) {
const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice(
0,
MAX_RECENT_PROJECTS,
);
await fs.writeFile(
RECENT_PROJECTS_FILE,
JSON.stringify({ paths: normalizedPaths }, null, 2),
"utf-8",
);
}
export async function rememberRecentProject(projectPath: string) {
if (!hasProjectFileExtension(projectPath)) {
return;
}
const existingPaths = await loadRecentProjectPaths();
await saveRecentProjectPaths([projectPath, ...existingPaths]);
}
export async function buildProjectLibraryEntry(
projectPath: string,
projectsDir: string,
): Promise<ProjectLibraryEntry | null> {
try {
const normalizedPath = normalizePath(projectPath);
if (!hasProjectFileExtension(normalizedPath)) {
return null;
}
const stats = await fs.stat(normalizedPath);
if (!stats.isFile()) {
return null;
}
const thumbnailPath = getProjectThumbnailPath(normalizedPath);
const thumbnailExists = await fs
.access(thumbnailPath, fsConstants.R_OK)
.then(() => true)
.catch(() => false);
return {
path: normalizedPath,
name: path.basename(normalizedPath).replace(/\.(recordly|openscreen)$/i, ""),
updatedAt: stats.mtimeMs,
thumbnailPath: thumbnailExists ? thumbnailPath : null,
isCurrent: Boolean(
currentProjectPath && normalizePath(currentProjectPath) === normalizedPath,
),
isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir),
};
} catch {
return null;
}
}
export async function listProjectLibraryEntries() {
const projectsDir = await getProjectsDir();
const projectPaths: string[] = [];
try {
const entries = await fs.readdir(projectsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const entryPath = path.join(projectsDir, entry.name);
if (hasProjectFileExtension(entryPath)) {
projectPaths.push(entryPath);
}
}
} catch {
// Ignore directory read failures and fall back to recent files.
}
const recentProjectPaths = await loadRecentProjectPaths();
const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths]));
const entries = (
await Promise.all(
candidatePaths.map((candidatePath) =>
buildProjectLibraryEntry(candidatePath, projectsDir),
),
)
)
.filter((entry): entry is ProjectLibraryEntry => entry != null)
.sort((left, right) => right.updatedAt - left.updatedAt);
await saveRecentProjectPaths(entries.map((entry) => entry.path));
return {
projectsDir,
entries,
};
}
export async function loadProjectFromPath(projectPath: string) {
const normalizedPath = normalizePath(projectPath);
const content = await fs.readFile(normalizedPath, "utf-8");
const project = JSON.parse(content);
const mediaSources = await resolveProjectMediaSources(project);
if (!mediaSources.success) {
return {
success: false,
canceled: false,
message: mediaSources.message,
};
}
setCurrentProjectPath(normalizedPath);
setCurrentVideoPath(mediaSources.videoPath);
const projectObj = project as Record<string, unknown>;
const editorObj = projectObj?.editor as Record<string, unknown> | undefined;
const audioTracks = editorObj?.audioTracks as { sourcePath?: unknown }[] | undefined;
const approvedProjectPaths: Array<string | null | undefined> = [
mediaSources.videoPath,
mediaSources.webcamPath,
];
if (Array.isArray(audioTracks)) {
for (const track of audioTracks) {
if (typeof track?.sourcePath === "string") {
approvedProjectPaths.push(track.sourcePath);
}
}
}
await replaceApprovedSessionLocalReadPaths(approvedProjectPaths);
setCurrentRecordingSession({
videoPath: mediaSources.videoPath,
webcamPath: mediaSources.webcamPath,
timeOffsetMs: 0,
} as RecordingSessionData);
await rememberRecentProject(normalizedPath);
return {
success: true,
path: normalizedPath,
project,
};
}
+138
View File
@@ -0,0 +1,138 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { RECORDING_SESSION_MANIFEST_SUFFIX } from "../constants";
import type { RecordingSessionData, RecordingSessionManifest } from "../types";
import { normalizeVideoSourcePath } from "../utils";
function normalizeRecordingTimeOffsetMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
}
export function getRecordingSessionManifestPath(videoPath: string) {
const extension = path.extname(videoPath);
const baseName = path.basename(videoPath, extension);
return path.join(path.dirname(videoPath), `${baseName}${RECORDING_SESSION_MANIFEST_SUFFIX}`);
}
export async function persistRecordingSessionManifest(session: RecordingSessionData): Promise<void> {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath);
if (!normalizedVideoPath) {
return;
}
const normalizedWebcamPath = normalizeVideoSourcePath(session.webcamPath ?? null);
const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath);
if (!normalizedWebcamPath) {
await fs.rm(manifestPath, { force: true });
return;
}
const manifest: RecordingSessionManifest = {
version: 2,
videoFileName: path.basename(normalizedVideoPath),
webcamFileName: path.basename(normalizedWebcamPath),
timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
};
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
}
export async function resolveRecordingSessionManifest(
videoPath?: string | null,
): Promise<RecordingSessionData | null> {
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
if (!normalizedVideoPath) {
return null;
}
const manifestPath = getRecordingSessionManifestPath(normalizedVideoPath);
try {
const content = await fs.readFile(manifestPath, "utf-8");
const parsed = JSON.parse(content) as Partial<RecordingSessionManifest>;
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
const webcamFileName =
typeof parsed.webcamFileName === "string" && parsed.webcamFileName.trim()
? parsed.webcamFileName.trim()
: null;
if (!webcamFileName) {
return {
videoPath: normalizedVideoPath,
webcamPath: null,
timeOffsetMs: 0,
};
}
const webcamPath = path.join(path.dirname(normalizedVideoPath), webcamFileName);
await fs.access(webcamPath, fsConstants.F_OK);
return {
videoPath: normalizedVideoPath,
webcamPath,
timeOffsetMs: normalizeRecordingTimeOffsetMs(parsed.timeOffsetMs),
};
} catch {
return null;
}
}
export async function resolveLinkedWebcamPath(videoPath?: string | null): Promise<string | null> {
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
if (!normalizedVideoPath) {
return null;
}
const extension = path.extname(normalizedVideoPath);
const baseName = path.basename(normalizedVideoPath, extension);
if (!baseName || baseName.endsWith("-webcam")) {
return null;
}
const candidateExtensions = Array.from(
new Set([extension, ".webm", ".mp4", ".mov", ".mkv", ".avi"].filter(Boolean)),
);
for (const candidateExtension of candidateExtensions) {
const candidatePath = path.join(
path.dirname(normalizedVideoPath),
`${baseName}-webcam${candidateExtension}`,
);
try {
await fs.access(candidatePath, fsConstants.F_OK);
return candidatePath;
} catch {
continue;
}
}
return null;
}
export async function resolveRecordingSession(
videoPath?: string | null,
): Promise<RecordingSessionData | null> {
const manifestSession = await resolveRecordingSessionManifest(videoPath);
if (manifestSession) {
return manifestSession;
}
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
if (!normalizedVideoPath) {
return null;
}
const linkedWebcamPath = await resolveLinkedWebcamPath(normalizedVideoPath);
return {
videoPath: normalizedVideoPath,
webcamPath: linkedWebcamPath,
};
}
+175
View File
@@ -0,0 +1,175 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import { COMPANION_AUDIO_LAYOUTS } from "../constants";
import type { NativeCaptureDiagnostics, CompanionAudioCandidate } from "../types";
import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from "../state";
const execFileAsync = promisify(execFile);
export function recordNativeCaptureDiagnostics(
diagnostics: Omit<NativeCaptureDiagnostics, "timestamp">,
) {
setLastNativeCaptureDiagnostics({
timestamp: new Date().toISOString(),
...diagnostics,
});
return lastNativeCaptureDiagnostics;
}
export async function getFileSizeIfPresent(filePath: string | null | undefined) {
if (!filePath) {
return null;
}
try {
const stat = await fs.stat(filePath);
return stat.size;
} catch {
return null;
}
}
export function parseFfmpegDurationSeconds(stderr: string) {
const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
if (![hours, minutes, seconds].every(Number.isFinite)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
/** Probe the duration of a media file (in seconds) using the container header. */
export async function probeMediaDurationSeconds(filePath: string): Promise<number> {
const ffmpegPath = getFfmpegBinaryPath();
try {
await execFileAsync(ffmpegPath, ["-i", filePath, "-hide_banner"], { timeout: 5000 });
} catch (error) {
const stderr = (error as NodeJS.ErrnoException & { stderr?: string })?.stderr ?? "";
const match = stderr.match(/Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d{2,3})/);
if (match) {
const h = Number(match[1]);
const m = Number(match[2]);
const s = Number(match[3]);
const frac = Number(match[4]) / (match[4].length === 3 ? 1000 : 100);
return h * 3600 + m * 60 + s + frac;
}
}
return 0;
}
export async function getUsableCompanionAudioCandidates(
videoPath: string,
): Promise<CompanionAudioCandidate[]> {
const basePath = videoPath.replace(/\.[^.]+$/u, "");
const candidates: CompanionAudioCandidate[] = [];
for (const layout of COMPANION_AUDIO_LAYOUTS) {
const systemPath = `${basePath}${layout.systemSuffix}`;
const micPath = `${basePath}${layout.micSuffix}`;
const usablePaths: string[] = [];
for (const companionPath of [systemPath, micPath]) {
try {
const stat = await fs.stat(companionPath);
if (stat.size > 0) {
usablePaths.push(companionPath);
}
} catch {
// Missing companion audio is expected for many recordings.
}
}
if (usablePaths.length > 0) {
candidates.push({
platform: layout.platform,
systemPath,
micPath,
usablePaths,
});
}
}
return candidates;
}
export async function hasEmbeddedAudioStream(videoPath: string) {
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
try {
const result = await execFileAsync(
ffmpegPath,
["-hide_banner", "-i", videoPath, "-map", "0:a:0", "-frames:a", "1", "-f", "null", "-"],
{ timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
);
stderr = result.stderr;
} catch (error) {
stderr = (error as NodeJS.ErrnoException & { stderr?: string }).stderr ?? "";
}
return /Stream #.*Audio:/i.test(stderr);
}
export async function getCompanionAudioFallbackPaths(videoPath: string) {
const companionCandidates = await getUsableCompanionAudioCandidates(videoPath);
if (companionCandidates.length === 0) {
return [];
}
if (await hasEmbeddedAudioStream(videoPath)) {
return [];
}
return companionCandidates.flatMap((candidate) => candidate.usablePaths);
}
export async function validateRecordedVideo(videoPath: string) {
const stat = await fs.stat(videoPath);
if (!stat.isFile()) {
throw new Error(`Recorded output is not a file: ${videoPath}`);
}
if (stat.size <= 0) {
throw new Error(`Recorded output is empty: ${videoPath}`);
}
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
try {
const result = await execFileAsync(
ffmpegPath,
["-hide_banner", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"],
{ timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
);
stderr = result.stderr;
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stderr?: string };
const output = execError.stderr?.trim();
throw new Error(output || `Recorded output could not be decoded: ${videoPath}`);
}
if (!/Stream #.*Video:/i.test(stderr)) {
throw new Error(`Recorded output does not contain a readable video stream: ${videoPath}`);
}
const durationSeconds = parseFfmpegDurationSeconds(stderr);
if (durationSeconds !== null && durationSeconds <= 0) {
throw new Error(`Recorded output has an invalid duration: ${videoPath}`);
}
return {
fileSizeBytes: stat.size,
durationSeconds,
};
}
+9
View File
@@ -0,0 +1,9 @@
import { BrowserWindow } from "electron";
export function emitRecordingInterrupted(reason: string, message: string) {
BrowserWindow.getAllWindows().forEach((window) => {
if (!window.isDestroyed()) {
window.webContents.send("recording-interrupted", { reason, message });
}
});
}
+204
View File
@@ -0,0 +1,204 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { SelectedSource } from "../types";
import {
ffmpegCaptureOutputBuffer,
} from "../state";
import { getScreen } from "../utils";
import { resolveWindowsCaptureDisplay } from "../windowsCaptureSelection";
import { resolveLinuxWindowBounds } from "../cursor/bounds";
export function getDisplayBoundsForSource(source: SelectedSource) {
return resolveWindowsCaptureDisplay(
source,
getScreen().getAllDisplays(),
getScreen().getPrimaryDisplay(),
).bounds;
}
export async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string) {
const commonOutputArgs = [
"-an",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
outputPath,
];
if (process.platform === "win32") {
if (source?.id?.startsWith("window:")) {
const windowTitle =
typeof source.windowTitle === "string"
? source.windowTitle.trim()
: source.name.trim();
if (!windowTitle) {
throw new Error("Missing window title for FFmpeg window capture");
}
return [
"-y",
"-f",
"gdigrab",
"-framerate",
"60",
"-draw_mouse",
"0",
"-i",
`title=${windowTitle}`,
...commonOutputArgs,
];
}
return [
"-y",
"-f",
"gdigrab",
"-framerate",
"60",
"-draw_mouse",
"0",
"-i",
"desktop",
...commonOutputArgs,
];
}
if (process.platform === "linux") {
const displayEnv = process.env.DISPLAY || ":0.0";
if (source?.id?.startsWith("window:")) {
const bounds = await resolveLinuxWindowBounds(source);
if (!bounds) {
throw new Error("Unable to resolve Linux window bounds for FFmpeg capture");
}
return [
"-y",
"-f",
"x11grab",
"-framerate",
"60",
"-draw_mouse",
"0",
"-video_size",
`${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
"-i",
`${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
...commonOutputArgs,
];
}
const bounds = getDisplayBoundsForSource(source);
return [
"-y",
"-f",
"x11grab",
"-framerate",
"60",
"-draw_mouse",
"0",
"-video_size",
`${Math.max(2, bounds.width)}x${Math.max(2, bounds.height)}`,
"-i",
`${displayEnv}+${Math.round(bounds.x)},${Math.round(bounds.y)}`,
...commonOutputArgs,
];
}
if (process.platform === "darwin") {
return [
"-y",
"-f",
"avfoundation",
"-capture_cursor",
"0",
"-framerate",
"60",
"-i",
"1:none",
...commonOutputArgs,
];
}
throw new Error(`FFmpeg capture is not supported on ${process.platform}`);
}
export function waitForFfmpegCaptureStart(process: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onExit = (code: number | null) => {
cleanup();
reject(
new Error(
ffmpegCaptureOutputBuffer.trim() ||
`FFmpeg exited before recording started (code ${code ?? "unknown"})`,
),
);
};
const timer = setTimeout(() => {
cleanup();
resolve();
}, 900);
const cleanup = () => {
clearTimeout(timer);
process.off("error", onError);
process.off("exit", onExit);
};
process.once("error", onError);
process.once("exit", onExit);
});
}
export function waitForFfmpegCaptureStop(process: ChildProcessWithoutNullStreams, outputPath: string) {
return new Promise<string>((resolve, reject) => {
const onClose = async (code: number | null) => {
cleanup();
try {
const { access } = await import("node:fs/promises");
await access(outputPath);
if (code === 0 || code === null) {
resolve(outputPath);
return;
}
if (ffmpegCaptureOutputBuffer.includes("Exiting normally")) {
resolve(outputPath);
return;
}
} catch {
// handled below
}
reject(
new Error(
ffmpegCaptureOutputBuffer.trim() ||
`FFmpeg 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);
});
}
+459
View File
@@ -0,0 +1,459 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
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";
import {
nativeScreenRecordingActive,
setNativeScreenRecordingActive,
setNativeCaptureProcess,
nativeCaptureOutputBuffer,
nativeCaptureTargetPath,
setNativeCaptureTargetPath,
nativeCaptureStopRequested,
setNativeCaptureStopRequested,
nativeCaptureSystemAudioPath,
setNativeCaptureSystemAudioPath,
nativeCaptureMicrophonePath,
setNativeCaptureMicrophonePath,
lastNativeCaptureDiagnostics,
setCurrentVideoPath,
setCurrentProjectPath,
selectedSource,
} from "../state";
import { moveFileWithOverwrite, isAutoRecordingPath } from "../utils";
import {
recordNativeCaptureDiagnostics,
getFileSizeIfPresent,
validateRecordedVideo,
getUsableCompanionAudioCandidates,
} 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);
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);
const onStdout = (chunk: Buffer) => {
const text = chunk.toString();
if (text.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 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,
) {
const ffmpegPath = getFfmpegBinaryPath();
const mixedOutputPath = `${videoPath}.mixed.mp4`;
const inputs = ["-i", videoPath];
const availableAudioInputs: string[] = [];
const audioFilePaths: string[] = [];
for (const [label, audioPath] of [
["system", systemAudioPath],
["microphone", microphonePath],
] as const) {
if (!audioPath) continue;
try {
const stat = await fs.stat(audioPath);
if (stat.size <= 0) {
console.warn(`[mux] Skipping ${label} audio: file is empty (${audioPath})`);
await fs.rm(audioPath, { force: true }).catch(() => undefined);
continue;
}
inputs.push("-i", audioPath);
availableAudioInputs.push(label);
audioFilePaths.push(audioPath);
} catch {
console.warn(`[mux] Skipping ${label} audio: file not accessible (${audioPath})`);
}
}
if (availableAudioInputs.length === 0) {
console.warn("[mux] No valid audio files to mux");
return;
}
const videoDuration = await probeMediaDurationSeconds(videoPath);
const audioAdjustments: Map<string, AudioSyncAdjustment> = new Map();
if (videoDuration > 0) {
for (let i = 0; i < audioFilePaths.length; i++) {
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration);
audioAdjustments.set(availableAudioInputs[i], adjustment);
if (adjustment.mode === "tempo") {
console.log(
`[mux] ${availableAudioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`,
);
} else if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
console.log(
`[mux] ${availableAudioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`,
);
}
}
}
const systemAdjustment = audioAdjustments.get("system") ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
const micAdjustment = audioAdjustments.get("microphone") ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
const needsFilter = systemAdjustment.mode !== "none" || micAdjustment.mode !== "none";
let args: string[];
if (availableAudioInputs.length === 2) {
if (needsFilter) {
const filterParts: string[] = [];
appendSyncedAudioFilter(filterParts, "[1:a]", "s", systemAdjustment);
appendSyncedAudioFilter(filterParts, "[2:a]", "m", micAdjustment);
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
args = [
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
];
} else {
args = [
"-y",
...inputs,
"-filter_complex",
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[aout]",
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
];
}
} else {
const singleAdjustment = audioAdjustments.get(availableAudioInputs[0]) ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
if (singleAdjustment.mode !== "none") {
const filterParts: string[] = [];
appendSyncedAudioFilter(filterParts, "[1:a]", "aout", singleAdjustment);
args = [
"-y",
...inputs,
"-filter_complex",
filterParts.join(";"),
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
];
} else {
args = [
"-y",
...inputs,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
];
}
}
console.log("[mux] Running ffmpeg:", ffmpegPath, args.join(" "));
try {
await execFileAsync(ffmpegPath, args, { timeout: 120000, maxBuffer: 10 * 1024 * 1024 });
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stderr?: string };
console.error("[mux] ffmpeg failed:", execError.stderr || execError.message);
throw error;
}
await moveFileWithOverwrite(mixedOutputPath, videoPath);
console.log("[mux] Successfully muxed audio into video:", videoPath);
for (const audioPath of [systemAudioPath, microphonePath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => undefined);
}
}
}
export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) {
process.once("close", () => {
const wasActive = nativeScreenRecordingActive;
setNativeCaptureProcess(null);
if (!wasActive || nativeCaptureStopRequested) {
return;
}
setNativeScreenRecordingActive(false);
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) {
// Safety net: if companion audio files still exist, the mux was skipped — attempt it now
if (videoPath.endsWith(".mp4")) {
const companionCandidates = await getUsableCompanionAudioCandidates(videoPath);
for (const { systemPath, micPath, platform } of companionCandidates) {
if (platform === "mac" || platform === "win") {
console.log(
`[finalize] Detected un-muxed ${platform} audio files alongside video — attempting safety-net mux`,
);
try {
if (platform === "win") {
await muxNativeWindowsVideoWithAudio(videoPath, systemPath, micPath);
} else {
await muxNativeMacRecordingWithAudio(videoPath, systemPath, micPath);
}
console.log("[finalize] Safety-net mux completed successfully");
} catch (error) {
console.warn("[finalize] Safety-net mux failed:", error);
}
break;
}
}
}
let validation: { fileSizeBytes: number; durationSeconds: number | null } | null = null;
try {
validation = await validateRecordedVideo(videoPath);
} catch (error) {
console.warn("Video validation failed (proceeding anyway):", error);
}
snapshotCursorTelemetryForPersistence();
setCurrentVideoPath(videoPath);
setCurrentProjectPath(null);
await persistPendingCursorTelemetry(videoPath);
if (isAutoRecordingPath(videoPath)) {
await pruneAutoRecordings([videoPath]);
}
if (lastNativeCaptureDiagnostics?.backend === "mac-screencapturekit") {
recordNativeCaptureDiagnostics({
backend: "mac-screencapturekit",
phase: "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 ?? null,
});
}
return {
success: true,
path: videoPath,
message:
validation?.durationSeconds !== null && validation !== 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;
}
}
+91
View File
@@ -0,0 +1,91 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
AUTO_RECORDING_RETENTION_COUNT,
AUTO_RECORDING_MAX_AGE_MS,
PROJECT_FILE_EXTENSION,
LEGACY_PROJECT_FILE_EXTENSIONS,
} from "../constants";
import { currentVideoPath } from "../state";
import { normalizePath, getTelemetryPathForVideo, isAutoRecordingPath, getRecordingsDir } from "../utils";
export async function hasSiblingProjectFile(videoPath: string) {
const baseName = path.basename(videoPath, path.extname(videoPath));
const candidateExtensions = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS];
for (const extension of candidateExtensions) {
const projectPath = path.join(path.dirname(videoPath), `${baseName}.${extension}`);
try {
await fs.access(projectPath);
return true;
} catch {
continue;
}
}
return false;
}
export { isAutoRecordingPath };
export async function pruneAutoRecordings(exemptPaths: string[] = []) {
const recordingsDir = await getRecordingsDir();
const exempt = new Set(
[currentVideoPath, ...exemptPaths]
.filter((value): value is string => Boolean(value))
.map((value) => normalizePath(value)),
);
const entries = await fs.readdir(recordingsDir, { withFileTypes: true });
const autoRecordingStats = await Promise.all(
entries
.filter((entry) => entry.isFile() && /^recording-.*\.(mp4|mov|webm)$/i.test(entry.name))
.map(async (entry) => {
const filePath = path.join(recordingsDir, entry.name);
const stats = await fs.stat(filePath);
return { filePath, stats };
}),
);
const sorted = autoRecordingStats.sort(
(left, right) => right.stats.mtimeMs - left.stats.mtimeMs,
);
const now = Date.now();
for (const [index, entry] of sorted.entries()) {
const normalizedFilePath = normalizePath(entry.filePath);
if (exempt.has(normalizedFilePath)) {
continue;
}
if (await hasSiblingProjectFile(entry.filePath)) {
continue;
}
const tooOld = now - entry.stats.mtimeMs > AUTO_RECORDING_MAX_AGE_MS;
const overLimit = index >= AUTO_RECORDING_RETENTION_COUNT;
if (!tooOld && !overLimit) {
continue;
}
try {
await fs.rm(entry.filePath, { force: true });
await fs.rm(getTelemetryPathForVideo(entry.filePath), { force: true });
// Clean up companion audio files left from recording (macOS .m4a, Windows .wav)
const base = entry.filePath.replace(/\.(mp4|mov|webm)$/i, "");
for (const suffix of [
".system.m4a",
".mic.m4a",
".system.wav",
".mic.wav",
".mic.webm",
".system.webm",
]) {
await fs.rm(base + suffix, { force: true }).catch(() => undefined);
}
} catch (error) {
console.warn("Failed to prune old auto recording:", entry.filePath, error);
}
}
}
+344
View File
@@ -0,0 +1,344 @@
import type { ChildProcessWithoutNullStreams } 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,
} from "../ffmpeg/filters";
import type { PauseSegment, AudioSyncAdjustment } from "../types";
import {
setWindowsCaptureProcess,
windowsCaptureOutputBuffer,
windowsCaptureTargetPath,
windowsNativeCaptureActive,
setWindowsNativeCaptureActive,
windowsCaptureStopRequested,
setWindowsCaptureStopRequested,
selectedSource,
} from "../state";
import { moveFileWithOverwrite } from "../utils";
import { probeMediaDurationSeconds } from "./diagnostics";
import { emitRecordingInterrupted } from "./events";
import { getWindowsCaptureExePath } from "../paths/binaries";
const execFileAsync = promisify(execFile);
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
if (process.platform !== "win32") return false;
const helperPath = getWindowsCaptureExePath();
const os = await import("node:os");
const [major, , build] = os.release().split(".").map(Number);
const supported = major >= 10 && build >= 19041;
let helperExists = false;
try {
await fs.access(helperPath, fsConstants.X_OK);
helperExists = true;
} catch {
return false;
}
void helperExists;
return supported;
}
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);
const onStdout = (chunk: Buffer) => {
const text = chunk.toString();
if (text.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) {
return new Promise<string>((resolve, reject) => {
const onClose = (code: number | null) => {
cleanup();
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) => {
cleanup();
reject(error);
};
const cleanup = () => {
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,
pauseSegments: PauseSegment[] = [],
) {
const ffmpegPath = getFfmpegBinaryPath();
const inputs: string[] = ["-i", videoPath];
const audioInputs: string[] = [];
const audioFilePaths: string[] = [];
for (const [label, audioPath] of [
["system", systemAudioPath],
["mic", micAudioPath],
] as const) {
if (!audioPath) continue;
try {
const stat = await fs.stat(audioPath);
if (stat.size <= 0) {
console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`);
await fs.rm(audioPath, { force: true }).catch(() => undefined);
continue;
}
inputs.push("-i", audioPath);
audioInputs.push(label);
audioFilePaths.push(audioPath);
} catch {
console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`);
}
}
if (audioInputs.length === 0) return;
const videoDuration = await probeMediaDurationSeconds(videoPath);
const audioAdjustments: Map<string, AudioSyncAdjustment> = new Map();
if (videoDuration > 0) {
for (let i = 0; i < audioFilePaths.length; i++) {
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
const adjustment = getAudioSyncAdjustment(videoDuration, audioDuration);
audioAdjustments.set(audioInputs[i], adjustment);
if (adjustment.mode === "tempo") {
console.log(
`[mux-win] ${audioInputs[i]} audio differs from video by ${adjustment.durationDeltaMs}ms — applying tempo ratio ${adjustment.tempoRatio.toFixed(6)}`,
);
} else if (adjustment.mode === "delay" && adjustment.delayMs > 0) {
console.log(
`[mux-win] ${audioInputs[i]} audio appears to start late by ${adjustment.delayMs}ms — adding leading silence`,
);
}
}
}
const mixedOutputPath = `${videoPath}.muxed.mp4`;
const normalizedPauseSegments = normalizePauseSegments(pauseSegments);
const systemAdjustment = audioAdjustments.get("system") ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
durationDeltaMs: 0,
};
const micAdjustment = audioAdjustments.get("mic") ?? {
mode: "none",
delayMs: 0,
tempoRatio: 1,
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,
);
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,
};
if (pauseFilter || singleAdjustment.mode !== "none") {
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 },
);
} else {
await execFileAsync(
ffmpegPath,
[
"-y",
...inputs,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
);
}
}
await moveFileWithOverwrite(mixedOutputPath, videoPath);
for (const audioPath of [systemAudioPath, micAudioPath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => undefined);
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type {
CursorInteractionType,
CursorTelemetryPoint,
CursorVisualType,
NativeCaptureDiagnostics,
RecordingSessionData,
SelectedSource,
SystemCursorAsset,
WindowBounds,
} from "./types";
// ── Source selection ──────────────────────────────────────────────────────────
export let selectedSource: SelectedSource | null = null;
// ── Project / video state ─────────────────────────────────────────────────────
export let currentProjectPath: string | null = null;
export let currentVideoPath: string | null = null;
export let currentRecordingSession: RecordingSessionData | null = null;
// ── Security: approved read paths ─────────────────────────────────────────────
export const approvedLocalReadPaths = new Set<string>();
// ── Native macOS capture ──────────────────────────────────────────────────────
export let nativeScreenRecordingActive = false;
export let nativeCaptureProcess: ChildProcessWithoutNullStreams | null = null;
export let nativeCaptureOutputBuffer = "";
export let nativeCaptureTargetPath: string | null = null;
export let nativeCaptureStopRequested = false;
export let nativeCaptureSystemAudioPath: string | null = null;
export let nativeCaptureMicrophonePath: string | null = null;
export let nativeCapturePaused = false;
// ── Native cursor monitor ─────────────────────────────────────────────────────
export let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null;
export let nativeCursorMonitorOutputBuffer = "";
// ── Windows native capture ────────────────────────────────────────────────────
export let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null;
export let windowsCaptureOutputBuffer = "";
export let windowsCaptureTargetPath: string | null = null;
export let windowsNativeCaptureActive = false;
export let windowsCaptureStopRequested = false;
export let windowsCapturePaused = false;
export let windowsSystemAudioPath: string | null = null;
export let windowsMicAudioPath: string | null = null;
export let windowsPendingVideoPath: string | null = null;
// ── Diagnostics ───────────────────────────────────────────────────────────────
export let lastNativeCaptureDiagnostics: NativeCaptureDiagnostics | null = null;
// ── FFmpeg capture ────────────────────────────────────────────────────────────
export let ffmpegScreenRecordingActive = false;
export let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null;
export let ffmpegCaptureOutputBuffer = "";
export let ffmpegCaptureTargetPath: string | null = null;
// ── Recordings directory ──────────────────────────────────────────────────────
export let customRecordingsDir: string | null = null;
export let recordingsDirLoaded = false;
// ── System cursor assets cache ────────────────────────────────────────────────
export let cachedSystemCursorAssets: Record<string, SystemCursorAsset> | null = null;
export let cachedSystemCursorAssetsSourceMtimeMs: number | null = null;
// ── Countdown ─────────────────────────────────────────────────────────────────
export let countdownTimer: ReturnType<typeof setInterval> | null = null;
export let countdownCancelled = false;
export let countdownInProgress = false;
export let countdownRemaining: number | null = null;
// ── Cursor visual type ────────────────────────────────────────────────────────
export let currentCursorVisualType: CursorVisualType | undefined = undefined;
// ── Cursor telemetry ──────────────────────────────────────────────────────────
export let cursorCaptureInterval: NodeJS.Timeout | null = null;
export let cursorCaptureStartTimeMs = 0;
export let activeCursorSamples: CursorTelemetryPoint[] = [];
export let pendingCursorSamples: CursorTelemetryPoint[] = [];
export let isCursorCaptureActive = false;
export let interactionCaptureCleanup: (() => void) | null = null;
export let hasLoggedInteractionHookFailure = false;
export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null;
export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null;
export let selectedWindowBounds: WindowBounds | null = null;
export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null;
// ── Native macOS window source cache ─────────────────────────────────────────
export let cachedNativeMacWindowSources: import("./types").NativeMacWindowSource[] | null = null;
export let cachedNativeMacWindowSourcesAtMs = 0;
// ── Native video export ───────────────────────────────────────────────────────
export let cachedNativeVideoEncoder: { ffmpegPath: string; encoderName: string } | null = null;
// ── Native helper migration ───────────────────────────────────────────────────
export let nativeHelperMigrationPromise: Promise<void> | null = null;
// ── Cursor interaction capture types ─────────────────────────────────────────
export type { CursorInteractionType, CursorTelemetryPoint };
// ── Setters (for modules that need to reassign exported lets) ─────────────────
// TypeScript exported `let` can be reassigned by the owning module but importers
// cannot assign to them directly. Provide simple setters for cross-module writes.
export function setSelectedSource(v: SelectedSource | null) { selectedSource = v; }
export function setCurrentProjectPath(v: string | null) { currentProjectPath = v; }
export function setCurrentVideoPath(v: string | null) { currentVideoPath = v; }
export function setCurrentRecordingSession(v: RecordingSessionData | null) { currentRecordingSession = v; }
export function setNativeScreenRecordingActive(v: boolean) { nativeScreenRecordingActive = v; }
export function setNativeCaptureProcess(v: ChildProcessWithoutNullStreams | null) { nativeCaptureProcess = v; }
export function setNativeCaptureOutputBuffer(v: string) { nativeCaptureOutputBuffer = v; }
export function setNativeCaptureTargetPath(v: string | null) { nativeCaptureTargetPath = v; }
export function setNativeCaptureStopRequested(v: boolean) { nativeCaptureStopRequested = v; }
export function setNativeCaptureSystemAudioPath(v: string | null) { nativeCaptureSystemAudioPath = v; }
export function setNativeCaptureMicrophonePath(v: string | null) { nativeCaptureMicrophonePath = v; }
export function setNativeCapturePaused(v: boolean) { nativeCapturePaused = v; }
export function setNativeCursorMonitorProcess(v: ChildProcessWithoutNullStreams | null) { nativeCursorMonitorProcess = v; }
export function setNativeCursorMonitorOutputBuffer(v: string) { nativeCursorMonitorOutputBuffer = v; }
export function setWindowsCaptureProcess(v: ChildProcessWithoutNullStreams | null) { windowsCaptureProcess = v; }
export function setWindowsCaptureOutputBuffer(v: string) { windowsCaptureOutputBuffer = v; }
export function setWindowsCaptureTargetPath(v: string | null) { windowsCaptureTargetPath = v; }
export function setWindowsNativeCaptureActive(v: boolean) { windowsNativeCaptureActive = v; }
export function setWindowsCaptureStopRequested(v: boolean) { windowsCaptureStopRequested = v; }
export function setWindowsCapturePaused(v: boolean) { windowsCapturePaused = v; }
export function setWindowsSystemAudioPath(v: string | null) { windowsSystemAudioPath = v; }
export function setWindowsMicAudioPath(v: string | null) { windowsMicAudioPath = v; }
export function setWindowsPendingVideoPath(v: string | null) { windowsPendingVideoPath = v; }
export function setLastNativeCaptureDiagnostics(v: NativeCaptureDiagnostics | null) { lastNativeCaptureDiagnostics = v; }
export function setFfmpegScreenRecordingActive(v: boolean) { ffmpegScreenRecordingActive = v; }
export function setFfmpegCaptureProcess(v: ChildProcessWithoutNullStreams | null) { ffmpegCaptureProcess = v; }
export function setFfmpegCaptureOutputBuffer(v: string) { ffmpegCaptureOutputBuffer = v; }
export function setFfmpegCaptureTargetPath(v: string | null) { ffmpegCaptureTargetPath = v; }
export function setCustomRecordingsDir(v: string | null) { customRecordingsDir = v; }
export function setRecordingsDirLoaded(v: boolean) { recordingsDirLoaded = v; }
export function setCachedSystemCursorAssets(v: Record<string, SystemCursorAsset> | null) { cachedSystemCursorAssets = v; }
export function setCachedSystemCursorAssetsSourceMtimeMs(v: number | null) { cachedSystemCursorAssetsSourceMtimeMs = v; }
export function setCountdownTimer(v: ReturnType<typeof setInterval> | null) { countdownTimer = v; }
export function setCountdownCancelled(v: boolean) { countdownCancelled = v; }
export function setCountdownInProgress(v: boolean) { countdownInProgress = v; }
export function setCountdownRemaining(v: number | null) { countdownRemaining = v; }
export function setCurrentCursorVisualType(v: CursorVisualType | undefined) { currentCursorVisualType = v; }
export function setCursorCaptureInterval(v: NodeJS.Timeout | null) { cursorCaptureInterval = v; }
export function setCursorCaptureStartTimeMs(v: number) { cursorCaptureStartTimeMs = v; }
export function setActiveCursorSamples(v: CursorTelemetryPoint[]) { activeCursorSamples = v; }
export function setPendingCursorSamples(v: CursorTelemetryPoint[]) { pendingCursorSamples = v; }
export function setIsCursorCaptureActive(v: boolean) { isCursorCaptureActive = v; }
export function setInteractionCaptureCleanup(v: (() => void) | null) { interactionCaptureCleanup = v; }
export function setHasLoggedInteractionHookFailure(v: boolean) { hasLoggedInteractionHookFailure = v; }
export function setLastLeftClick(v: { timeMs: number; cx: number; cy: number } | null) { lastLeftClick = v; }
export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: number } | null) { linuxCursorScreenPoint = v; }
export function setSelectedWindowBounds(v: WindowBounds | null) { selectedWindowBounds = v; }
export function setWindowBoundsCaptureInterval(v: NodeJS.Timeout | null) { windowBoundsCaptureInterval = v; }
export function setCachedNativeMacWindowSources(v: import("./types").NativeMacWindowSource[] | null) { cachedNativeMacWindowSources = v; }
export function setCachedNativeMacWindowSourcesAtMs(v: number) { cachedNativeMacWindowSourcesAtMs = v; }
export function setCachedNativeVideoEncoder(v: { ffmpegPath: string; encoderName: string } | null) { cachedNativeVideoEncoder = v; }
export function setNativeHelperMigrationPromise(v: Promise<void> | null) { nativeHelperMigrationPromise = v; }
+202
View File
@@ -0,0 +1,202 @@
export type SelectedSource = {
id?: string;
name: string;
display_id?: string;
sourceType?: "screen" | "window";
appName?: string;
windowTitle?: string;
[key: string]: unknown;
};
export type NativeMacRecordingOptions = {
capturesSystemAudio?: boolean;
capturesMicrophone?: boolean;
microphoneDeviceId?: string;
microphoneLabel?: string;
};
export type WindowBounds = {
x: number;
y: number;
width: number;
height: number;
};
export type NativeCaptureDiagnostics = {
backend: "windows-wgc" | "mac-screencapturekit" | "browser-store" | "ffmpeg";
phase: "availability" | "start" | "stop" | "mux";
timestamp: string;
sourceId?: string | null;
sourceType?: SelectedSource["sourceType"] | "unknown";
displayId?: number | null;
displayBounds?: WindowBounds | null;
windowHandle?: number | null;
helperPath?: string | null;
outputPath?: string | null;
systemAudioPath?: string | null;
microphonePath?: string | null;
osRelease?: string;
supported?: boolean;
helperExists?: boolean;
fileSizeBytes?: number | null;
processOutput?: string;
error?: string;
};
export type RecordingSessionData = {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
};
export type PauseSegment = {
startMs: number;
endMs: number;
};
export type RecordingSessionManifest = {
version: 1 | 2;
videoFileName: string;
webcamFileName?: string | null;
timeOffsetMs?: number;
};
export type ProjectLibraryEntry = {
path: string;
name: string;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
isInProjectsDirectory: boolean;
};
export type SystemCursorAsset = {
dataUrl: string;
hotspotX: number;
hotspotY: number;
width: number;
height: number;
};
export type CursorVisualType =
| "arrow"
| "text"
| "pointer"
| "crosshair"
| "open-hand"
| "closed-hand"
| "resize-ew"
| "resize-ns"
| "not-allowed";
export type CursorInteractionType =
| "move"
| "click"
| "double-click"
| "right-click"
| "middle-click"
| "mouseup";
export interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
interactionType?: CursorInteractionType;
cursorType?: CursorVisualType;
}
export type NativeMacWindowSource = {
id: string;
name: string;
display_id?: string;
appName?: string;
windowTitle?: string;
bundleId?: string;
appIcon?: string | null;
x?: number;
y?: number;
width?: number;
height?: number;
};
export type HookEventName = "mousedown" | "mouseup" | "mousemove";
export type HookMouseEvent = {
button?: number;
mouseButton?: number;
x?: number;
y?: number;
screenX?: number;
screenY?: number;
data?: {
button?: number;
mouseButton?: number;
x?: number;
y?: number;
screenX?: number;
screenY?: number;
};
};
export type HookEventListener = (event: HookMouseEvent) => void;
export type UiohookLike = {
on: (eventName: HookEventName, listener: HookEventListener) => void;
off?: (eventName: HookEventName, listener: HookEventListener) => void;
removeListener?: (eventName: HookEventName, listener: HookEventListener) => void;
start: () => void;
stop?: () => void;
};
export type UiohookModuleNamespace = {
uIOhook?: UiohookLike;
uiohook?: UiohookLike;
Uiohook?: UiohookLike;
default?: UiohookLike | UiohookModuleNamespace;
};
export type AudioSyncAdjustment = {
mode: "none" | "tempo" | "delay";
delayMs: number;
tempoRatio: number;
durationDeltaMs: number;
};
export type CompanionAudioCandidate = {
platform: "mac" | "win";
systemPath: string;
micPath: string;
usablePaths: string[];
};
export type CaptionWordPayload = {
text: string;
startMs: number;
endMs: number;
leadingSpace?: boolean;
};
export type CaptionCuePayload = {
id: string;
startMs: number;
endMs: number;
text: string;
words?: CaptionWordPayload[];
};
export type WhisperJsonToken = {
text?: unknown;
offsets?: {
from?: unknown;
to?: unknown;
};
};
export type WhisperJsonSegment = {
text?: unknown;
offsets?: {
from?: unknown;
to?: unknown;
};
tokens?: unknown;
};
+105
View File
@@ -0,0 +1,105 @@
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { app } from "electron";
import { RECORDINGS_DIR } from "../appPaths";
import { RECORDINGS_SETTINGS_FILE, AUTO_RECORDING_PREFIX } from "./constants";
import {
customRecordingsDir,
setCustomRecordingsDir,
recordingsDirLoaded,
setRecordingsDirLoaded,
} from "./state";
const nodeRequire = createRequire(import.meta.url);
export function getScreen() {
if (!app.isReady()) {
throw new Error(
"getScreen() called before app is ready. Ensure all screen access happens after app.whenReady().",
);
}
return nodeRequire("electron").screen as typeof import("electron").screen;
}
export function normalizePath(filePath: string) {
return path.resolve(filePath);
}
export function normalizeVideoSourcePath(videoPath?: string | null): string | null {
if (typeof videoPath !== "string") {
return null;
}
const trimmed = videoPath.trim();
if (!trimmed) {
return null;
}
if (/^file:\/\//i.test(trimmed)) {
try {
return fileURLToPath(trimmed);
} catch {
// Fall through and keep best-effort string path below.
}
}
return trimmed;
}
export function parseWindowId(sourceId?: string) {
if (!sourceId) return null;
const match = sourceId.match(/^window:(\d+)/);
return match ? Number.parseInt(match[1], 10) : null;
}
export function getTelemetryPathForVideo(videoPath: string) {
return `${videoPath}.cursor.json`;
}
export function isAutoRecordingPath(filePath: string) {
return path.basename(filePath).startsWith(AUTO_RECORDING_PREFIX);
}
export async function moveFileWithOverwrite(sourcePath: string, destinationPath: string) {
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.rm(destinationPath, { force: true });
try {
await fs.rename(sourcePath, destinationPath);
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== "EXDEV") {
throw error;
}
await fs.copyFile(sourcePath, destinationPath);
await fs.unlink(sourcePath);
}
}
async function loadRecordingsDirectorySetting() {
if (recordingsDirLoaded) {
return;
}
setRecordingsDirLoaded(true);
try {
const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8");
const parsed = JSON.parse(content) as { recordingsDir?: unknown };
if (typeof parsed.recordingsDir === "string" && parsed.recordingsDir.trim()) {
setCustomRecordingsDir(path.resolve(parsed.recordingsDir));
}
} catch {
setCustomRecordingsDir(null);
}
}
export async function getRecordingsDir() {
await loadRecordingsDirectorySetting();
const targetDir = customRecordingsDir ?? RECORDINGS_DIR;
await fs.mkdir(targetDir, { recursive: true });
return targetDir;
}