mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 15:55:35 +00:00
- Always route audio through aresample=async=1:first_pts=0 filter during muxing on both macOS and Windows, even when duration delta is small. Previously skipped when delta ≤50ms, which left progressive clock drift (from CPU load) completely uncorrected. - Lower sync detection threshold from 50ms to 20ms so tempo correction kicks in earlier. - Use explicit 48kHz AudioContext sample rate in both browser recording and export rendering to prevent sample rate mismatch drift. - Reduce MediaRecorder timeslice from 1000ms to 250ms to reduce chunk loss under CPU pressure. - Tighten export audio sync: seek threshold 300ms→150ms, tolerance 15ms→8ms, correction window 2s→0.5s, max adjustment ±8%→±12%. - Lower companion audio start delay threshold from 50ms to 25ms.
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
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);
|
|
|
|
let stdoutBuffer = "";
|
|
const onStdout = (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString();
|
|
if (stdoutBuffer.includes("Recording started")) {
|
|
cleanup();
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const onExit = (code: number | null) => {
|
|
cleanup();
|
|
reject(
|
|
new Error(
|
|
nativeCaptureOutputBuffer.trim() ||
|
|
`Native capture helper exited before recording started (code ${code ?? "unknown"})`,
|
|
),
|
|
);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
process.stdout.off("data", onStdout);
|
|
process.off("error", onError);
|
|
process.off("exit", onExit);
|
|
};
|
|
|
|
process.stdout.on("data", onStdout);
|
|
process.once("error", onError);
|
|
process.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
export function 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,
|
|
};
|
|
|
|
// Always route through the filter graph so that aresample=async=1 is
|
|
// applied to every audio stream. This corrects progressive clock drift
|
|
// between the video and audio tracks that a simple duration comparison
|
|
// cannot detect (e.g. audio gradually falling behind under CPU load).
|
|
let args: string[];
|
|
if (availableAudioInputs.length === 2) {
|
|
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 {
|
|
const singleAdjustment = audioAdjustments.get(availableAudioInputs[0]) ?? {
|
|
mode: "none",
|
|
delayMs: 0,
|
|
tempoRatio: 1,
|
|
durationDeltaMs: 0,
|
|
};
|
|
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,
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|