Skip to content
Merged
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
63 changes: 63 additions & 0 deletions packages/engine/src/utils/ffprobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ describe("ffprobe missing-binary fallback", () => {

it("spawns the configured absolute FFprobe path when HYPERFRAMES_FFPROBE_PATH is set", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/tools/ffprobe.exe";
const successfulStderr = "recoverable diagnostic on a successful probe";
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
Expand All @@ -191,6 +192,7 @@ describe("ffprobe missing-binary fallback", () => {
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }],
format: { duration: "1.25", bit_rate: "128000" },
}),
stderr: successfulStderr,
},
]);
vi.resetModules();
Expand All @@ -200,7 +202,9 @@ describe("ffprobe missing-binary fallback", () => {
const meta = await extractAudioMetadata("/tmp/uses-configured-ffprobe.wav");

expect(meta.durationSeconds).toBe(1.25);
expect(JSON.stringify(meta)).not.toContain(successfulStderr);
expect(calls[0]?.command).toBe(resolve("/tools/ffprobe.exe"));
expect(calls[0]?.args.slice(0, 2)).toEqual(["-v", "error"]);
});

it.each([
Expand Down Expand Up @@ -363,6 +367,65 @@ describe("ffprobe missing-binary fallback", () => {
await expect(extractMediaMetadataMocked("/tmp/no-such-video.mp4")).rejects.toThrow(/ffprobe/);
});

it("surfaces bounded ffprobe stderr for invalid media", async () => {
const leadingNoise = "x".repeat(10_000);
const diagnostic = "Invalid data found when processing input";
const inputPath = "/tmp/render/My Secret Video.mp4";
const { spawn, calls } = createSpawnSpy([
{
kind: "exit",
code: 1,
stderr: `${leadingNoise}${inputPath}: ${diagnostic}`,
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { extractAudioMetadata } = await import("./ffprobe.js");

let thrown: unknown;
try {
await extractAudioMetadata(inputPath);
} catch (error) {
thrown = error;
}
expect(String(thrown)).toContain(diagnostic);
expect(String(thrown)).toContain("[input]");
expect(String(thrown)).not.toContain(inputPath);
expect(String(thrown)).not.toContain("My Secret Video.mp4");
expect(String(thrown).length).toBeLessThan(4_500);
expect(calls[0]?.args.slice(0, 2)).toEqual(["-v", "error"]);
expect(calls[0]?.args.at(-1)).toBe(inputPath);
});

it("redacts an input path fragment when the stderr tail starts inside its basename", async () => {
const diagnostic = "Invalid data found when processing input";
const inputPath = "/tmp/render/Confidential Client Preview.mp4";
const retainedPathFragment = "Client Preview.mp4";
const diagnosticPrefix = `: ${diagnostic} `;
const remainingBytes =
8 * 1024 - Buffer.byteLength(retainedPathFragment) - Buffer.byteLength(diagnosticPrefix);
const multibyteCount = Math.floor(remainingBytes / Buffer.byteLength("€"));
const trailingAscii = "x".repeat(remainingBytes - multibyteCount * Buffer.byteLength("€"));
const stderr = `${inputPath}${diagnosticPrefix}${"€".repeat(multibyteCount)}${trailingAscii}`;
const { spawn } = createSpawnSpy([{ kind: "exit", code: 1, stderr }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { extractAudioMetadata } = await import("./ffprobe.js");

let thrown: unknown;
try {
await extractAudioMetadata(inputPath);
} catch (error) {
thrown = error;
}
expect(String(thrown)).toContain(diagnostic);
expect(String(thrown)).toContain("[input]");
expect(String(thrown)).not.toContain(retainedPathFragment);
expect(String(thrown)).not.toContain("Client Preview.mp4");
});

it("extractAudioMetadata surfaces a ffprobe-missing error verbatim", async () => {
const { spawn, calls } = createSpawnSpy([{ kind: "missing" }]);
hidePathBinaries();
Expand Down
67 changes: 50 additions & 17 deletions packages/engine/src/utils/ffprobe.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,54 @@
// fallow-ignore-file code-duplication complexity
import { spawn } from "child_process";
import { readFileSync } from "fs";
import { extname } from "path";
import { basename, extname } from "path";
import { redactTelemetryString } from "@hyperframes/core";
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
import { ManagedChildProcess } from "./managedChildProcess.js";
import { trackChildProcess } from "./processTracker.js";

const FFPROBE_STDERR_MAX_BYTES = 8 * 1024;
const FFPROBE_ERROR_MAX_CHARS = 4 * 1024;

function redactFfprobeInput(stderr: string, filePath: string): string {
if (!filePath) return stderr;

let redacted = stderr.split(filePath).join("[input]");
const inputBasename = basename(filePath);
if (inputBasename) redacted = redacted.split(inputBasename).join("[input]");

// ManagedChildProcess retains an 8 KiB byte tail. When that boundary lands
// inside the input path, stderr starts with only a suffix of the path, so
// neither exact-path nor generic absolute-path redaction can recognize it.
if (!redacted.startsWith("[input]")) {
for (let offset = 1; offset < filePath.length; offset += 1) {
const suffix = filePath.slice(offset);
if (suffix.length < 4 || !redacted.startsWith(suffix)) continue;
const boundary = redacted[suffix.length];
if (boundary !== undefined && !/[\s:'")\]}]/.test(boundary)) continue;
redacted = `[input]${redacted.slice(suffix.length)}`;
break;
}
}

return redacted;
}

function sanitizeFfprobeDiagnostic(stderr: string, filePath: string): string {
const stderrWithoutInput = redactFfprobeInput(stderr, filePath);
const redacted = redactTelemetryString(stderrWithoutInput, FFPROBE_STDERR_MAX_BYTES);
if (redacted.length <= FFPROBE_ERROR_MAX_CHARS) return redacted;
return `…${redacted.slice(-(FFPROBE_ERROR_MAX_CHARS - 1))}`;
}

/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
async function runFfprobe(args: string[], signal?: AbortSignal): Promise<string> {
async function runFfprobe(
filePath: string,
argsWithoutInput: string[],
signal?: AbortSignal,
): Promise<string> {
const command = getFfprobeBinary();
const proc = spawn(command, args);
const proc = spawn(command, ["-v", "error", ...argsWithoutInput, filePath]);
trackChildProcess(proc);
let stdout = "";
proc.stdout.on("data", (data) => {
Expand All @@ -18,6 +57,7 @@ async function runFfprobe(args: string[], signal?: AbortSignal): Promise<string>
const managed = new ManagedChildProcess(proc, {
signal,
deadlineAtMs: Date.now() + 30_000,
stderrMaxBytes: FFPROBE_STDERR_MAX_BYTES,
});
const outcome = await managed.wait();
if (outcome.reason === "spawn_error") {
Expand All @@ -32,8 +72,9 @@ async function runFfprobe(args: string[], signal?: AbortSignal): Promise<string>
throw outcome.error ?? new Error(outcome.stderr);
}
if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
const diagnostic = sanitizeFfprobeDiagnostic(outcome.stderr, filePath);
throw new Error(
`[FFmpeg] ffprobe ${outcome.reason} with code ${outcome.exitCode}: ${outcome.stderr}`,
`[FFmpeg] ffprobe ${outcome.reason} with code ${outcome.exitCode}: ${diagnostic}`,
);
}
return stdout;
Expand Down Expand Up @@ -263,14 +304,11 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad

let output: FFProbeOutput | null = null;
try {
const stdout = await runFfprobe([
"-v",
"quiet",
const stdout = await runFfprobe(filePath, [
"-print_format",
"json",
"-show_format",
"-show_streams",
filePath,
]);
output = parseProbeJson(stdout);
} catch (error) {
Expand Down Expand Up @@ -361,7 +399,8 @@ export async function extractAudioMetadata(

const probePromise = (async (): Promise<AudioMetadata> => {
const stdout = await runFfprobe(
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
filePath,
["-print_format", "json", "-show_format", "-show_streams"],
options?.signal,
);
const output = parseProbeJson(stdout);
Expand All @@ -373,17 +412,14 @@ export async function extractAudioMetadata(
const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
const audioCodec = audioStream.codec_name || "unknown";
if (audioCodec === "aac" && sampleRate > 0) {
const packetStdout = await runFfprobe([
"-v",
"quiet",
const packetStdout = await runFfprobe(filePath, [
"-select_streams",
"a:0",
"-count_packets",
"-show_entries",
"stream=nb_read_packets",
"-print_format",
"json",
filePath,
]);
const packetOutput = parseProbeJson(packetStdout);
const packetCount = Number(packetOutput.streams[0]?.nb_read_packets);
Expand Down Expand Up @@ -441,9 +477,7 @@ export async function analyzeKeyframeIntervals(filePath: string): Promise<Keyfra
}

async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<KeyframeAnalysis> {
const stdout = await runFfprobe([
"-v",
"quiet",
const stdout = await runFfprobe(filePath, [
"-select_streams",
"v:0",
"-skip_frame",
Expand All @@ -452,7 +486,6 @@ async function analyzeKeyframeIntervalsUncached(filePath: string): Promise<Keyfr
"frame=pts_time",
"-of",
"csv=p=0",
filePath,
]);

const timestamps = stdout
Expand Down
Loading