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
3 changes: 3 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,13 @@ export {
extractMediaMetadata,
extractVideoMetadata,
extractAudioMetadata,
extractVideoStreamStats,
validateVideoStreamParity,
analyzeKeyframeIntervals,
type VideoMetadata,
type AudioMetadata,
type KeyframeAnalysis,
type VideoStreamStats,
} from "./utils/ffprobe.js";

export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
Expand Down
36 changes: 34 additions & 2 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,10 @@ describe("muxVideoWithAudio audio codec handling", () => {
"+faststart",
"-avoid_negative_ts",
"make_zero",
"-r",
"30",
"-y",
"/tmp/output.mp4",
]);
expect(calls[0]!.args).not.toContain("-r");
expect(calls[0]!.args).not.toContain("-shortest");
expect(calls[0]!.args).not.toContain("-use_editlist");

Expand Down Expand Up @@ -596,6 +595,39 @@ describe("muxVideoWithAudio audio codec handling", () => {
});
});

describe("stream-copy remux frame preservation", () => {
it("does not apply an output -r while copying video during faststart", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { applyFaststart } = await import("./chunkEncoder.js");
const faststartPromise = applyFaststart(
"/tmp/video-only.mp4",
"/tmp/output.mp4",
undefined,
undefined,
{ num: 30, den: 1 },
);

expect(calls).toHaveLength(1);
expect(calls[0]!.args).toEqual([
"-i",
"/tmp/video-only.mp4",
"-c",
"copy",
"-movflags",
"+faststart",
"-y",
"/tmp/output.mp4",
]);
expect(calls[0]!.args).not.toContain("-r");

emitClose(calls[0]!.proc, 0);
await expect(faststartPromise).resolves.toMatchObject({ success: true });
});
});

