Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,15 @@ interface Window {
success: boolean;
paths: string[];
startDelayMsByPath?: Record<string, number>;
mediaInfoByPath?: Record<
string,
{
durationMs: number;
sampleRate: number | null;
channels: number | null;
hasAudioStream: boolean;
}
>;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
Expand Down
70 changes: 70 additions & 0 deletions electron/ipc/recording/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,76 @@ describe("getCompanionAudioFallbackPaths", () => {
startDelayMsByPath: {
[micPath]: 2750,
},
mediaInfoByPath: {
[micPath]: {
durationMs: 0,
sampleRate: null,
channels: null,
hasAudioStream: false,
},
},
});
});

it("returns probed companion audio media info for pure mic sidecars", async () => {
const videoPath = path.join(tempRoot, "recording.mp4");
const micPath = path.join(tempRoot, "recording.mic.wav");

await Promise.all([
fs.writeFile(videoPath, "video"),
fs.writeFile(micPath, "mic"),
fs.writeFile(`${micPath}.json`, JSON.stringify({ startDelayMs: 143 })),
]);

execFileMock.mockImplementation(
(
file: string,
args: string[],
_options: Record<string, unknown>,
callback: ExecFileCallback,
) => {
if (
file === "ffprobe" &&
args.includes("-select_streams") &&
args.includes("a:0")
) {
callback(
null,
JSON.stringify({
streams: [
{
duration: "147.360000",
sample_rate: "48000",
channels: 1,
},
],
}),
"",
);
return;
}

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]: 143,
},
mediaInfoByPath: {
[micPath]: {
durationMs: 147_360,
sampleRate: 48_000,
channels: 1,
hasAudioStream: true,
},
},
});
});

Expand Down
93 changes: 91 additions & 2 deletions electron/ipc/recording/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type VideoStreamDurationProbe = {
frameRate: number | null;
};

export type CompanionAudioMediaInfo = {
durationMs: number;
sampleRate: number | null;
channels: number | null;
hasAudioStream: boolean;
};

export type RecordingDiagnosticsSnapshot = {
backend: NativeCaptureDiagnostics["backend"];
phase:
Expand Down Expand Up @@ -305,6 +312,72 @@ export async function probeVideoStreamDurationSeconds(filePath: string): Promise
: probeMediaDurationSeconds(filePath);
}

function parseFfprobeAudioStreamInfo(output: string): CompanionAudioMediaInfo {
try {
const parsed = JSON.parse(output) as {
streams?: Array<{
duration?: unknown;
sample_rate?: unknown;
channels?: unknown;
}>;
};
const stream = parsed.streams?.[0];
if (!stream) {
return {
durationMs: 0,
sampleRate: null,
channels: null,
hasAudioStream: false,
};
}

return {
durationMs: Math.round((parsePositiveNumber(stream.duration) ?? 0) * 1000),
sampleRate: parsePositiveInteger(stream.sample_rate),
channels: parsePositiveInteger(stream.channels),
hasAudioStream: true,
};
} catch {
return {
durationMs: 0,
sampleRate: null,
channels: null,
hasAudioStream: false,
};
}
}

export async function probeCompanionAudioMediaInfo(
filePath: string,
): Promise<CompanionAudioMediaInfo> {
try {
const result = await execFileAsync(
getFfprobeBinaryPath(),
[
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=duration,sample_rate,channels",
"-of",
"json",
filePath,
],
{ timeout: 30000, maxBuffer: 2 * 1024 * 1024 },
);
const stdout = typeof result === "string" ? result : result.stdout;
return parseFfprobeAudioStreamInfo(stdout);
} catch {
return {
durationMs: 0,
sampleRate: null,
channels: null,
hasAudioStream: false,
};
}
}

export function getRecordingDiagnosticsPath(videoPath: string) {
return `${videoPath.replace(/\.[^.]+$/u, "")}.recording-diagnostics.json`;
}
Expand Down Expand Up @@ -364,9 +437,11 @@ async function describeAudioFile(filePath: string | null | undefined) {
}

const startDelayMs = await getCompanionAudioStartDelayMs(filePath);
const stream = await probeCompanionAudioMediaInfo(filePath);
return {
...media,
startDelayMs,
stream,
};
}

Expand Down Expand Up @@ -503,7 +578,7 @@ export async function getCompanionAudioFallbackPaths(videoPath: string) {
export async function getCompanionAudioFallbackInfo(videoPath: string) {
const companionCandidates = await getUsableCompanionAudioCandidates(videoPath);
if (companionCandidates.length === 0) {
return { paths: [], startDelayMsByPath: {} };
return { paths: [], startDelayMsByPath: {}, mediaInfoByPath: {} };
}

let paths: string[];
Expand Down Expand Up @@ -538,7 +613,7 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) {
),
);
if (companionPaths.length === 0) {
return { paths: [], startDelayMsByPath: {} };
return { paths: [], startDelayMsByPath: {}, mediaInfoByPath: {} };
}

paths = [videoPath, ...companionPaths];
Expand All @@ -559,12 +634,26 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) {
return [audioPath, startDelayMs] as const;
}),
);
const mediaInfoEntries = await Promise.all(
paths.map(async (audioPath) => {
if (audioPath === videoPath) {
return null;
}

return [audioPath, await probeCompanionAudioMediaInfo(audioPath)] as const;
}),
);

return {
paths,
startDelayMsByPath: Object.fromEntries(
metadataEntries.filter((entry): entry is readonly [string, number] => entry !== null),
),
mediaInfoByPath: Object.fromEntries(
mediaInfoEntries.filter(
(entry): entry is readonly [string, CompanionAudioMediaInfo] => entry !== null,
),
),
};
}

