mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(render): trim AAC packet padding exactly
This commit is contained in:
@@ -291,7 +291,7 @@ export async function assemble(
|
||||
// ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
|
||||
let audioForMux: string | 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,
|
||||
|
||||
@@ -81,15 +81,20 @@ describe("buildPadTrimAudioArgs", () => {
|
||||
expect(args[args.indexOf("-t") + 1]).toBe("1.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");
|
||||
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", () => {
|
||||
@@ -243,8 +248,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 () => {
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* "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. Pad by concat-copying a generated silence tail. For trim,
|
||||
* decode and filter to the exact target before re-encoding into an M4A
|
||||
* container; packet-copying AAC can only cut on packet boundaries.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
@@ -121,8 +122,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.
|
||||
*/
|
||||
@@ -187,13 +188,27 @@ export function buildPadTrimAudioPlan(
|
||||
cleanupPaths: [silencePath, concatListPath],
|
||||
};
|
||||
}
|
||||
// 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`,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-y",
|
||||
outputPath,
|
||||
],
|
||||
},
|
||||
],
|
||||
cleanupPaths: [],
|
||||
};
|
||||
|
||||
@@ -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,11 +62,11 @@ 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" },
|
||||
@@ -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",
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
|
||||
const audioStem = audioExtension
|
||||
? audioOutputPath.slice(0, -audioExtension.length)
|
||||
: audioOutputPath;
|
||||
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`;
|
||||
const normalizedAudioPath = `${audioStem}.duration-normalized.m4a`;
|
||||
const normalizeResult = await padOrTrimAudioToVideoFrameCount({
|
||||
videoPath: videoOnlyPath,
|
||||
audioPath: audioOutputPath,
|
||||
|
||||
Reference in New Issue
Block a user