diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts new file mode 100644 index 00000000..8d87f813 --- /dev/null +++ b/electron/ipc/recording/diagnostics.test.ts @@ -0,0 +1,135 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type ExecFileCallback = (error: Error | null, stdout?: string, stderr?: string) => void; + +describe("getCompanionAudioFallbackPaths", () => { + let tempRoot: string; + let appDataPath: string; + let userDataPath: string; + let tempPath: string; + let appPath: string; + let execFileMock: ReturnType; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-companion-audio-")); + appDataPath = path.join(tempRoot, "AppData"); + userDataPath = path.join(tempRoot, "UserData"); + tempPath = path.join(tempRoot, "Temp"); + appPath = path.join(tempRoot, "App"); + await Promise.all( + [appDataPath, userDataPath, tempPath, appPath].map((dirPath) => + fs.mkdir(dirPath, { recursive: true }), + ), + ); + execFileMock = vi.fn( + ( + _file: string, + _args: string[], + _options: Record, + callback: ExecFileCallback, + ) => { + callback(null, "", ""); + }, + ); + + vi.resetModules(); + vi.doMock("electron", () => ({ + app: { + isPackaged: false, + getAppPath: () => appPath, + getPath: (name: string) => { + if (name === "appData") return appDataPath; + if (name === "userData") return userDataPath; + if (name === "temp") return tempPath; + return tempRoot; + }, + setPath: () => undefined, + }, + })); + vi.doMock("node:child_process", () => ({ + execFile: execFileMock, + })); + vi.doMock("../ffmpeg/binary", () => ({ + getFfmpegBinaryPath: () => "ffmpeg", + })); + }); + + afterEach(async () => { + vi.resetModules(); + vi.doUnmock("electron"); + vi.doUnmock("node:child_process"); + vi.doUnmock("../ffmpeg/binary"); + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("returns companion audio files directly when the video has no embedded audio", async () => { + const videoPath = path.join(tempRoot, "recording.mp4"); + const systemPath = path.join(tempRoot, "recording.system.wav"); + const micPath = path.join(tempRoot, "recording.mic.wav"); + + await Promise.all([ + fs.writeFile(videoPath, "video"), + fs.writeFile(systemPath, "system"), + fs.writeFile(micPath, "mic"), + ]); + + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: Record, + callback: ExecFileCallback, + ) => { + const error = new Error("ffmpeg probe failed") as Error & { stderr?: string }; + error.stderr = "Stream #0:0: Video: h264"; + callback(error, "", error.stderr); + }, + ); + + const { getCompanionAudioFallbackPaths } = await import("./diagnostics"); + + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([ + systemPath, + micPath, + ]); + }); + + it("keeps the embedded source audio and adds the mic companion when both are present", async () => { + const videoPath = path.join(tempRoot, "recording.mp4"); + const systemPath = path.join(tempRoot, "recording.system.wav"); + const micPath = path.join(tempRoot, "recording.mic.wav"); + + await Promise.all([ + fs.writeFile(videoPath, "video"), + fs.writeFile(systemPath, "system"), + fs.writeFile(micPath, "mic"), + ]); + + execFileMock.mockImplementation( + ( + _file: string, + _args: string[], + _options: Record, + callback: ExecFileCallback, + ) => { + const error = new Error("ffmpeg probe found embedded audio") as Error & { + stderr?: string; + }; + error.stderr = "Stream #0:1: Audio: aac"; + callback(error, "", error.stderr); + }, + ); + + const { getCompanionAudioFallbackPaths } = await import("./diagnostics"); + + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([ + videoPath, + micPath, + ]); + }); +}); diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index 9ce89a89..3adcedc5 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -1,10 +1,10 @@ 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 { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from "../state"; +import type { CompanionAudioCandidate, NativeCaptureDiagnostics } from "../types"; const execFileAsync = promisify(execFile); @@ -123,10 +123,23 @@ export async function getCompanionAudioFallbackPaths(videoPath: string) { } if (await hasEmbeddedAudioStream(videoPath)) { - return []; + const microphoneCompanionPaths = Array.from( + new Set( + companionCandidates.flatMap((candidate) => + candidate.usablePaths.filter( + (companionPath) => companionPath === candidate.micPath, + ), + ), + ), + ); + if (microphoneCompanionPaths.length === 0) { + return []; + } + + return [videoPath, ...microphoneCompanionPaths]; } - return companionCandidates.flatMap((candidate) => candidate.usablePaths); + return Array.from(new Set(companionCandidates.flatMap((candidate) => candidate.usablePaths))); } export async function validateRecordedVideo(videoPath: string) {