describe("getEncoderPreset", () => {
it("returns h264 with yuv420p for mp4 format", () => {
const preset = getEncoderPreset("standard", "mp4");
Expand Down
22 changes: 8 additions & 14 deletions packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,7 @@ export async function muxVideoWithAudio(
outputPath: string,
signal?: AbortSignal,
config?: MuxVideoWithAudioOptions,
fps?: Fps,
_fps?: Fps,
): Promise<MuxResult> {
const outputDir = dirname(outputPath);
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
Expand Down Expand Up @@ -792,12 +792,10 @@ export async function muxVideoWithAudio(
// PTS bases can diverge during mux and reintroduce negative DTS. See
// buildEncoderArgs for the full reasoning on why that breaks playback.
args.push("-avoid_negative_ts", "make_zero");
if (fps !== undefined) {
// Set the exact output framerate so the muxer doesn't PTS-average a
// fractional rational like `360000/12001` instead of `30/1` into the
// output container metadata. `-c:v copy` is retained; no re-encode.
args.push("-r", fpsToFfmpegArg(fps));
}
// Do not pass output `-r` while stream-copying video. FFmpeg is allowed to
// drop or retimestamp packets to satisfy an output rate even with `-c:v
// copy`, which can turn a complete long render into a short sparse stream.
// The encoded video already owns authoritative frame timestamps.
args.push("-y", outputPath);

const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
Expand All @@ -824,7 +822,7 @@ export async function applyFaststart(
outputPath: string,
signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
fps?: Fps,
_fps?: Fps,
): Promise<MuxResult> {
// faststart is MP4-only (moves moov atom to file start for streaming).
// WebM and MOV don't need it — skip the re-mux.
Expand All @@ -833,12 +831,8 @@ export async function applyFaststart(
return { success: true, outputPath, durationMs: 0 };
}
const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"];
if (fps !== undefined) {
// Set the exact output framerate so the final remux doesn't PTS-average
// a fractional rational like `360000/12001` instead of `30/1` into the
// output container metadata. `-c copy` is retained; no re-encode.
args.push("-r", fpsToFfmpegArg(fps));
}
// Preserve the encoded stream timestamps. Output `-r` is not safe with
// stream copy because FFmpeg may drop or retimestamp packets.
args.push("-y", outputPath);

const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
Expand Down
74 changes: 72 additions & 2 deletions packages/engine/src/utils/ffprobe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { EventEmitter } from "events";
import { readFileSync } from "fs";
import { resolve } from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { extractMediaMetadata, extractPngMetadataFromBuffer } from "./ffprobe.js";
import {
extractMediaMetadata,
extractPngMetadataFromBuffer,
parseVideoStreamStatsProbe,
validateVideoStreamParity,
} from "./ffprobe.js";

function crc32(buf: Buffer): number {
let crc = 0xffffffff;
Expand All @@ -17,6 +22,50 @@ function crc32(buf: Buffer): number {
return (crc ^ 0xffffffff) >>> 0;
}

describe("video stream copy parity", () => {
it("parses packet count and stream duration from a count-packets probe", () => {
expect(
parseVideoStreamStatsProbe(
JSON.stringify({
streams: [{ codec_type: "video", duration: "1050.900000", nb_read_packets: "31527" }],
format: { duration: "1050.900000" },
}),
),
).toEqual({ durationSeconds: 1050.9, frameCount: 31527 });
});

it("rejects a mux output that silently drops copied video packets", () => {
expect(() =>
validateVideoStreamParity(
{ durationSeconds: 1050.9, frameCount: 31527 },
{ durationSeconds: 1050.9, frameCount: 162 },
),
).toThrow(/31527.*162/);
});

it("rejects stream-duration drift even when packet counts match", () => {
expect(() =>
validateVideoStreamParity(
{ durationSeconds: 1050.9, frameCount: 31527 },
{ durationSeconds: 5.4, frameCount: 31527 },
),
).toThrow(/duration/i);
});

it("does not substitute longer container audio duration when video stream duration is absent", () => {
const output = parseVideoStreamStatsProbe(
JSON.stringify({
streams: [{ codec_type: "video", nb_read_packets: "60" }],
format: { duration: "2.162000" },
}),
);
expect(output).toEqual({ durationSeconds: undefined, frameCount: 60 });
expect(() =>
validateVideoStreamParity({ durationSeconds: 2, frameCount: 60 }, output),
).not.toThrow();
});
});

function pngChunk(type: string, data: number[]): Buffer {
const chunkData = Buffer.from(data);
const header = Buffer.alloc(8);
Expand Down Expand Up @@ -118,18 +167,22 @@ interface SpawnCall {
interface FakeProc extends EventEmitter {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
}

type SpawnOutcome =
| { kind: "missing" }
| { kind: "hang" }
| { kind: "error"; message: string; code?: string }
| { kind: "exit"; code: number; stdout?: string; stderr?: string };

function createSpawnSpy(outcomes: SpawnOutcome[]): {
spawn: (command: string, args: readonly string[]) => FakeProc;
calls: SpawnCall[];
procs: FakeProc[];
} {
const calls: SpawnCall[] = [];
const procs: FakeProc[] = [];
let invocation = 0;
const spawn = (command: string, args: readonly string[]): FakeProc => {
calls.push({ command, args });
Expand All @@ -139,9 +192,12 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {
const proc = new EventEmitter() as FakeProc;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
procs.push(proc);

process.nextTick(() => {
if (!outcome) return;
if (outcome.kind === "hang") return;
if (outcome.kind === "missing") {
const err = new Error("spawn ffprobe ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
Expand All @@ -161,7 +217,7 @@ function createSpawnSpy(outcomes: SpawnOutcome[]): {

return proc;
};
return { spawn, calls };
return { spawn, calls, procs };
}

describe("ffprobe missing-binary fallback", () => {
Expand Down Expand Up @@ -351,4 +407,18 @@ describe("ffprobe missing-binary fallback", () => {

await expect(extractAudioMetadata("/tmp/example.mp3")).rejects.toThrow(/install FFmpeg/i);
});

it("kills a full-stream packet probe when assembly is aborted", async () => {
const { spawn, procs } = createSpawnSpy([{ kind: "hang" }]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractVideoStreamStats } = await import("./ffprobe.js");
const controller = new AbortController();

const probe = extractVideoStreamStats("/tmp/long-video.mp4", controller.signal);
controller.abort();

await expect(probe).rejects.toThrow(/cancelled/i);
expect(procs[0]?.kill).toHaveBeenCalledWith("SIGTERM");
});
});
104 changes: 99 additions & 5 deletions packages/engine/src/utils/ffprobe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,39 @@ import { readFileSync } from "fs";
import { extname } from "path";
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";

interface RunFfprobeOptions {
signal?: AbortSignal;
timeoutMs?: number;
}

/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
function runFfprobe(args: string[]): Promise<string> {
function runFfprobe(args: string[], options?: RunFfprobeOptions): Promise<string> {
return new Promise((resolve, reject) => {
const command = getFfprobeBinary();
const proc = spawn(command, args);
let stdout = "";
let stderr = "";
let settled = false;
const finish = (error?: Error) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
options?.signal?.removeEventListener("abort", onAbort);
if (error) reject(error);
else resolve(stdout);
};
const onAbort = () => {
proc.kill("SIGTERM");
finish(new Error("[FFmpeg] ffprobe cancelled"));
};
const timer = options?.timeoutMs
? setTimeout(() => {
proc.kill("SIGTERM");
finish(new Error(`[FFmpeg] ffprobe timed out after ${options.timeoutMs} ms`));
}, options.timeoutMs)
: undefined;
if (options?.signal?.aborted) onAbort();
else options?.signal?.addEventListener("abort", onAbort, { once: true });
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
Expand All @@ -19,23 +45,23 @@ function runFfprobe(args: string[]): Promise<string> {
});
proc.on("close", (code) => {
if (code !== 0) {
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
finish(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
} else {
resolve(stdout);
finish();
}
});
proc.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
reject(
finish(
new Error(
configured
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
),
);
} else {
reject(err);
finish(err);
}
});
});
Expand Down Expand Up @@ -98,6 +124,7 @@ interface FFProbeStream {
height?: number;
duration?: string;
nb_frames?: string;
nb_read_packets?: string;
pix_fmt?: string;
r_frame_rate?: string;
avg_frame_rate?: string;
Expand All @@ -109,6 +136,73 @@ interface FFProbeStream {
tags?: Record<string, string>;
}

export interface VideoStreamStats {
/** True video-stream duration. Undefined when the container omits it. */
durationSeconds?: number;
frameCount: number;
}

export function parseVideoStreamStatsProbe(stdout: string): VideoStreamStats {
const output = parseProbeJson(stdout);
const videoStream = output.streams.find((stream) => stream.codec_type === "video");
if (!videoStream) throw new Error("[FFmpeg] No video stream found");

const frameCount = Number.parseInt(
videoStream.nb_read_packets ?? videoStream.nb_frames ?? "",
10,
);
const parsedDuration = Number.parseFloat(videoStream.duration ?? "");
const durationSeconds =
Number.isFinite(parsedDuration) && parsedDuration > 0 ? parsedDuration : undefined;
if (!Number.isFinite(frameCount) || frameCount < 1) {
throw new Error("[FFmpeg] ffprobe did not report a video packet count");
}
return { durationSeconds, frameCount };
}

export async function extractVideoStreamStats(
filePath: string,
signal?: AbortSignal,
): Promise<VideoStreamStats> {
const stdout = await runFfprobe(
[
"-v",
"quiet",
"-count_packets",
"-select_streams",
"v:0",
"-print_format",
"json",
"-show_entries",
"stream=codec_type,duration,nb_read_packets,nb_frames:format=duration",
filePath,
],
{ signal, timeoutMs: 300_000 },
);
return parseVideoStreamStatsProbe(stdout);
}

export function validateVideoStreamParity(
source: VideoStreamStats,
output: VideoStreamStats,
): void {
if (source.frameCount !== output.frameCount) {
throw new Error(
`Video stream frame parity failed after mux: source=${source.frameCount}, output=${output.frameCount}`,
);
}
if (source.durationSeconds === undefined || output.durationSeconds === undefined) return;
const frameDuration = source.durationSeconds / source.frameCount;
const toleranceSeconds = Math.max(0.05, frameDuration * 2);
const durationDrift = Math.abs(source.durationSeconds - output.durationSeconds);
if (durationDrift > toleranceSeconds) {
throw new Error(
`Video stream duration parity failed after mux: source=${source.durationSeconds.toFixed(3)}s, ` +
`output=${output.durationSeconds.toFixed(3)}s, drift=${durationDrift.toFixed(3)}s`,
);
}
}

interface FFProbeFormat {
duration?: string;
bit_rate?: string;
Expand Down
Loading
Loading