mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
635 lines
18 KiB
TypeScript
635 lines
18 KiB
TypeScript
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 (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,
|
|
};
|
|
}
|