mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
removed muxing completly from the code (mac/windows)
This commit is contained in:
@@ -38,10 +38,3 @@ export function shouldKeepRecordingAudioSidecars(env: NodeJS.ProcessEnv = proces
|
||||
const value = env[RECORDING_AUDIO_SIDECAR_DEBUG_ENV]?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle for the recording finalization optimizations.
|
||||
* - true: Fast probe (no -count_frames), separate audio tracks (no muxing).
|
||||
* - false: slow probe (full decode), combined audio/video (heavy muxing).
|
||||
*/
|
||||
export const OPTIMIZE_RECORDING_FINALIZATION = true;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { COMPANION_AUDIO_LAYOUTS } from "../constants";
|
||||
import { OPTIMIZE_RECORDING_FINALIZATION } from "./audioFilters";
|
||||
import { getFfmpegBinaryPath, getFfprobeBinaryPath } from "../ffmpeg/binary";
|
||||
import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from "../state";
|
||||
import type { CompanionAudioCandidate, NativeCaptureDiagnostics } from "../types";
|
||||
@@ -280,7 +279,6 @@ export async function probeVideoStreamDuration(
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
...(OPTIMIZE_RECORDING_FINALIZATION ? [] : ["-count_frames"]),
|
||||
"-show_entries",
|
||||
"stream=duration,nb_frames,nb_read_frames,avg_frame_rate,r_frame_rate",
|
||||
"-of",
|
||||
|
||||
+22
-173
@@ -1,14 +1,10 @@
|
||||
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 {
|
||||
persistPendingCursorTelemetry,
|
||||
snapshotCursorTelemetryForPersistence,
|
||||
} from "../cursor/telemetry";
|
||||
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
|
||||
import { appendSyncedAudioFilter, getAudioSyncAdjustment } from "../ffmpeg/filters";
|
||||
import {
|
||||
lastNativeCaptureDiagnostics,
|
||||
nativeCaptureMicrophonePath,
|
||||
@@ -27,22 +23,15 @@ import {
|
||||
setNativeCaptureTargetPath,
|
||||
setNativeScreenRecordingActive,
|
||||
} from "../state";
|
||||
import type { AudioSyncAdjustment } from "../types";
|
||||
import { isAutoRecordingPath, moveFileWithOverwrite } from "../utils";
|
||||
import { OPTIMIZE_RECORDING_FINALIZATION } from "./audioFilters";
|
||||
import {
|
||||
getFileSizeIfPresent,
|
||||
getRecordingAudioMuxTimeoutMs,
|
||||
getUsableCompanionAudioCandidates,
|
||||
probeMediaDurationSeconds,
|
||||
recordNativeCaptureDiagnostics,
|
||||
validateRecordedVideo,
|
||||
} from "./diagnostics";
|
||||
import { emitRecordingInterrupted } from "./events";
|
||||
import { pruneAutoRecordings } from "./prune";
|
||||
import { muxNativeWindowsVideoWithAudio } from "./windows";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -129,155 +118,35 @@ export async function muxNativeMacRecordingWithAudio(
|
||||
systemAudioPath?: string | null,
|
||||
microphonePath?: string | null,
|
||||
) {
|
||||
const ffmpegPath = getFfmpegBinaryPath();
|
||||
const mixedOutputPath = `${videoPath}.mixed.mp4`;
|
||||
console.log("[mac-mux] Optimization active: keeping tracks separate.");
|
||||
|
||||
const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, "");
|
||||
|
||||
const inputs = ["-i", videoPath];
|
||||
const availableAudioInputs: string[] = [];
|
||||
const audioFilePaths: string[] = [];
|
||||
|
||||
for (const [label, audioPath] of [
|
||||
["system", systemAudioPath],
|
||||
["microphone", microphonePath],
|
||||
] as const) {
|
||||
if (!audioPath) continue;
|
||||
// Optimization: instead of heavy FFmpeg muxing, we ensure audio sidecars
|
||||
// are available alongside the video for the editor.
|
||||
if (systemAudioPath) {
|
||||
const finalSystemPath = `${videoPathWithoutExt}.system.wav`;
|
||||
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;
|
||||
const stat = await fs.stat(systemAudioPath);
|
||||
if (stat.size > 0 && systemAudioPath !== finalSystemPath) {
|
||||
await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
|
||||
}
|
||||
inputs.push("-i", audioPath);
|
||||
availableAudioInputs.push(label);
|
||||
audioFilePaths.push(audioPath);
|
||||
} catch {
|
||||
console.warn(`[mux] Skipping ${label} audio: file not accessible (${audioPath})`);
|
||||
} catch (err) {
|
||||
console.error(`[mac-mux] Failed to handle system audio:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (availableAudioInputs.length === 0) {
|
||||
console.warn(
|
||||
"[mux] No valid audio files to mux — video will have no audio. " +
|
||||
`system=${systemAudioPath ?? "none"} mic=${microphonePath ?? "none"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const videoDuration = await probeMediaDurationSeconds(videoPath);
|
||||
const muxTimeoutMs = getRecordingAudioMuxTimeoutMs(videoDuration);
|
||||
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`,
|
||||
);
|
||||
if (microphonePath) {
|
||||
const finalMicPath = `${videoPathWithoutExt}.mic.wav`;
|
||||
try {
|
||||
const stat = await fs.stat(microphonePath);
|
||||
if (stat.size > 0 && microphonePath !== finalMicPath) {
|
||||
await moveFileWithOverwrite(microphonePath, finalMicPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[mac-mux] Failed to handle mic audio:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-nostats",
|
||||
...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",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-nostats",
|
||||
...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: muxTimeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
await validateRecordedVideo(mixedOutputPath);
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & { stderr?: string };
|
||||
console.error("[mux] failed:", execError.stderr || execError.message || String(error));
|
||||
await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await moveFileWithOverwrite(mixedOutputPath, videoPath);
|
||||
console.log("[mux] Successfully muxed audio into video:", videoPath);
|
||||
}
|
||||
|
||||
export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStreams) {
|
||||
@@ -290,6 +159,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr
|
||||
}
|
||||
|
||||
setNativeScreenRecordingActive(false);
|
||||
console.log("[mac-finalize] Optimization active: skipping safety-net muxing.");
|
||||
setNativeCaptureTargetPath(null);
|
||||
setNativeCaptureStopRequested(false);
|
||||
setNativeCaptureSystemAudioPath(null);
|
||||
@@ -318,28 +188,7 @@ export function attachNativeCaptureLifecycle(process: ChildProcessWithoutNullStr
|
||||
}
|
||||
|
||||
export async function finalizeStoredVideo(videoPath: string) {
|
||||
// Safety net: if companion audio files still exist, the mux was skipped — attempt it now
|
||||
if (!OPTIMIZE_RECORDING_FINALIZATION && 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("[finalize] Optimization active: skipping safety-net muxing.");
|
||||
|
||||
let validation: { fileSizeBytes: number; durationSeconds: number | null };
|
||||
try {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { BrowserWindow } from "electron";
|
||||
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
|
||||
import {
|
||||
appendSyncedAudioFilter,
|
||||
applyRecordedAudioStartDelay,
|
||||
getAudioSyncAdjustment,
|
||||
} from "../ffmpeg/filters";
|
||||
import { getWindowsCaptureExePath } from "../paths/binaries";
|
||||
import {
|
||||
selectedSource,
|
||||
@@ -21,24 +13,11 @@ import {
|
||||
windowsCaptureTargetPath,
|
||||
windowsNativeCaptureActive,
|
||||
} from "../state";
|
||||
import type { AudioSyncAdjustment } from "../types";
|
||||
import { moveFileWithOverwrite } from "../utils";
|
||||
import {
|
||||
OPTIMIZE_RECORDING_FINALIZATION,
|
||||
shouldKeepRecordingAudioSidecars,
|
||||
WINDOWS_NATIVE_MIC_PRE_FILTERS,
|
||||
} from "./audioFilters";
|
||||
import {
|
||||
getCompanionAudioStartDelayMs,
|
||||
getRecordingAudioMuxTimeoutMs,
|
||||
probeMediaDurationSeconds,
|
||||
probeVideoStreamDurationSeconds,
|
||||
validateRecordedVideo,
|
||||
} from "./diagnostics";
|
||||
AudioSyncAdjustment,
|
||||
} from "../types";
|
||||
import { emitRecordingInterrupted } from "./events";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MIN_NATIVE_WINDOWS_VIDEO_PAD_MS = 500;
|
||||
import { moveFileWithOverwrite } from "../utils";
|
||||
|
||||
export type NativeWindowsVideoPaddingResult = {
|
||||
padded: boolean;
|
||||
@@ -64,7 +43,7 @@ export type NativeWindowsAudioMuxResult = {
|
||||
}
|
||||
>;
|
||||
outputPath?: string;
|
||||
keptAudioSidecars: boolean;
|
||||
keptAudioSidecars?: boolean;
|
||||
};
|
||||
|
||||
export async function isNativeWindowsCaptureAvailable(): Promise<boolean> {
|
||||
@@ -190,97 +169,6 @@ export function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStrea
|
||||
});
|
||||
}
|
||||
|
||||
export async function extendNativeWindowsVideoToDuration(
|
||||
videoPath: string,
|
||||
targetDurationMs: number | null | undefined,
|
||||
): Promise<NativeWindowsVideoPaddingResult> {
|
||||
const start = Date.now();
|
||||
console.log("[PERF:MAIN] extendNativeWindowsVideoToDuration: STARTED");
|
||||
try {
|
||||
if (!Number.isFinite(targetDurationMs) || (targetDurationMs ?? 0) <= 0) {
|
||||
return {
|
||||
padded: false,
|
||||
durationSeconds: 0,
|
||||
containerDurationSeconds: await probeMediaDurationSeconds(videoPath),
|
||||
targetDurationSeconds: 0,
|
||||
padDurationSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const containerDurationSeconds = await probeMediaDurationSeconds(videoPath);
|
||||
const currentDurationSeconds = await probeVideoStreamDurationSeconds(videoPath);
|
||||
if (currentDurationSeconds <= 0) {
|
||||
return {
|
||||
padded: false,
|
||||
durationSeconds: currentDurationSeconds,
|
||||
containerDurationSeconds,
|
||||
targetDurationSeconds: (targetDurationMs ?? 0) / 1000,
|
||||
padDurationSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const targetDurationSeconds = (targetDurationMs ?? 0) / 1000;
|
||||
const padDurationSeconds = targetDurationSeconds - currentDurationSeconds;
|
||||
if (padDurationSeconds * 1000 < MIN_NATIVE_WINDOWS_VIDEO_PAD_MS) {
|
||||
return {
|
||||
padded: false,
|
||||
durationSeconds: currentDurationSeconds,
|
||||
containerDurationSeconds,
|
||||
targetDurationSeconds,
|
||||
padDurationSeconds: Math.max(0, padDurationSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
const ffmpegPath = getFfmpegBinaryPath();
|
||||
const paddedOutputPath = `${videoPath}.duration-padded.mp4`;
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-nostats",
|
||||
"-i",
|
||||
videoPath,
|
||||
"-vf",
|
||||
`tpad=stop_mode=clone:stop_duration=${padDurationSeconds.toFixed(3)}`,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
paddedOutputPath,
|
||||
],
|
||||
{ timeout: 300000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
await validateRecordedVideo(paddedOutputPath);
|
||||
await moveFileWithOverwrite(paddedOutputPath, videoPath);
|
||||
return {
|
||||
padded: true,
|
||||
durationSeconds: targetDurationSeconds,
|
||||
containerDurationSeconds,
|
||||
targetDurationSeconds,
|
||||
padDurationSeconds,
|
||||
};
|
||||
} catch (error) {
|
||||
await fs.rm(paddedOutputPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
console.log(
|
||||
`[PERF:MAIN] extendNativeWindowsVideoToDuration: COMPLETED in ${Date.now() - start}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function muxNativeWindowsVideoWithAudio(
|
||||
videoPath: string,
|
||||
systemAudioPath: string | null,
|
||||
@@ -288,253 +176,63 @@ export async function muxNativeWindowsVideoWithAudio(
|
||||
): Promise<NativeWindowsAudioMuxResult> {
|
||||
const start = Date.now();
|
||||
console.log("[PERF:MAIN] muxNativeWindowsVideoWithAudio: STARTED");
|
||||
try {
|
||||
const ffmpegPath = getFfmpegBinaryPath();
|
||||
const keepAudioSidecars = shouldKeepRecordingAudioSidecars();
|
||||
const inputs: string[] = ["-i", videoPath];
|
||||
const audioInputs: string[] = [];
|
||||
const audioFilePaths: string[] = [];
|
||||
const audio: NativeWindowsAudioMuxResult["audio"] = {};
|
||||
const audioInputs: string[] = [];
|
||||
|
||||
for (const [label, audioPath] of [
|
||||
["system", systemAudioPath],
|
||||
["mic", micAudioPath],
|
||||
] as const) {
|
||||
if (!audioPath) continue;
|
||||
const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, "");
|
||||
|
||||
// Optimization: instead of heavy FFmpeg muxing, we just move the audio sidecars
|
||||
// to their final companion paths so the editor can find them as separate tracks.
|
||||
if (systemAudioPath) {
|
||||
const finalSystemPath = `${videoPathWithoutExt}.system.wav`;
|
||||
try {
|
||||
const stat = await fs.stat(audioPath);
|
||||
if (stat.size <= 0) {
|
||||
console.warn(`[mux-win] Skipping ${label} audio: file is empty (${audioPath})`);
|
||||
if (!keepAudioSidecars) {
|
||||
await fs.rm(audioPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
continue;
|
||||
const stat = await fs.stat(systemAudioPath);
|
||||
if (stat.size > 0) {
|
||||
await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
|
||||
audioInputs.push("system");
|
||||
audio.system = {
|
||||
path: finalSystemPath,
|
||||
sizeBytes: stat.size,
|
||||
durationSeconds: 0,
|
||||
startDelayMs: null,
|
||||
adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
|
||||
};
|
||||
}
|
||||
inputs.push("-i", audioPath);
|
||||
audioInputs.push(label);
|
||||
audioFilePaths.push(audioPath);
|
||||
audio[label] = {
|
||||
path: audioPath,
|
||||
sizeBytes: stat.size,
|
||||
durationSeconds: 0,
|
||||
startDelayMs: null,
|
||||
adjustment: {
|
||||
mode: "none",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 0,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
console.warn(`[mux-win] Skipping ${label} audio: file not accessible (${audioPath})`);
|
||||
} catch (err) {
|
||||
console.error(`[mux-win] Failed to handle system audio:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const videoDuration = await probeVideoStreamDurationSeconds(videoPath);
|
||||
const muxTimeoutMs = getRecordingAudioMuxTimeoutMs(videoDuration);
|
||||
const audioAdjustments: Map<string, AudioSyncAdjustment> = new Map();
|
||||
if (audioInputs.length === 0) {
|
||||
return {
|
||||
muxed: false,
|
||||
videoDurationSeconds: videoDuration,
|
||||
muxTimeoutMs,
|
||||
audioInputs,
|
||||
audio,
|
||||
keptAudioSidecars: keepAudioSidecars,
|
||||
};
|
||||
}
|
||||
|
||||
if (videoDuration > 0) {
|
||||
for (let i = 0; i < audioFilePaths.length; i++) {
|
||||
const audioDuration = await probeMediaDurationSeconds(audioFilePaths[i]);
|
||||
const recordedStartDelayMs = await getCompanionAudioStartDelayMs(audioFilePaths[i]);
|
||||
const adjustment = applyRecordedAudioStartDelay(
|
||||
getAudioSyncAdjustment(videoDuration, audioDuration),
|
||||
recordedStartDelayMs,
|
||||
);
|
||||
audioAdjustments.set(audioInputs[i], adjustment);
|
||||
audio[audioInputs[i]] = {
|
||||
...audio[audioInputs[i]],
|
||||
durationSeconds: audioDuration,
|
||||
startDelayMs: recordedStartDelayMs,
|
||||
adjustment,
|
||||
};
|
||||
if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "delay") {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio recorded a start delay of ${adjustment.delayMs}ms`,
|
||||
);
|
||||
} else if (Number.isFinite(recordedStartDelayMs) && adjustment.mode === "pad") {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio started on time but ends ${adjustment.durationDeltaMs}ms early — padding trailing silence`,
|
||||
);
|
||||
} else 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`,
|
||||
);
|
||||
} else if (adjustment.mode === "pad" && adjustment.durationDeltaMs > 0) {
|
||||
console.log(
|
||||
`[mux-win] ${audioInputs[i]} audio is much shorter than video by ${adjustment.durationDeltaMs}ms — padding trailing silence`,
|
||||
);
|
||||
if (micAudioPath) {
|
||||
const finalMicPath = `${videoPathWithoutExt}.mic.wav`;
|
||||
try {
|
||||
const stat = await fs.stat(micAudioPath);
|
||||
if (stat.size > 0) {
|
||||
await moveFileWithOverwrite(micAudioPath, finalMicPath);
|
||||
audioInputs.push("mic");
|
||||
audio.mic = {
|
||||
path: finalMicPath,
|
||||
sizeBytes: stat.size,
|
||||
durationSeconds: 0,
|
||||
startDelayMs: null,
|
||||
adjustment: { mode: "none", delayMs: 0, tempoRatio: 1, durationDeltaMs: 0 },
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[mux-win] Failed to handle mic audio:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const mixedOutputPath = `${videoPath}.muxed.mp4`;
|
||||
const systemAdjustment = audioAdjustments.get("system") ?? {
|
||||
mode: "none",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 0,
|
||||
console.log(
|
||||
`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`,
|
||||
);
|
||||
|
||||
return {
|
||||
muxed: false,
|
||||
videoDurationSeconds: 0, // No longer needed here
|
||||
muxTimeoutMs: 0,
|
||||
audioInputs,
|
||||
audio,
|
||||
keptAudioSidecars: true,
|
||||
};
|
||||
const micAdjustment = audioAdjustments.get("mic") ?? {
|
||||
mode: "none",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 0,
|
||||
};
|
||||
|
||||
if (OPTIMIZE_RECORDING_FINALIZATION) {
|
||||
console.log("[mux-win] Optimization enabled: skipping heavy muxing, keeping tracks separate.");
|
||||
const videoPathWithoutExt = videoPath.replace(/\.[^.]+$/u, "");
|
||||
|
||||
// Move audio sidecars to final companion paths if they exist
|
||||
if (systemAudioPath) {
|
||||
const finalSystemPath = `${videoPathWithoutExt}.system.wav`;
|
||||
if (systemAudioPath !== finalSystemPath) {
|
||||
try {
|
||||
await moveFileWithOverwrite(systemAudioPath, finalSystemPath);
|
||||
if (audio.system) audio.system.path = finalSystemPath;
|
||||
} catch (err) {
|
||||
console.error(`[mux-win] Failed to move system audio to ${finalSystemPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (micAudioPath) {
|
||||
const finalMicPath = `${videoPathWithoutExt}.mic.wav`;
|
||||
if (micAudioPath !== finalMicPath) {
|
||||
try {
|
||||
await moveFileWithOverwrite(micAudioPath, finalMicPath);
|
||||
if (audio.mic) audio.mic.path = finalMicPath;
|
||||
} catch (err) {
|
||||
console.error(`[mux-win] Failed to move mic audio to ${finalMicPath}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
muxed: false,
|
||||
videoDurationSeconds: videoDuration,
|
||||
muxTimeoutMs,
|
||||
audioInputs,
|
||||
audio,
|
||||
keptAudioSidecars: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (audioInputs.length === 2) {
|
||||
const filterParts: string[] = [];
|
||||
appendSyncedAudioFilter(filterParts, "[1:a]", "s", systemAdjustment);
|
||||
appendSyncedAudioFilter(filterParts, "[2:a]", "m", micAdjustment, {
|
||||
preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS,
|
||||
});
|
||||
filterParts.push("[s][m]amix=inputs=2:duration=longest:normalize=0[aout]");
|
||||
|
||||
await execFileAsync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-nostats",
|
||||
...inputs,
|
||||
"-filter_complex",
|
||||
filterParts.join(";"),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
mixedOutputPath,
|
||||
],
|
||||
{ timeout: muxTimeoutMs, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
} else {
|
||||
const singleAdjustment = audioAdjustments.get(audioInputs[0]) ?? {
|
||||
mode: "none",
|
||||
delayMs: 0,
|
||||
tempoRatio: 1,
|
||||
durationDeltaMs: 0,
|
||||
};
|
||||
|
||||
const filterParts: string[] = [];
|
||||
// Always route through the filter graph so that aresample=async=1 is
|
||||
// applied. This corrects progressive clock drift between video and
|
||||
// audio tracks that a simple duration comparison cannot detect.
|
||||
appendSyncedAudioFilter(
|
||||
filterParts,
|
||||
"[1:a]",
|
||||
"aout",
|
||||
singleAdjustment,
|
||||
audioInputs[0] === "mic" ? { preFilters: WINDOWS_NATIVE_MIC_PRE_FILTERS } : 1,
|
||||
);
|
||||
|
||||
await execFileAsync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-nostdin",
|
||||
"-nostats",
|
||||
...inputs,
|
||||
"-filter_complex",
|
||||
filterParts.join(";"),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
mixedOutputPath,
|
||||
],
|
||||
{ timeout: muxTimeoutMs, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
}
|
||||
|
||||
await validateRecordedVideo(mixedOutputPath);
|
||||
await moveFileWithOverwrite(mixedOutputPath, videoPath);
|
||||
} catch (error) {
|
||||
await fs.rm(mixedOutputPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
muxed: true,
|
||||
videoDurationSeconds: videoDuration,
|
||||
muxTimeoutMs,
|
||||
audioInputs,
|
||||
audio,
|
||||
outputPath: videoPath,
|
||||
keptAudioSidecars: true,
|
||||
};
|
||||
} finally {
|
||||
console.log(
|
||||
`[PERF:MAIN] muxNativeWindowsVideoWithAudio: COMPLETED in ${Date.now() - start}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
import { rememberApprovedLocalReadPath } from "../project/manager";
|
||||
import {
|
||||
getBrowserMicSidecarFilters,
|
||||
OPTIMIZE_RECORDING_FINALIZATION,
|
||||
shouldKeepRecordingAudioSidecars,
|
||||
} from "../recording/audioFilters";
|
||||
import {
|
||||
@@ -70,7 +69,6 @@ import {
|
||||
} from "../recording/mac";
|
||||
import {
|
||||
attachWindowsCaptureLifecycle,
|
||||
extendNativeWindowsVideoToDuration,
|
||||
isNativeWindowsCaptureAvailable,
|
||||
muxNativeWindowsVideoWithAudio,
|
||||
waitForWindowsCaptureStart,
|
||||
@@ -1300,35 +1298,7 @@ export function registerRecordingHandlers(
|
||||
hasOrphanedMicrophone: Boolean(orphanedMicAudioPath),
|
||||
},
|
||||
});
|
||||
if (!OPTIMIZE_RECORDING_FINALIZATION && expectedDurationMs) {
|
||||
try {
|
||||
const padding = await extendNativeWindowsVideoToDuration(
|
||||
videoPath,
|
||||
expectedDurationMs,
|
||||
);
|
||||
await writeWindowsRecordingDiagnostics(videoPath, {
|
||||
phase: "pad",
|
||||
expectedDurationMs,
|
||||
outputPath: videoPath,
|
||||
systemAudioPath: diagnosticsSystemAudioPath,
|
||||
microphonePath: diagnosticsMicAudioPath,
|
||||
details: { ...padding },
|
||||
});
|
||||
} catch (paddingError) {
|
||||
console.warn(
|
||||
"[mux-win] Failed to extend native Windows video duration:",
|
||||
paddingError,
|
||||
);
|
||||
await writeWindowsRecordingDiagnostics(videoPath, {
|
||||
phase: "pad",
|
||||
expectedDurationMs,
|
||||
outputPath: videoPath,
|
||||
systemAudioPath: diagnosticsSystemAudioPath,
|
||||
microphonePath: diagnosticsMicAudioPath,
|
||||
error: String(paddingError),
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log("[mux-win] Optimization active: skipping video padding.");
|
||||
|
||||
let muxDetails: unknown = null;
|
||||
if (diagnosticsSystemAudioPath || diagnosticsMicAudioPath) {
|
||||
|
||||
Reference in New Issue
Block a user