mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
411 lines
11 KiB
TypeScript
411 lines
11 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("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",
|
|
);
|
|
});
|
|
});
|