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 ────────────────────────────────────
|
// ── 3. Audio: pad-or-trim then mux ────────────────────────────────────
|
||||||
let audioForMux: string | null = null;
|
let audioForMux: string | null = null;
|
||||||
if (audioPath !== null && existsSync(audioPath)) {
|
if (audioPath !== null && existsSync(audioPath)) {
|
||||||
const paddedAudioPath = join(workDir, "audio-padded.aac");
|
const paddedAudioPath = join(workDir, "audio-padded.m4a");
|
||||||
const padTrimResult = await padOrTrimAudioToVideoFrameCount({
|
const padTrimResult = await padOrTrimAudioToVideoFrameCount({
|
||||||
videoPath: postConcatPath,
|
videoPath: postConcatPath,
|
||||||
audioPath,
|
audioPath,
|
||||||
|
|||||||
@@ -81,15 +81,20 @@ describe("buildPadTrimAudioArgs", () => {
|
|||||||
expect(args[args.indexOf("-t") + 1]).toBe("1.000000");
|
expect(args[args.indexOf("-t") + 1]).toBe("1.000000");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits -t when audio is longer than target", () => {
|
it("filter-trims and re-encodes AAC packet padding beyond the target", () => {
|
||||||
const { args, operation } = buildPadTrimAudioArgs("/tmp/in.aac", "/tmp/out.aac", 6.123, 5.0);
|
const { args, operation } = buildPadTrimAudioArgs(
|
||||||
|
"/tmp/in.aac",
|
||||||
|
"/tmp/out.m4a",
|
||||||
|
15.018667,
|
||||||
|
15.0,
|
||||||
|
);
|
||||||
expect(operation).toBe("trim");
|
expect(operation).toBe("trim");
|
||||||
const tIdx = args.indexOf("-t");
|
const filterIdx = args.indexOf("-af");
|
||||||
expect(tIdx).toBeGreaterThan(-1);
|
expect(args[filterIdx + 1]).toBe("atrim=duration=15.000000,asetpts=PTS-STARTPTS");
|
||||||
expect(args[tIdx + 1]).toBe("5.000000");
|
|
||||||
// Trim preserves AAC stream copy.
|
|
||||||
const codecIdx = args.indexOf("-c:a");
|
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", () => {
|
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.operation).toBe("trim");
|
||||||
expect(result.targetDurationSeconds).toBe(4);
|
expect(result.targetDurationSeconds).toBe(4);
|
||||||
expect(captured.args).toHaveLength(1);
|
expect(captured.args).toHaveLength(1);
|
||||||
const tIdx = captured.args[0]!.indexOf("-t");
|
const filterIdx = captured.args[0]!.indexOf("-af");
|
||||||
expect(captured.args[0]![tIdx + 1]).toBe("4.000000");
|
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 () => {
|
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.
|
* "audio cuts off early" or "video shows a frozen final frame" bugs.
|
||||||
*
|
*
|
||||||
* The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at
|
* The fix: post-pad/trim audio to *exactly* `frameCount / fps` seconds at
|
||||||
* assemble time. Pad by concat-copying a generated silence tail, trim with
|
* assemble time. Pad by concat-copying a generated silence tail. For trim,
|
||||||
* `-t`, and avoid re-encoding the already mixed source AAC in either case.
|
* 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";
|
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
|
* tail, then concat-copy the source AAC plus that tail. This avoids
|
||||||
* re-encoding the already mixed `audio.aac`; the pad branch remains the
|
* re-encoding the already mixed `audio.aac`; the pad branch remains the
|
||||||
* inverse of trim instead of becoming a second full-source AAC encode.
|
* inverse of trim instead of becoming a second full-source AAC encode.
|
||||||
* - `sourceDuration > targetDuration` → trim with `-t target`. `-c:a copy`
|
* - `sourceDuration > targetDuration` → filter to the exact target and
|
||||||
* is preserved when the input is already AAC.
|
* re-encode AAC so packet padding cannot outlast the video.
|
||||||
* - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still
|
* - `|Δ| < AUDIO_DURATION_TOLERANCE_SECONDS` → no-op `copy`, but we still
|
||||||
* run ffmpeg with `-c:a copy` to materialize the output path.
|
* run ffmpeg with `-c:a copy` to materialize the output path.
|
||||||
*/
|
*/
|
||||||
@@ -187,13 +188,27 @@ export function buildPadTrimAudioPlan(
|
|||||||
cleanupPaths: [silencePath, concatListPath],
|
cleanupPaths: [silencePath, concatListPath],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Trim. `-t` truncates AAC without re-encoding because AAC frames are
|
// Packet-copy trimming snaps to AAC frame boundaries (typically 1024
|
||||||
// independently decodable; ffmpeg snaps the cut point to the nearest
|
// samples), which can leave ~20ms beyond the target. Decode/filter/re-encode
|
||||||
// packet boundary, fine for the ±1ms tolerance we care about here.
|
// into M4A so ffmpeg records the exact presentation duration.
|
||||||
return {
|
return {
|
||||||
operation: "trim",
|
operation: "trim",
|
||||||
steps: [
|
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: [],
|
cleanupPaths: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("runAssembleStage audio duration parity", () => {
|
|||||||
muxVideoWithAudioMock.mockResolvedValue({ success: true });
|
muxVideoWithAudioMock.mockResolvedValue({ success: true });
|
||||||
padOrTrimAudioMock.mockResolvedValue({
|
padOrTrimAudioMock.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
outputPath: "/tmp/audio.duration-normalized.m4a",
|
||||||
targetDurationSeconds: 1,
|
targetDurationSeconds: 1,
|
||||||
sourceDurationSeconds: 1.024,
|
sourceDurationSeconds: 1.024,
|
||||||
operation: "trim",
|
operation: "trim",
|
||||||
@@ -62,11 +62,11 @@ describe("runAssembleStage audio duration parity", () => {
|
|||||||
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
||||||
videoPath: "/tmp/video-only.mp4",
|
videoPath: "/tmp/video-only.mp4",
|
||||||
audioPath: "/tmp/audio.aac",
|
audioPath: "/tmp/audio.aac",
|
||||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
outputPath: "/tmp/audio.duration-normalized.m4a",
|
||||||
});
|
});
|
||||||
expect(muxVideoWithAudioMock).toHaveBeenCalledWith(
|
expect(muxVideoWithAudioMock).toHaveBeenCalledWith(
|
||||||
"/tmp/video-only.mp4",
|
"/tmp/video-only.mp4",
|
||||||
"/tmp/audio.duration-normalized.aac",
|
"/tmp/audio.duration-normalized.m4a",
|
||||||
"/tmp/output.mp4",
|
"/tmp/output.mp4",
|
||||||
undefined,
|
undefined,
|
||||||
{ audioCodec: "aac" },
|
{ audioCodec: "aac" },
|
||||||
@@ -80,14 +80,14 @@ describe("runAssembleStage audio duration parity", () => {
|
|||||||
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
expect(padOrTrimAudioMock).toHaveBeenCalledWith({
|
||||||
videoPath: "/tmp/video-only.mp4",
|
videoPath: "/tmp/video-only.mp4",
|
||||||
audioPath: "/tmp/audio.m4a",
|
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 () => {
|
it("fails instead of muxing an unnormalized AAC tail", async () => {
|
||||||
padOrTrimAudioMock.mockResolvedValue({
|
padOrTrimAudioMock.mockResolvedValue({
|
||||||
success: false,
|
success: false,
|
||||||
outputPath: "/tmp/audio.duration-normalized.aac",
|
outputPath: "/tmp/audio.duration-normalized.m4a",
|
||||||
targetDurationSeconds: 1,
|
targetDurationSeconds: 1,
|
||||||
sourceDurationSeconds: 1.024,
|
sourceDurationSeconds: 1.024,
|
||||||
operation: "trim",
|
operation: "trim",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export async function runAssembleStage(input: AssembleStageInput): Promise<Assem
|
|||||||
const audioStem = audioExtension
|
const audioStem = audioExtension
|
||||||
? audioOutputPath.slice(0, -audioExtension.length)
|
? audioOutputPath.slice(0, -audioExtension.length)
|
||||||
: audioOutputPath;
|
: audioOutputPath;
|
||||||
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`;
|
const normalizedAudioPath = `${audioStem}.duration-normalized.m4a`;
|
||||||
const normalizeResult = await padOrTrimAudioToVideoFrameCount({
|
const normalizeResult = await padOrTrimAudioToVideoFrameCount({
|
||||||
videoPath: videoOnlyPath,
|
videoPath: videoOnlyPath,
|
||||||
audioPath: audioOutputPath,
|
audioPath: audioOutputPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user