Skip to content
Open
48 changes: 48 additions & 0 deletions packages/engine/src/services/chunkEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,54 @@ describe("muxVideoWithAudio audio codec handling", () => {
});
});

it("keeps negative-timestamp repair for an M4A without a known priming edit list", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.mp4",
"/tmp/audio.duration-normalized.m4a",
"/tmp/output.mp4",
undefined,
{ audioCodec: "aac" },
{ num: 30, den: 1 },
);

await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("copy");
expect(calls[0]!.args).toContain("-avoid_negative_ts");

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

it("preserves a known M4A priming edit list instead of shifting copied video", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));

const { muxVideoWithAudio } = await import("./chunkEncoder.js");
const muxPromise = muxVideoWithAudio(
"/tmp/video-only.mp4",
"/tmp/audio.duration-normalized.m4a",
"/tmp/output.mp4",
undefined,
{ audioCodec: "aac", preserveAudioPrimingEditList: true },
{ num: 30, den: 1 },
);

await flushMuxCodecResolution();
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toContain("copy");
expect(calls[0]!.args).not.toContain("-avoid_negative_ts");

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

it("uses the caller-provided AAC codec contract instead of the sidecar extension", async () => {
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
Expand Down
10 changes: 9 additions & 1 deletion packages/engine/src/services/chunkEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export interface MuxVideoWithAudioOptions extends Partial<
* depend on the file extension alone.
*/
audioCodec?: "aac";
/** Preserve a priming edit list known to have been created by AAC re-encoding. */
preserveAudioPrimingEditList?: boolean;
/** Hard cap copied audio to the already-encoded video's exact duration. */
}

async function shouldCopyAacSidecar(
Expand Down Expand Up @@ -690,9 +693,14 @@ export async function muxVideoWithAudio(
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
}
}
const copiesContainerizedAac =
!isWebm && shouldCopyAudio && config?.preserveAudioPrimingEditList === true;
// 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");
// A freshly encoded M4A is the exception: its edit list already hides the
// AAC priming packet. `make_zero` discards that edit and shifts copied video
// forward by one AAC frame (~21ms), creating a visible first-frame offset.
if (!copiesContainerizedAac) 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
Expand Down
23 changes: 16 additions & 7 deletions packages/producer/src/services/distributed/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,12 @@ export async function assemble(
}

// ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
let audioForMux: string | null = null;
let normalizedAudio: {
path: string;
preserveAudioPrimingEditList: boolean;
} | null = null;
if (audioPath !== null && existsSync(audioPath)) {
const paddedAudioPath = join(workDir, "audio-padded.aac");
const paddedAudioPath = join(workDir, "audio-padded.m4a");
const padTrimResult = await padOrTrimAudioToVideoFrameCount({
videoPath: postConcatPath,
audioPath,
Expand All @@ -301,7 +304,10 @@ export async function assemble(
if (!padTrimResult.success) {
throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`);
}
audioForMux = paddedAudioPath;
normalizedAudio = {
path: paddedAudioPath,
preserveAudioPrimingEditList: padTrimResult.operation !== "copy",
};
log.info("[assemble] audio normalized for mux", {
operation: padTrimResult.operation,
targetDurationSeconds: padTrimResult.targetDurationSeconds,
Expand All @@ -314,14 +320,17 @@ export async function assemble(
// because it operates on a `RenderJob` and emits `updateJobStatus`
// payloads — the distributed activity has no job to thread through.
const muxOutputPath =
audioForMux !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath;
if (audioForMux !== null) {
normalizedAudio !== null ? join(workDir, `mux.${plan.dimensions.format}`) : postConcatPath;
if (normalizedAudio !== null) {
const muxResult = await muxVideoWithAudio(
postConcatPath,
audioForMux,
normalizedAudio.path,
muxOutputPath,
abortSignal,
{ audioCodec: "aac" },
{
audioCodec: "aac",
preserveAudioPrimingEditList: normalizedAudio.preserveAudioPrimingEditList,
},
{ num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
);
if (!muxResult.success) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { padOrTrimAudioToVideoFrameCount } from "./audioPadTrim.js";

const dirs: string[] = [];
afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })));

const hasFfmpeg = (() => {
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
execFileSync("ffprobe", ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
})();

describe.skipIf(!hasFfmpeg)("audio pad real-media packet contract", () => {
it("normalizes a tiny raw-ADTS pad without an oversized terminal packet", async () => {
const dir = mkdtempSync(join(tmpdir(), "hf-pad-"));
dirs.push(dir);
const input = join(dir, "input.aac");
const output = join(dir, "normalized.m4a");
execFileSync("ffmpeg", [
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=16.04",
"-c:a",
"aac",
"-b:a",
"192k",
"-f",
"adts",
input,
]);
const result = await padOrTrimAudioToVideoFrameCount({
videoPath: join(dir, "video.mp4"),
audioPath: input,
outputPath: output,
probeVideoFrameInfo: async () => ({ frameCount: 482, fpsNum: 30, fpsDen: 1 }),
probeAudioInfo: async () => ({ durationSeconds: 16.04 }),
runFfmpeg: async (args) => {
const p = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]);
return { success: p.status === 0, error: p.stderr?.toString() };
},
});
expect(result.success).toBe(true);
const probe: {
streams?: Array<{ duration?: string }>;
packets?: Array<{ duration_time?: string }>;
} = JSON.parse(
execFileSync(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"stream=duration",
"-show_entries",
"packet=duration_time",
"-of",
"json",
output,
],
{ encoding: "utf8" },
),
);
const duration = Number(probe.streams?.[0]?.duration);
const packets = probe.packets ?? [];
expect(duration).toBeGreaterThan(16.0);
expect(duration).toBeLessThan(16.1);
expect(Number(packets.at(-1)?.duration_time)).toBeLessThan(0.1);
});
});
127 changes: 35 additions & 92 deletions packages/producer/src/services/render/audioPadTrim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,72 +24,41 @@ import {
} from "./audioPadTrim.js";

describe("buildPadTrimAudioArgs", () => {
it("emits a concat-copy pad plan when audio is shorter than target", () => {
const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0, {
sampleRate: 48000,
channels: 2,
});
it("emits a decode/filter/re-encode pad plan when audio is shorter than target", () => {
const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0);
expect(plan.operation).toBe("pad");
expect(plan.steps).toHaveLength(2);

const silenceArgs = plan.steps[0]!.args;
expect(plan.steps[0]!.kind).toBe("pad-silence");
expect(silenceArgs).not.toContain("/tmp/in.aac");
expect(silenceArgs[silenceArgs.indexOf("-i") + 1]).toBe(
"anullsrc=channel_layout=stereo:sample_rate=48000",
);
expect(silenceArgs[silenceArgs.indexOf("-t") + 1]).toBe("1.000000");
expect(silenceArgs[silenceArgs.indexOf("-c:a") + 1]).toBe("aac");

const concatArgs = plan.steps[1]!.args;
expect(plan.steps[1]!.kind).toBe("pad-concat");
expect(concatArgs).toContain("concat");
// The concat script is passed via a real file (NOT `pipe:0`). Feeding
// it through stdin makes FFmpeg's URL joiner prepend `pipe:` to bare
// absolute paths in the script — the demuxer then tries to open e.g.
// `pipe:/tmp/foo.aac` and fails with "Impossible to open pipe:/…".
// Materializing to a file matches `assemble.ts`'s concat convention.
expect(plan.steps[1]!.concatListPath).toBe("/tmp/out.aac.concat-list.txt");
expect(concatArgs[concatArgs.indexOf("-i") + 1]).toBe("/tmp/out.aac.concat-list.txt");
expect(concatArgs[concatArgs.indexOf("-c:a") + 1]).toBe("copy");
expect(concatArgs[concatArgs.length - 1]).toBe("/tmp/out.aac");
// Concat script MUST use bare paths, NOT `file://` URLs. FFmpeg 8.x
// on Windows can't open `file:///C:/…` URLs from the concat demuxer
// (field-signal ts=1784169914 / 1784177061 / 1784177375). Regression
// pin: the `file://` scheme prefix must never appear in the concat
// list content.
expect(plan.steps[1]!.concatListContent).toContain("file '/tmp/in.aac'");
expect(plan.steps[1]!.concatListContent).toContain("file '/tmp/out.aac.pad-silence.aac'");
expect(plan.steps[1]!.concatListContent).not.toContain("file://");
// Cleanup includes BOTH the silence tail and the concat list script.
expect(plan.cleanupPaths).toEqual([
"/tmp/out.aac.pad-silence.aac",
"/tmp/out.aac.concat-list.txt",
]);

const reencodedSourceStep = plan.steps.find(
(step) =>
step.args.includes("/tmp/in.aac") && step.args[step.args.indexOf("-c:a") + 1] === "aac",
);
expect(reencodedSourceStep).toBeUndefined();
expect(plan.steps).toHaveLength(1);
const args = plan.steps[0]!.args;
expect(args[args.indexOf("-i") + 1]).toBe("/tmp/in.aac");
expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000");
expect(args[args.indexOf("-t") + 1]).toBe("5.000000");
expect(args[args.indexOf("-c:a") + 1]).toBe("aac");
// The single-step filter plan has no intermediate artifacts to clean up.
expect(plan.cleanupPaths).toEqual([]);
});

it("keeps the legacy args helper on the first pad materialization step", () => {
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0);
expect(operation).toBe("pad");
expect(args).not.toContain("/tmp/in.aac");
expect(args[args.indexOf("-t") + 1]).toBe("1.000000");
expect(args).toContain("/tmp/in.aac");
expect(args[args.indexOf("-t") + 1]).toBe("5.000000");
});

it("emits -t when audio is longer than target", () => {
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 6.123, 5.0);
it("filter-trims and re-encodes AAC packet padding beyond the target", () => {
const { args, operation } = buildPadTrimAudioArgs(
"/tmp/in.aac",
"/tmp/out.m4a",
15.018667,
15.0,
);
expect(operation).toBe("trim");
const tIdx = args.indexOf("-t");
expect(tIdx).toBeGreaterThan(-1);
expect(args[tIdx + 1]).toBe("5.000000");
// Trim preserves AAC stream copy.
const filterIdx = args.indexOf("-af");
expect(args[filterIdx + 1]).toBe("atrim=duration=15.000000,asetpts=PTS-STARTPTS");
expect(args[args.indexOf("-t") + 1]).toBe("15.000000");
const codecIdx = args.indexOf("-c:a");
expect(args[codecIdx + 1]).toBe("copy");
expect(args[codecIdx + 1]).toBe("aac");
expect(args[args.indexOf("-b:a") + 1]).toBe("192k");
expect(args.at(-1)).toBe("/tmp/out.m4a");
});

it("emits a plain copy when source duration matches target within ~1ms", () => {
Expand Down Expand Up @@ -123,7 +92,7 @@ describe("buildPadTrimAudioArgs", () => {
expect(trimNeeded.operation).toBe("trim");
});

it("does not emit `file://` URLs in the pad-concat script (FFmpeg 8.x Windows compat)", () => {
it("uses apad for Windows-safe duration normalization", () => {
// Regression pin for field-signal reports ts=1784169914 / 1784177061 /
// ts=1784177375 (win32/x64, CLI 0.7.59, ffmpeg 8.1.1-full_build). The
// concat demuxer's file open on Windows in FFmpeg 8.x rejects
Expand All @@ -137,34 +106,9 @@ describe("buildPadTrimAudioArgs", () => {
5.0,
);
expect(winPlan.operation).toBe("pad");
const concatStep = winPlan.steps.find((s) => s.kind === "pad-concat");
expect(concatStep).toBeDefined();
expect(concatStep!.concatListContent).toBeDefined();
expect(concatStep!.concatListContent).not.toContain("file://");
expect(concatStep!.concatListContent).not.toContain("file:\\\\");
// Bare Windows paths appear as-is in the concat directives.
expect(concatStep!.concatListContent).toContain(
"file 'C:\\Users\\alice\\AppData\\Local\\Temp\\hf-render-abc\\audio.aac'",
);
});

it("materializes the pad-concat script to a real file (not `pipe:0`)", () => {
// Regression pin for the Linux CI failure that surfaced when the
// fix originally dropped `file://` while still feeding the concat
// script via `pipe:0`. FFmpeg's URL joiner resolves bare absolute
// paths against the base `pipe:` URL, producing `pipe:/tmp/foo.aac`
// which the demuxer then tries to open as a pipe. Materializing to
// a real file makes the demuxer treat absolute paths as absolute.
const plan = buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0);
const concatStep = plan.steps.find((s) => s.kind === "pad-concat");
expect(concatStep).toBeDefined();
// The concat script must NOT be piped in via stdin.
expect(concatStep!.args).not.toContain("pipe:0");
// The `-i` arg points at the materialized concat list file.
const iIdx = concatStep!.args.indexOf("-i");
expect(concatStep!.args[iIdx + 1]).toBe(concatStep!.concatListPath);
// The concat list file is cleaned up alongside the silence tail.
expect(plan.cleanupPaths).toContain(concatStep!.concatListPath!);
const args = winPlan.steps[0]!.args;
expect(args).toContain("-af");
expect(args[args.indexOf("-af") + 1]).toBe("apad=whole_dur=5.000000");
});
});

Expand Down Expand Up @@ -226,11 +170,10 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
expect(result.operation).toBe("pad");
expect(result.targetDurationSeconds).toBe(6);
expect(result.sourceDurationSeconds).toBe(5.5);
expect(captured.args).toHaveLength(2);
expect(captured.args).toHaveLength(1);
const tIdx = captured.args[0]!.indexOf("-t");
expect(captured.args[0]![tIdx + 1]).toBe("0.500000");
expect(captured.args[0]).not.toContain("/tmp/a.aac");
expect(captured.args[1]![captured.args[1]!.indexOf("-c:a") + 1]).toBe("copy");
expect(captured.args[0]![tIdx + 1]).toBe("6.000000");
expect(captured.args[0]![captured.args[0]!.indexOf("-c:a") + 1]).toBe("aac");
});

it("trims a video of N=120 frames at 30/1 fps with longer audio", async () => {
Expand All @@ -243,8 +186,8 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
expect(result.operation).toBe("trim");
expect(result.targetDurationSeconds).toBe(4);
expect(captured.args).toHaveLength(1);
const tIdx = captured.args[0]!.indexOf("-t");
expect(captured.args[0]![tIdx + 1]).toBe("4.000000");
const filterIdx = captured.args[0]!.indexOf("-af");
expect(captured.args[0]![filterIdx + 1]).toBe("atrim=duration=4.000000,asetpts=PTS-STARTPTS");
});

it("emits a copy when audio duration already equals frameCount/fps", async () => {
Expand All @@ -271,7 +214,7 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
expect(result.operation).toBe("pad");
expect(result.targetDurationSeconds).toBeCloseTo((120 * 1001) / 30000, 9);
const tIdx = captured.args[0]!.indexOf("-t");
expect(captured.args[0]![tIdx + 1]).toMatch(/^0\.004\d+$/);
expect(captured.args[0]![tIdx + 1]).toBe("4.004000");
});

it("propagates video probe failure as success=false", async () => {
Expand Down
Loading
Loading