diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index 4fee83b5b5..80491e966a 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -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(); diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index 3350793ac8..709d89ac49 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -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( @@ -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 diff --git a/packages/producer/src/services/distributed/assemble.ts b/packages/producer/src/services/distributed/assemble.ts index 3b59b91b3e..5d19316a89 100644 --- a/packages/producer/src/services/distributed/assemble.ts +++ b/packages/producer/src/services/distributed/assemble.ts @@ -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, @@ -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, @@ -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) { diff --git a/packages/producer/src/services/render/audioPadTrim.integration.test.ts b/packages/producer/src/services/render/audioPadTrim.integration.test.ts new file mode 100644 index 0000000000..137dbdcd1c --- /dev/null +++ b/packages/producer/src/services/render/audioPadTrim.integration.test.ts @@ -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); + }); +}); diff --git a/packages/producer/src/services/render/audioPadTrim.test.ts b/packages/producer/src/services/render/audioPadTrim.test.ts index 14399f30d1..90fe862b46 100644 --- a/packages/producer/src/services/render/audioPadTrim.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.test.ts @@ -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", () => { @@ -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 @@ -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"); }); }); @@ -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 () => { @@ -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 () => { @@ -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 () => { diff --git a/packages/producer/src/services/render/audioPadTrim.ts b/packages/producer/src/services/render/audioPadTrim.ts index 2beb0bd419..6d56cec775 100644 --- a/packages/producer/src/services/render/audioPadTrim.ts +++ b/packages/producer/src/services/render/audioPadTrim.ts @@ -14,12 +14,13 @@ * "audio cuts off early" or "video shows a frozen final frame" bugs. * * The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at - * assemble time. Pad by concat-copying a generated silence tail, trim with - * `-t`, and avoid re-encoding the already mixed source AAC in either case. + * assemble time. Both branches decode/filter and re-encode AAC. Concatenating + * ADTS packets with `-c:a copy` is unsafe because concat timestamp estimation + * can stretch the terminal packet in the final MP4. */ import { spawn } from "node:child_process"; -import { rmSync, writeFileSync } from "node:fs"; +import { rmSync } from "node:fs"; import { extractAudioMetadata, formatFfmpegError, @@ -89,21 +90,11 @@ export interface PadTrimAudioResult { error?: string; } -export type PadTrimAudioStepKind = "copy" | "trim" | "pad-silence" | "pad-concat"; +export type PadTrimAudioStepKind = "copy" | "trim" | "normalize"; export interface PadTrimAudioStep { kind: PadTrimAudioStepKind; args: string[]; - /** - * Concat-demuxer script materialization. When both fields are set, the - * runner writes `concatListContent` to `concatListPath` synchronously - * before spawning ffmpeg, and the step's `args` reference that path via - * `-i concatListPath`. Feeding the concat script through a real file - * (instead of `pipe:0`) is what makes bare-path directives work on both - * Linux and Windows — see `concatFileLine` for the platform history. - */ - concatListPath?: string; - concatListContent?: string; } export interface PadTrimAudioPlan { @@ -121,8 +112,8 @@ export interface PadTrimAudioPlan { * tail, then concat-copy the source AAC plus that tail. This avoids * re-encoding the already mixed `audio.aac`; the pad branch remains the * inverse of trim instead of becoming a second full-source AAC encode. - * - `sourceDuration > targetDuration` → trim with `-t target`. `-c:a copy` - * is preserved when the input is already AAC. + * - `sourceDuration > targetDuration` → filter to the exact target and + * re-encode AAC so packet padding cannot outlast the video. * - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still * run ffmpeg with `-c:a copy` to materialize the output path. */ @@ -131,7 +122,6 @@ export function buildPadTrimAudioPlan( outputPath: string, sourceDurationSeconds: number, targetDurationSeconds: number, - audioInfo: Pick = {}, ): PadTrimAudioPlan { const delta = targetDurationSeconds - sourceDurationSeconds; const targetSec = formatSeconds(targetDurationSeconds); @@ -143,57 +133,57 @@ export function buildPadTrimAudioPlan( }; } if (delta > 0) { - const padDur = formatSeconds(delta); - const silencePath = `${outputPath}.pad-silence.aac`; - const concatListPath = `${outputPath}.concat-list.txt`; return { operation: "pad", steps: [ { - kind: "pad-silence", + kind: "normalize", args: [ - "-f", - "lavfi", "-i", - `anullsrc=channel_layout=${channelLayoutForChannels(audioInfo.channels)}:sample_rate=${sampleRateForFilter(audioInfo.sampleRate)}`, + audioPath, + "-af", + `apad=whole_dur=${targetSec}`, "-t", - padDur, + targetSec, "-c:a", "aac", "-b:a", "192k", "-y", - silencePath, - ], - }, - { - kind: "pad-concat", - args: [ - "-f", - "concat", - "-safe", - "0", - "-i", - concatListPath, - "-c:a", - "copy", - "-y", outputPath, ], - concatListPath, - concatListContent: `${concatFileLine(audioPath)}\n${concatFileLine(silencePath)}\n`, }, ], - cleanupPaths: [silencePath, concatListPath], + cleanupPaths: [], }; } - // Trim. `-t` truncates AAC without re-encoding because AAC frames are - // independently decodable; ffmpeg snaps the cut point to the nearest - // packet boundary, fine for the ±1ms tolerance we care about here. + // Packet-copy trimming snaps to AAC frame boundaries (typically 1024 + // samples), which can leave ~20ms beyond the target. Decode/filter/re-encode + // into M4A so ffmpeg records the exact presentation duration. return { operation: "trim", steps: [ - { kind: "trim", args: ["-i", audioPath, "-t", targetSec, "-c:a", "copy", "-y", outputPath] }, + { + kind: "trim", + args: [ + "-i", + audioPath, + "-af", + `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`, + // `atrim` limits decoded samples but does not cap muxer timestamps + // introduced by encoder delay/flush. The output duration contract + // is enforced at the container boundary as well; without `-t`, a + // long primed source can retain its original tail after re-encode. + "-t", + targetSec, + "-c:a", + "aac", + "-b:a", + "192k", + "-y", + outputPath, + ], + }, ], cleanupPaths: [], }; @@ -224,43 +214,6 @@ function formatSeconds(sec: number): string { return sec.toFixed(6); } -function sampleRateForFilter(sampleRate: number | undefined): number { - return sampleRate !== undefined && Number.isFinite(sampleRate) && sampleRate > 0 - ? Math.round(sampleRate) - : 48000; -} - -function channelLayoutForChannels(channels: number | undefined): string { - if (channels === 1) return "mono"; - if (channels === 6) return "5.1"; - if (channels === 8) return "7.1"; - return "stereo"; -} - -function concatFileLine(path: string): string { - // Bare paths in concat directives — NOT `file://` URLs. Two failure - // modes on the round trip to a working shape: - // 1. `file:///C:/…` — FFmpeg 8.x on Windows fails to open URL-form - // paths from the concat demuxer with "Impossible to open - // file:///C:/…" (its `file:` protocol strips the scheme leaving - // `///C:/…`, which Windows path parsing then rejects). Field- - // signal reports ts=1784169914 / 1784177061 / 1784177375 (all - // win32/x64 CLI 0.7.59; the last isolated the module's arg shape - // vs a working manual `apad=whole_dur` command). - // 2. Bare `/tmp/…` when the concat script was fed via `pipe:0` — - // FFmpeg's URL joiner resolves absolute POSIX paths against the - // base `pipe:` URL, producing `pipe:/tmp/…` which the demuxer - // then tries to open as a pipe. Broke Linux CI (regression shard - // + producer integration) once the `file://` prefix was dropped. - // Fix: emit bare paths AND materialize the concat script into a real - // file (see `concatListPath`/`concatListContent` on the pad-concat - // step), matching sibling `assemble.ts`'s concat convention. A real - // file's directory becomes the base URL, and absolute paths in the - // script resolve as-is on both platforms. The single-quote escaping - // (`'\''`) is the concat demuxer's own escape rule. - return `file '${path.replace(/'/g, "'\\''")}'`; -} - /** * Pad or trim `audio.aac` so its exact duration matches `frameCount / fps` * for the assembled video. @@ -323,7 +276,6 @@ export async function padOrTrimAudioToVideoFrameCount( input.outputPath, audioInfo.durationSeconds, targetDurationSeconds, - audioInfo, ); try { @@ -332,9 +284,6 @@ export async function padOrTrimAudioToVideoFrameCount( // needs one. Doing this here (instead of piping via `pipe:0` in the // runner) is what makes the demuxer's URL resolution treat // absolute paths as absolute — see `concatFileLine` for context. - if (step.concatListPath !== undefined && step.concatListContent !== undefined) { - writeFileSync(step.concatListPath, step.concatListContent, "utf-8"); - } const ffmpegResult = await runner(step.args); if (!ffmpegResult.success) { return { diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index e18da3be75..ae06962023 100644 --- a/packages/producer/src/services/render/stages/assembleStage.test.ts +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -49,7 +49,7 @@ describe("runAssembleStage audio duration parity", () => { muxVideoWithAudioMock.mockResolvedValue({ success: true }); padOrTrimAudioMock.mockResolvedValue({ success: true, - outputPath: "/tmp/audio.duration-normalized.aac", + outputPath: "/tmp/audio.duration-normalized.m4a", targetDurationSeconds: 1, sourceDurationSeconds: 1.024, operation: "trim", @@ -62,14 +62,14 @@ describe("runAssembleStage audio duration parity", () => { expect(padOrTrimAudioMock).toHaveBeenCalledWith({ videoPath: "/tmp/video-only.mp4", audioPath: "/tmp/audio.aac", - outputPath: "/tmp/audio.duration-normalized.aac", + outputPath: "/tmp/audio.duration-normalized.m4a", }); expect(muxVideoWithAudioMock).toHaveBeenCalledWith( "/tmp/video-only.mp4", - "/tmp/audio.duration-normalized.aac", + "/tmp/audio.duration-normalized.m4a", "/tmp/output.mp4", undefined, - { audioCodec: "aac" }, + { audioCodec: "aac", preserveAudioPrimingEditList: true }, { num: 30, den: 1 }, ); }); @@ -80,14 +80,14 @@ describe("runAssembleStage audio duration parity", () => { expect(padOrTrimAudioMock).toHaveBeenCalledWith({ videoPath: "/tmp/video-only.mp4", audioPath: "/tmp/audio.m4a", - outputPath: "/tmp/audio.duration-normalized.aac", + outputPath: "/tmp/audio.duration-normalized.m4a", }); }); it("fails instead of muxing an unnormalized AAC tail", async () => { padOrTrimAudioMock.mockResolvedValue({ success: false, - outputPath: "/tmp/audio.duration-normalized.aac", + outputPath: "/tmp/audio.duration-normalized.m4a", targetDurationSeconds: 1, sourceDurationSeconds: 1.024, operation: "trim", diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index 31eea26a5e..39dc431ca6 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -61,7 +61,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise