Files

638 lines
18 KiB
TypeScript

import { isLibrarySequenceSource } from "./sequenceSource";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import { COMPANION_AUDIO_LAYOUTS } from "../constants";
import { getFfmpegBinaryPath, getFfprobeBinaryPath } from "../ffmpeg/binary";
import { lastNativeCaptureDiagnostics, setLastNativeCaptureDiagnostics } from "../state";
import type { CompanionAudioCandidate, NativeCaptureDiagnostics } from "../types";
import { parseJsonWithByteOrderMark } from "../utils";
const execFileAsync = promisify(execFile);
export const MIN_VALID_RECORDED_VIDEO_BYTES = 1024;
export const RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS = 5 * 60 * 1000;
export const RECORDING_AUDIO_MUX_MAX_TIMEOUT_MS = 2 * 60 * 60 * 1000;
type CompanionAudioTimingMetadata = {
startDelayMs?: number;
};
export type MicrophoneChunkTimingEvent = {
index: number;
size: number;
elapsedMs: number;
deltaMs: number | null;
recordedElapsedMs?: number;
recordedDeltaMs?: number | null;
};
export type MicrophonePauseInterval = {
startElapsedMs: number;
endElapsedMs?: number;
durationMs?: number;
};
export type VideoStreamDurationProbe = {
durationSeconds: number | null;
frameCount: number | null;
frameRate: number | null;
};
export type RecordingDiagnosticsSnapshot = {
backend: NativeCaptureDiagnostics["backend"];
phase:
| `${NativeCaptureDiagnostics["phase"]}`
| "pad"
| "mic-sidecar"
| "mux-start"
| "mux-complete"
| "mux-error";
expectedDurationMs?: number | null;
outputPath?: string | null;
systemAudioPath?: string | null;
microphonePath?: string | null;
processOutput?: string;
error?: string;
details?: Record<string, unknown>;
};
type RecordingDiagnosticsLog = {
version: 1;
createdAt: string;
updatedAt: string;
videoPath: string;
diagnosticsPath: string;
events: unknown[];
latest?: unknown;
};
export function recordNativeCaptureDiagnostics(
diagnostics: Omit<NativeCaptureDiagnostics, "timestamp">,
) {
setLastNativeCaptureDiagnostics({
timestamp: new Date().toISOString(),
...diagnostics,
});
return lastNativeCaptureDiagnostics;
}
export async function getFileSizeIfPresent(filePath: string | null | undefined) {
if (!filePath) {
return null;
}
try {
const stat = await fs.stat(filePath);
return stat.size;
} catch {
return null;
}
}
export function parseFfmpegDurationSeconds(stderr: string) {
const match = stderr.match(/Duration:\s+(\d+):(\d+):(\d+(?:\.\d+)?)/i);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
if (![hours, minutes, seconds].every(Number.isFinite)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
export function getRecordingAudioMuxTimeoutMs(durationSeconds: number) {
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
return RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS;
}
const realtimeMuxBudgetMs = Math.ceil(durationSeconds * 1000) + 60 * 1000;
return Math.min(
RECORDING_AUDIO_MUX_MAX_TIMEOUT_MS,
Math.max(RECORDING_AUDIO_MUX_MIN_TIMEOUT_MS, realtimeMuxBudgetMs),
);
}
function getFiniteNonNegativeNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
}
export function summarizeMicrophoneChunkTiming(
chunkEvents: MicrophoneChunkTimingEvent[] | null | undefined,
pauseIntervals: MicrophonePauseInterval[] | null | undefined = [],
timesliceMs = 250,
) {
const normalizedTimesliceMs =
Number.isFinite(timesliceMs) && timesliceMs > 0 ? Math.round(timesliceMs) : 250;
const thresholdMs = Math.max(600, Math.round(normalizedTimesliceMs * 2.5));
const events = Array.isArray(chunkEvents) ? chunkEvents : [];
const pauses = Array.isArray(pauseIntervals) ? pauseIntervals : [];
const pausedDurationMs = pauses.reduce((total, interval) => {
const durationMs = getFiniteNonNegativeNumber(interval.durationMs);
return durationMs === null ? total : total + Math.round(durationMs);
}, 0);
const wallClockGaps = events
.filter((event) => (event.deltaMs ?? 0) > thresholdMs)
.map((event) => ({
index: event.index,
deltaMs: Math.round(event.deltaMs ?? 0),
elapsedMs: Math.round(event.elapsedMs),
}));
const recordedGaps = events
.filter((event) => {
const recordedDeltaMs =
getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0;
return recordedDeltaMs > thresholdMs;
})
.map((event) => ({
index: event.index,
deltaMs: Math.round(
getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0,
),
recordedElapsedMs: Math.round(event.recordedElapsedMs ?? event.elapsedMs),
}));
const maxWallClockDeltaMs = events.reduce(
(maxDeltaMs, event) => Math.max(maxDeltaMs, event.deltaMs ?? 0),
0,
);
const maxRecordedDeltaMs = events.reduce((maxDeltaMs, event) => {
const recordedDeltaMs =
getFiniteNonNegativeNumber(event.recordedDeltaMs) ?? event.deltaMs ?? 0;
return Math.max(maxDeltaMs, recordedDeltaMs);
}, 0);
const status =
recordedGaps.length > 0
? "needs-review"
: wallClockGaps.length > 0 && pausedDurationMs > 0
? "pause-accounted"
: "ok";
return {
status,
timesliceMs: normalizedTimesliceMs,
thresholdMs,
eventCount: events.length,
pauseIntervalCount: pauses.length,
pausedDurationMs,
maxWallClockDeltaMs: Math.round(maxWallClockDeltaMs),
maxRecordedDeltaMs: Math.round(maxRecordedDeltaMs),
wallClockGapCount: wallClockGaps.length,
recordedGapCount: recordedGaps.length,
wallClockGaps: wallClockGaps.slice(0, 10),
recordedGaps: recordedGaps.slice(0, 10),
};
}
/** Probe the duration of a media file (in seconds) using the container header. */
export async function probeMediaDurationSeconds(filePath: string): Promise<number> {
const start = Date.now();
const ffmpegPath = getFfmpegBinaryPath();
try {
await execFileAsync(ffmpegPath, ["-i", filePath, "-hide_banner"], { timeout: 5000 });
} catch (error) {
const stderr = (error as NodeJS.ErrnoException & { stderr?: string })?.stderr ?? "";
const duration = parseFfmpegDurationSeconds(stderr);
if (duration !== null) {
return duration;
}
} finally {
console.log(`[PERF:MAIN] probeMediaDurationSeconds: COMPLETED in ${Date.now() - start}ms`);
}
return 0;
}
function parsePositiveNumber(value: unknown) {
const parsed =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function parsePositiveInteger(value: unknown) {
const parsed = parsePositiveNumber(value);
return parsed === null ? null : Math.max(0, Math.round(parsed));
}
function parseFrameRate(value: unknown) {
if (typeof value !== "string") {
return null;
}
const [numeratorRaw, denominatorRaw] = value.split("/");
const numerator = Number(numeratorRaw);
const denominator = Number(denominatorRaw);
if (
!Number.isFinite(numerator) ||
!Number.isFinite(denominator) ||
numerator <= 0 ||
denominator <= 0
) {
return null;
}
return numerator / denominator;
}
export function parseFfprobeVideoStreamDuration(output: string): VideoStreamDurationProbe | null {
const parsed = JSON.parse(output) as {
streams?: Array<{
duration?: unknown;
nb_frames?: unknown;
nb_read_frames?: unknown;
avg_frame_rate?: unknown;
r_frame_rate?: unknown;
}>;
};
const stream = parsed.streams?.[0];
if (!stream) {
return null;
}
const frameRate = parseFrameRate(stream.avg_frame_rate) ?? parseFrameRate(stream.r_frame_rate);
const frameCount =
parsePositiveInteger(stream.nb_read_frames) ?? parsePositiveInteger(stream.nb_frames);
const streamDuration = parsePositiveNumber(stream.duration);
const frameDerivedDuration =
frameCount !== null && frameRate !== null && frameRate > 0 ? frameCount / frameRate : null;
return {
durationSeconds: streamDuration ?? frameDerivedDuration,
frameCount,
frameRate,
};
}
export async function probeVideoStreamDuration(
filePath: string,
): Promise<VideoStreamDurationProbe | null> {
const start = Date.now();
try {
const result = await execFileAsync(
getFfprobeBinaryPath(),
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=duration,nb_frames,nb_read_frames,avg_frame_rate,r_frame_rate",
"-of",
"json",
filePath,
],
{ timeout: 30000, maxBuffer: 2 * 1024 * 1024 },
);
const stdout = typeof result === "string" ? result : result.stdout;
return parseFfprobeVideoStreamDuration(stdout);
} catch {
return null;
} finally {
console.log(`[PERF:MAIN] probeVideoStreamDuration: COMPLETED in ${Date.now() - start}ms`);
}
}
export async function probeVideoStreamDurationSeconds(filePath: string): Promise<number> {
const probe = await probeVideoStreamDuration(filePath);
return probe?.durationSeconds && probe.durationSeconds > 0
? probe.durationSeconds
: probeMediaDurationSeconds(filePath);
}
export function getRecordingDiagnosticsPath(videoPath: string) {
return `${videoPath.replace(/\.[^.]+$/u, "")}.recording-diagnostics.json`;
}
function truncateDiagnosticsText(value: string | undefined, maxLength = 12000) {
if (!value || value.length <= maxLength) {
return value;
}
return `${value.slice(-maxLength)}\n[recordly: truncated to last ${maxLength} chars]`;
}
async function describeMediaFile(filePath: string | null | undefined) {
if (!filePath) {
return null;
}
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return {
path: filePath,
exists: false,
};
}
return {
path: filePath,
exists: true,
sizeBytes: stat.size,
containerDurationSeconds: await probeMediaDurationSeconds(filePath),
};
} catch {
return {
path: filePath,
exists: false,
};
}
}
async function describeVideoFile(filePath: string | null | undefined) {
const media = await describeMediaFile(filePath);
if (!media?.exists || !filePath) {
return media;
}
return {
...media,
stream: await probeVideoStreamDuration(filePath),
};
}
async function describeAudioFile(filePath: string | null | undefined) {
const media = await describeMediaFile(filePath);
if (!media?.exists || !filePath) {
return media;
}
const startDelayMs = await getCompanionAudioStartDelayMs(filePath);
return {
...media,
startDelayMs,
};
}
export async function writeRecordingDiagnosticsSnapshot(
videoPath: string,
snapshot: RecordingDiagnosticsSnapshot,
) {
const diagnosticsPath = getRecordingDiagnosticsPath(videoPath);
const now = new Date().toISOString();
let existing: RecordingDiagnosticsLog | null = null;
try {
const raw = await fs.readFile(diagnosticsPath, "utf8");
const parsed = parseJsonWithByteOrderMark<RecordingDiagnosticsLog | null>(raw);
if (parsed?.version === 1 && Array.isArray(parsed.events)) {
existing = parsed;
}
} catch {
// First diagnostics event for this recording.
}
const event = {
timestamp: now,
...snapshot,
processOutput: truncateDiagnosticsText(snapshot.processOutput),
media: {
video: await describeVideoFile(videoPath),
systemAudio: await describeAudioFile(snapshot.systemAudioPath),
microphone: await describeAudioFile(snapshot.microphonePath),
},
};
const log: RecordingDiagnosticsLog = existing ?? {
version: 1,
createdAt: now,
updatedAt: now,
videoPath,
diagnosticsPath,
events: [],
};
log.updatedAt = now;
log.videoPath = videoPath;
log.diagnosticsPath = diagnosticsPath;
log.events.push(event);
log.latest = event;
await fs.writeFile(diagnosticsPath, JSON.stringify(log, null, 2), "utf8");
return diagnosticsPath;
}
export async function getUsableCompanionAudioCandidates(
videoPath: string,
): Promise<CompanionAudioCandidate[]> {
const basePath = videoPath.replace(/\.[^.]+$/u, "");
const candidates: CompanionAudioCandidate[] = [];
for (const layout of COMPANION_AUDIO_LAYOUTS) {
const systemPath = `${basePath}${layout.systemSuffix}`;
const micPath = `${basePath}${layout.micSuffix}`;
const usablePaths: string[] = [];
for (const companionPath of [systemPath, micPath]) {
try {
const stat = await fs.stat(companionPath);
if (stat.size > 0) {
usablePaths.push(companionPath);
}
} catch {
// Missing companion audio is expected for many recordings.
}
}
if (usablePaths.length > 0) {
candidates.push({
platform: layout.platform,
systemPath,
micPath,
usablePaths,
});
}
}
return candidates;
}
async function readCompanionAudioTimingMetadata(
companionPath: string,
): Promise<CompanionAudioTimingMetadata | null> {
try {
const raw = await fs.readFile(`${companionPath}.json`, "utf8");
const parsed = parseJsonWithByteOrderMark<CompanionAudioTimingMetadata | null>(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch {
return null;
}
}
export async function getCompanionAudioStartDelayMs(companionPath: string) {
const metadata = await readCompanionAudioTimingMetadata(companionPath);
const startDelayMs = metadata?.startDelayMs;
if (!Number.isFinite(startDelayMs) || (startDelayMs ?? 0) < 0) {
return null;
}
return Math.round(startDelayMs ?? 0);
}
export async function hasEmbeddedAudioStream(videoPath: string) {
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
try {
const result = await execFileAsync(
ffmpegPath,
["-hide_banner", "-i", videoPath, "-map", "0:a:0", "-frames:a", "1", "-f", "null", "-"],
{ timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
);
stderr = result.stderr;
} catch (error) {
stderr = (error as NodeJS.ErrnoException & { stderr?: string }).stderr ?? "";
}
return /Stream #.*Audio:/i.test(stderr);
}
export async function getCompanionAudioFallbackPaths(videoPath: string) {
const { paths } = await getCompanionAudioFallbackInfo(videoPath);
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) {
return { paths: [], startDelayMsByPath: {} };
}
let paths: string[];
if (isLibrarySequenceSource(videoPath)) {
paths = companionCandidates.flatMap((candidate) => candidate.usablePaths);
} else if (await hasEmbeddedAudioStream(videoPath)) {
const hasUsableMacSystemCompanion = companionCandidates.some(
(candidate) =>
candidate.platform === "mac" &&
candidate.usablePaths.includes(candidate.systemPath),
);
const usableMacMicOnlyCompanions = Array.from(
new Set(
companionCandidates.flatMap((candidate) =>
candidate.platform === "mac" &&
!candidate.usablePaths.includes(candidate.systemPath) &&
candidate.usablePaths.includes(candidate.micPath)
? [candidate.micPath]
: [],
),
),
);
if (!hasUsableMacSystemCompanion && usableMacMicOnlyCompanions.length > 0) {
paths = usableMacMicOnlyCompanions;
} else if (hasUsableMacSystemCompanion) {
// 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(
companionCandidates.flatMap((candidate) =>
candidate.usablePaths.filter(
(companionPath) => companionPath === candidate.micPath,
),
),
),
);
if (companionPaths.length === 0) {
return { paths: [], startDelayMsByPath: {} };
}
paths = [videoPath, ...companionPaths];
}
} else {
paths = Array.from(
new Set(companionCandidates.flatMap((candidate) => candidate.usablePaths)),
);
}
const metadataEntries = await Promise.all(
paths.map(async (audioPath) => {
const startDelayMs = await getCompanionAudioStartDelayMs(audioPath);
if (!Number.isFinite(startDelayMs)) {
return null;
}
return [audioPath, startDelayMs] as const;
}),
);
return {
paths,
startDelayMsByPath: Object.fromEntries(
metadataEntries.filter((entry): entry is readonly [string, number] => entry !== null),
),
};
}
export async function validateRecordedVideo(videoPath: string) {
const stat = await fs.stat(videoPath);
if (!stat.isFile()) {
throw new Error(`Recorded output is not a file: ${videoPath}`);
}
if (stat.size <= 0) {
throw new Error(`Recorded output is empty: ${videoPath}`);
}
if (stat.size < MIN_VALID_RECORDED_VIDEO_BYTES) {
throw new Error(
`Recorded output is too small to contain playable video (${stat.size} bytes): ${videoPath}`,
);
}
const ffmpegPath = getFfmpegBinaryPath();
let stderr = "";
try {
const result = await execFileAsync(
ffmpegPath,
["-hide_banner", "-i", videoPath, "-map", "0:v:0", "-frames:v", "1", "-f", "null", "-"],
{ timeout: 20000, maxBuffer: 10 * 1024 * 1024 },
);
stderr = result.stderr;
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stderr?: string };
const output = execError.stderr?.trim();
throw new Error(output || `Recorded output could not be decoded: ${videoPath}`);
}
if (!/Stream #.*Video:/i.test(stderr)) {
throw new Error(`Recorded output does not contain a readable video stream: ${videoPath}`);
}
const durationSeconds = parseFfmpegDurationSeconds(stderr);
if (durationSeconds === null || durationSeconds <= 0) {
throw new Error(`Recorded output has an invalid duration: ${videoPath}`);
}
return {
fileSizeBytes: stat.size,
durationSeconds,
};
}