diff --git a/electron/ipc/recording/diagnostics.test.ts b/electron/ipc/recording/diagnostics.test.ts index 9f7ddeb08..a0092a602 100644 --- a/electron/ipc/recording/diagnostics.test.ts +++ b/electron/ipc/recording/diagnostics.test.ts @@ -134,6 +134,42 @@ describe("getCompanionAudioFallbackPaths", () => { ]); }); + it("returns both mac sidecars instead of the video when a system sidecar exists", async () => { + const videoPath = path.join(tempRoot, "recording.mp4"); + const systemPath = path.join(tempRoot, "recording.system.m4a"); + const micPath = path.join(tempRoot, "recording.mic.m4a"); + + 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"); + + // The inline mp4 track holds system audio only, so returning [videoPath] + // here silently dropped the microphone. + await expect(getCompanionAudioFallbackPaths(videoPath)).resolves.toEqual([ + systemPath, + micPath, + ]); + }); + it("prefers the mac mic companion alone when embedded audio already exists and no system sidecar is present", async () => { const videoPath = path.join(tempRoot, "recording.mp4"); const micPath = path.join(tempRoot, "recording.mic.m4a"); diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index 985209f22..76a9a343d 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -496,6 +496,16 @@ export async function getCompanionAudioFallbackPaths(videoPath: string) { return paths; } +/** + * Resolve which audio files the editor should play alongside `videoPath`, and + * the start delay recorded for each. + * + * The renderer treats a `.system.`/`.mic.` pair as independent tracks and mutes + * the video's own track when both are present. The macOS helper writes system + * audio to the inline track but keeps both sources as sidecars, so once a mac + * system sidecar exists the sidecars are authoritative and are returned in place + * of the video. Other layouts keep the embedded track and add the mic sidecar. + */ export async function getCompanionAudioFallbackInfo(videoPath: string) { const companionCandidates = await getUsableCompanionAudioCandidates(videoPath); if (companionCandidates.length === 0) { @@ -524,7 +534,17 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) { if (!hasUsableMacSystemCompanion && usableMacMicOnlyCompanions.length > 0) { paths = usableMacMicOnlyCompanions; } else if (hasUsableMacSystemCompanion) { - paths = [videoPath]; + // The inline mp4 audio track carries system audio only (the helper skips + // the microphone while system audio is captured), so returning the video + // alone drops the mic entirely. Hand over both mac sidecars instead and + // let the renderer route them as independent system/mic tracks. + paths = Array.from( + new Set( + companionCandidates.flatMap((candidate) => + candidate.platform === "mac" ? candidate.usablePaths : [], + ), + ), + ); } else { const companionPaths = Array.from( new Set(