mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-28 16:55:36 +00:00
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:
@@ -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 }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user