Expand Down
92 changes: 92 additions & 0 deletions electron/ipc/register/recording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const companionInfo = {
paths: ["C:\\Recordly\\recording.mic.wav"],
startDelayMsByPath: {
"C:\\Recordly\\recording.mic.wav": 143,
},
mediaInfoByPath: {
"C:\\Recordly\\recording.mic.wav": {
durationMs: 147_360,
sampleRate: 48_000,
channels: 1,
hasAudioStream: true,
},
},
};

const getCompanionAudioFallbackInfoMock = vi.fn();
const rememberApprovedLocalReadPathMock = vi.fn(async () => undefined);

vi.mock("electron", () => ({
app: {
getAppPath: () => process.cwd(),
getPath: () => process.cwd(),
isPackaged: false,
},
BrowserWindow: {
getAllWindows: () => [],
},
desktopCapturer: {
getSources: vi.fn(),
},
dialog: {
showMessageBox: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
},
shell: {
openExternal: vi.fn(),
},
systemPreferences: {
getMediaAccessStatus: vi.fn(),
askForMediaAccess: vi.fn(),
},
}));

vi.mock("../recording/diagnostics", () => ({
getCompanionAudioFallbackInfo: getCompanionAudioFallbackInfoMock,
getFileSizeIfPresent: vi.fn(),
recordNativeCaptureDiagnostics: vi.fn(),
summarizeMicrophoneChunkTiming: vi.fn(),
validateRecordedVideo: vi.fn(),
writeRecordingDiagnosticsSnapshot: vi.fn(),
}));

vi.mock("../project/manager", () => ({
rememberApprovedLocalReadPath: rememberApprovedLocalReadPathMock,
}));

describe("resolveVideoAudioFallbackPathsForIpc", () => {
beforeEach(() => {
getCompanionAudioFallbackInfoMock.mockReset();
rememberApprovedLocalReadPathMock.mockClear();
});

it("returns companion media info so renderer playback and waveforms can use probed duration", async () => {
getCompanionAudioFallbackInfoMock.mockResolvedValue(companionInfo);
const videoPath = "C:\\Recordly\\recording.mp4";

const { resolveVideoAudioFallbackPathsForIpc } = await import("./recording");

await expect(resolveVideoAudioFallbackPathsForIpc(videoPath)).resolves.toEqual({
success: true,
...companionInfo,
});
expect(rememberApprovedLocalReadPathMock).toHaveBeenCalledWith(videoPath);
expect(rememberApprovedLocalReadPathMock).toHaveBeenCalledWith(companionInfo.paths[0]);
});

it("returns a stable empty shape when no video path is available", async () => {
const { resolveVideoAudioFallbackPathsForIpc } = await import("./recording");

await expect(resolveVideoAudioFallbackPathsForIpc("")).resolves.toEqual({
success: true,
paths: [],
startDelayMsByPath: {},
mediaInfoByPath: {},
});
expect(getCompanionAudioFallbackInfoMock).not.toHaveBeenCalled();
});
});
41 changes: 26 additions & 15 deletions electron/ipc/register/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,31 @@ async function resolveExistingPath(...candidates: Array<string | null | undefine
return null;
}

export async function resolveVideoAudioFallbackPathsForIpc(videoPath: string) {
if (!videoPath) {
return { success: true, paths: [], startDelayMsByPath: {}, mediaInfoByPath: {} };
}

try {
const { paths, startDelayMsByPath, mediaInfoByPath } =
await getCompanionAudioFallbackInfo(videoPath);
await Promise.all([
rememberApprovedLocalReadPath(videoPath),
...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)),
]);
return { success: true, paths, startDelayMsByPath, mediaInfoByPath };
} catch (error) {
console.error("Failed to resolve companion audio fallback paths:", error);
return {
success: false,
paths: [],
startDelayMsByPath: {},
mediaInfoByPath: {},
error: String(error),
};
}
}

export function registerRecordingHandlers(
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
) {
Expand Down Expand Up @@ -1385,21 +1410,7 @@ export function registerRecordingHandlers(
});

ipcMain.handle("get-video-audio-fallback-paths", async (_event, videoPath: string) => {
if (!videoPath) {
return { success: true, paths: [], startDelayMsByPath: {} };
}

try {
const { paths, startDelayMsByPath } = await getCompanionAudioFallbackInfo(videoPath);
await Promise.all([
rememberApprovedLocalReadPath(videoPath),
...paths.map((fallbackPath) => rememberApprovedLocalReadPath(fallbackPath)),
]);
return { success: true, paths, startDelayMsByPath };
} catch (error) {
console.error("Failed to resolve companion audio fallback paths:", error);
return { success: false, paths: [], startDelayMsByPath: {}, error: String(error) };
}
return resolveVideoAudioFallbackPathsForIpc(videoPath);
});

ipcMain.handle("mux-native-windows-recording", async (_event, expectedDurationMs?: number) => {
Expand Down
4 changes: 4 additions & 0 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6366,6 +6366,10 @@ export default function VideoEditor() {
onSourceAudioTracksMetaChange={(tracks) => {
audio.onSourceAudioTracksMetaChange(tracks);
}}
sourceAudioFallbackPaths={audio.sourceAudioFallbackPaths}
sourceAudioFallbackMediaInfoByPath={
audio.sourceAudioFallbackMediaInfoByPath
}
/>
</div>
</div>
Expand Down
Loading