mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
getCompanionAudioFallbackInfo returned only the video path when a macOS system sidecar existed, assuming the inline mp4 track was a complete mix. The capture helper writes system audio alone to that track, so the microphone was dropped from preview and export whenever both sources were enabled — the recorded narration was silent even though it had been captured correctly to recording-<ts>.mic.m4a. Return both macOS sidecars instead, so the renderer routes them as separate system and mic tracks and mutes the silent embedded track. Existing recordings recover their audio on reopen. Fixes webadderallorg/Recordly#912
447 lines
12 KiB
TypeScript
447 lines
12 KiB
TypeScript
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<typeof vi.fn>;
|
|
|
|
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<string, unknown>,
|
|
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",
|
|
getFfprobeBinaryPath: () => "ffprobe",
|
|
}));
|
|
});
|
|
|
|
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<string, unknown>,
|
|
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<string, unknown>,
|
|
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,
|
|
]);
|
|
});
|
|
|
|
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<string, unknown>,
|
|
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");
|
|
|
|
await Promise.all([fs.writeFile(videoPath, "video"), fs.writeFile(micPath, "mic")]);
|
|
|
|
execFileMock.mockImplementation(
|
|
(
|
|
_file: string,
|
|
_args: string[],
|
|
_options: Record<string, unknown>,
|
|
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([micPath]);
|
|
});
|
|
|
|
it("loads saved sidecar timing metadata alongside companion audio paths", async () => {
|
|
const videoPath = path.join(tempRoot, "recording.mp4");
|
|
const micPath = path.join(tempRoot, "recording.mic.webm");
|
|
|
|
await Promise.all([
|
|
fs.writeFile(videoPath, "video"),
|
|
fs.writeFile(micPath, "mic"),
|
|
fs.writeFile(`${micPath}.json`, `\ufeff${JSON.stringify({ startDelayMs: 2750 })}`),
|
|
]);
|
|
|
|
execFileMock.mockImplementation(
|
|
(
|
|
_file: string,
|
|
_args: string[],
|
|
_options: Record<string, unknown>,
|
|
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 { getCompanionAudioFallbackInfo } = await import("./diagnostics");
|
|
|
|
await expect(getCompanionAudioFallbackInfo(videoPath)).resolves.toEqual({
|
|
paths: [micPath],
|
|
startDelayMsByPath: {
|
|
[micPath]: 2750,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("scales audio mux timeout for long recordings", async () => {
|
|
const { getRecordingAudioMuxTimeoutMs } = await import("./diagnostics");
|
|
|
|
expect(getRecordingAudioMuxTimeoutMs(0)).toBe(5 * 60 * 1000);
|
|
expect(getRecordingAudioMuxTimeoutMs(29 * 60 + 29.41)).toBeGreaterThan(120000);
|
|
expect(getRecordingAudioMuxTimeoutMs(29 * 60 + 29.41)).toBeCloseTo(
|
|
(29 * 60 + 29.41) * 1000 + 60 * 1000,
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("uses video stream frames when container duration is misleading", async () => {
|
|
const { parseFfprobeVideoStreamDuration } = await import("./diagnostics");
|
|
|
|
expect(
|
|
parseFfprobeVideoStreamDuration(
|
|
JSON.stringify({
|
|
streams: [
|
|
{
|
|
duration: "18.000000",
|
|
nb_read_frames: "540",
|
|
avg_frame_rate: "30/1",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toEqual({
|
|
durationSeconds: 18,
|
|
frameCount: 540,
|
|
frameRate: 30,
|
|
});
|
|
});
|
|
|
|
it("derives video stream duration from frame count when stream duration is absent", async () => {
|
|
const { parseFfprobeVideoStreamDuration } = await import("./diagnostics");
|
|
|
|
expect(
|
|
parseFfprobeVideoStreamDuration(
|
|
JSON.stringify({
|
|
streams: [
|
|
{
|
|
nb_read_frames: "540",
|
|
avg_frame_rate: "30/1",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toEqual({
|
|
durationSeconds: 18,
|
|
frameCount: 540,
|
|
frameRate: 30,
|
|
});
|
|
});
|
|
|
|
it("writes a recording diagnostics sidecar with stream and audio probes", async () => {
|
|
const videoPath = path.join(tempRoot, "recording-123.mp4");
|
|
const micPath = path.join(tempRoot, "recording-123.mic.wav");
|
|
await Promise.all([
|
|
fs.writeFile(videoPath, "video"),
|
|
fs.writeFile(micPath, "mic"),
|
|
fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: 125 })),
|
|
]);
|
|
|
|
execFileMock.mockImplementation(
|
|
(
|
|
file: string,
|
|
args: string[],
|
|
_options: Record<string, unknown>,
|
|
callback: ExecFileCallback,
|
|
) => {
|
|
if (file === "ffprobe" || args.includes("-of")) {
|
|
callback(
|
|
null,
|
|
JSON.stringify({
|
|
streams: [
|
|
{
|
|
duration: "18.000000",
|
|
nb_read_frames: "540",
|
|
avg_frame_rate: "30/1",
|
|
},
|
|
],
|
|
}),
|
|
"",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const error = new Error("ffmpeg probe") as Error & { stderr?: string };
|
|
error.stderr = "Duration: 00:00:18.00, start: 0.000000";
|
|
callback(error, "", error.stderr);
|
|
},
|
|
);
|
|
|
|
const { getRecordingDiagnosticsPath, writeRecordingDiagnosticsSnapshot } = await import(
|
|
"./diagnostics"
|
|
);
|
|
|
|
const diagnosticsPath = await writeRecordingDiagnosticsSnapshot(videoPath, {
|
|
backend: "windows-wgc",
|
|
phase: "mux-start",
|
|
expectedDurationMs: 60_000,
|
|
outputPath: videoPath,
|
|
microphonePath: micPath,
|
|
details: {
|
|
hasMicrophone: true,
|
|
},
|
|
});
|
|
const diagnostics = JSON.parse(await fs.readFile(diagnosticsPath, "utf8"));
|
|
|
|
expect(diagnosticsPath).toBe(getRecordingDiagnosticsPath(videoPath));
|
|
expect(diagnostics.events).toHaveLength(1);
|
|
expect(diagnostics.latest.expectedDurationMs).toBe(60_000);
|
|
expect(diagnostics.latest.media.video.stream).toEqual({
|
|
durationSeconds: 18,
|
|
frameCount: 540,
|
|
frameRate: 30,
|
|
});
|
|
expect(diagnostics.latest.media.microphone).toMatchObject({
|
|
path: micPath,
|
|
exists: true,
|
|
containerDurationSeconds: 18,
|
|
startDelayMs: 125,
|
|
});
|
|
});
|
|
|
|
it("ignores invalid sidecar timing metadata values", async () => {
|
|
const micPath = path.join(tempRoot, "recording.mic.wav");
|
|
await Promise.all([
|
|
fs.writeFile(micPath, "mic"),
|
|
fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: -250 })),
|
|
]);
|
|
|
|
const { getCompanionAudioStartDelayMs } = await import("./diagnostics");
|
|
|
|
await expect(getCompanionAudioStartDelayMs(micPath)).resolves.toBeNull();
|
|
});
|
|
|
|
it("classifies wall-clock mic chunk gaps covered by pause intervals", async () => {
|
|
const { summarizeMicrophoneChunkTiming } = await import("./diagnostics");
|
|
|
|
expect(
|
|
summarizeMicrophoneChunkTiming(
|
|
[
|
|
{
|
|
index: 0,
|
|
size: 1024,
|
|
elapsedMs: 250,
|
|
deltaMs: null,
|
|
recordedElapsedMs: 250,
|
|
recordedDeltaMs: null,
|
|
},
|
|
{
|
|
index: 1,
|
|
size: 1024,
|
|
elapsedMs: 8250,
|
|
deltaMs: 8000,
|
|
recordedElapsedMs: 500,
|
|
recordedDeltaMs: 250,
|
|
},
|
|
],
|
|
[{ startElapsedMs: 250, endElapsedMs: 8250, durationMs: 7750 }],
|
|
250,
|
|
),
|
|
).toMatchObject({
|
|
status: "pause-accounted",
|
|
wallClockGapCount: 1,
|
|
recordedGapCount: 0,
|
|
pausedDurationMs: 7750,
|
|
});
|
|
});
|
|
|
|
it("flags recorded mic chunk gaps that remain after pause accounting", async () => {
|
|
const { summarizeMicrophoneChunkTiming } = await import("./diagnostics");
|
|
|
|
expect(
|
|
summarizeMicrophoneChunkTiming(
|
|
[
|
|
{
|
|
index: 0,
|
|
size: 1024,
|
|
elapsedMs: 250,
|
|
deltaMs: null,
|
|
recordedElapsedMs: 250,
|
|
recordedDeltaMs: null,
|
|
},
|
|
{
|
|
index: 1,
|
|
size: 1024,
|
|
elapsedMs: 2500,
|
|
deltaMs: 2250,
|
|
recordedElapsedMs: 2500,
|
|
recordedDeltaMs: 2250,
|
|
},
|
|
],
|
|
[],
|
|
250,
|
|
),
|
|
).toMatchObject({
|
|
status: "needs-review",
|
|
wallClockGapCount: 1,
|
|
recordedGapCount: 1,
|
|
});
|
|
});
|
|
|
|
it("rejects tiny MP4 container-only outputs before they reach the editor", async () => {
|
|
const videoPath = path.join(tempRoot, "recording-123.mp4");
|
|
await fs.writeFile(videoPath, Buffer.alloc(261));
|
|
|
|
const { validateRecordedVideo } = await import("./diagnostics");
|
|
|
|
await expect(validateRecordedVideo(videoPath)).rejects.toThrow(
|
|
"Recorded output is too small to contain playable video",
|
|
);
|
|
});
|
|
});
|