Skip to content

Commit 5d7d887

Browse files
committed
fix: preserve stored audio tail duration during recording remux
1 parent 2e601a2 commit 5d7d887

3 files changed

Lines changed: 55 additions & 5 deletions

File tree

apps/media-server/src/__tests__/lib/recording-verification.integration.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,18 @@ afterAll(async () => {
489489
});
490490

491491
describe("encoded recording preservation", () => {
492-
test("verifies looped AAC through decoded evidence when edit-list rounding changes the tail", async () => {
492+
test("uses decoded source evidence for tied terminal video samples", async () => {
493+
const input = await tiedTimestampSource("packet-tied-terminal.mp4", 2);
494+
const output = join(directory, "packet-tied-terminal-output.mp4");
495+
await muxMediaTracksToMp4(input, silent, output);
496+
const verified = await verifyRemuxedRecording(input, silent, output, {
497+
requireAudio: true,
498+
});
499+
expect(verified.sourcePreserved).toBe(true);
500+
expect(verified.integrity).toBeDefined();
501+
expect(verified.video.frameCount).toBe(40);
502+
});
503+
test("preserves the stored looped AAC tail for one complete output decode", async () => {
493504
const input = join(directory, "looped-audio.mp4");
494505
const output = join(directory, "looped-audio-remux.mp4");
495506
await run([
@@ -510,6 +521,7 @@ describe("encoded recording preservation", () => {
510521
});
511522
expect(verified.fullDecode).toBe(true);
512523
expect(verified.sourcePreserved).toBe(true);
524+
expect(verified.integrity).toBeUndefined();
513525
const source = await inspectRecordingSources(input, input);
514526
expect(verified.audio).toEqual(source.audio);
515527
expect(verified.video).toEqual(source.video);
@@ -593,7 +605,7 @@ describe("encoded recording preservation", () => {
593605
}),
594606
).rejects.toThrow();
595607
});
596-
test("retains the strict fallback for packet transformations", async () => {
608+
test("preserves a shorter audio track independently of the video", async () => {
597609
const output = join(directory, "proof-short-audio.mp4");
598610
await muxMediaTracksToMp4(silent, shortAudio, output);
599611
const verified = await verifyRemuxedRecording(silent, shortAudio, output, {
@@ -803,8 +815,10 @@ describe("complete recording decode", () => {
803815
);
804816
expect(source.video).toEqual(stock.video);
805817
expect(source.audio).toEqual(stock.audio);
818+
const output = join(directory, "long-recording-remux.mp4");
819+
await muxMediaTracksToMp4(input, input, output);
806820
const efficientStarted = performance.now();
807-
const efficient = await verifyRemuxedRecording(input, input, input, {
821+
const efficient = await verifyRemuxedRecording(input, input, output, {
808822
requireAudio: true,
809823
timeoutMs: 30_000,
810824
});

apps/media-server/src/lib/media-video.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "./media-common";
1515
import { probeVideoFile } from "./media-probe";
1616
import { fetchMedia, materializeMedia } from "./media-transfer";
17+
import { readRecordingAudioTail } from "./recording-packet-proof";
1718
import {
1819
RecordingTimingError,
1920
readRecordingVideoTiming,
@@ -2474,6 +2475,10 @@ export async function muxMediaTracksToMp4(
24742475
abortSignal?: AbortSignal,
24752476
): Promise<void> {
24762477
if (abortSignal?.aborted) throw new Error("Recording mux was cancelled");
2478+
abortSignal = AbortSignal.any([
2479+
...(abortSignal ? [abortSignal] : []),
2480+
AbortSignal.timeout(PROCESS_TIMEOUT_MS),
2481+
]);
24772482
const startedAt = performance.now();
24782483
const timing = await readRecordingVideoTiming(videoInputPath, {
24792484
abortSignal,
@@ -2483,6 +2488,15 @@ export async function muxMediaTracksToMp4(
24832488
const lastTimestamp = timing.lastTimestampTicks - timing.firstTimestampTicks;
24842489
// FFmpeg 7 can discard a fragmented MP4's stored final sample duration.
24852490
const videoTimingFilter = `setts=pts=PTS:dts=DTS:duration=if(eq(PTS-STARTPTS\\,${lastTimestamp})\\,${timing.lastDurationTicks}\\,DURATION)`;
2491+
const audioTiming = audioInputPath
2492+
? await readRecordingAudioTail(audioInputPath, abortSignal, true)
2493+
: undefined;
2494+
if (audioTiming && audioTiming.packetCount === undefined)
2495+
throw new Error("Recording audio packet count is missing");
2496+
// FFmpeg synthesizes nominal AAC durations, so bind the final duration to the stored source sample.
2497+
const audioTimingFilter = audioTiming?.packetCount
2498+
? `setts=duration=if(eq(N\\,${audioTiming.packetCount - 1})\\,${audioTiming.durationTicks}\\,DURATION)`
2499+
: undefined;
24862500
const args = audioInputPath
24872501
? [
24882502
"ffmpeg",
@@ -2501,6 +2515,7 @@ export async function muxMediaTracksToMp4(
25012515
"copy",
25022516
"-bsf:v",
25032517
videoTimingFilter,
2518+
...(audioTimingFilter ? ["-bsf:a", audioTimingFilter] : []),
25042519
"-avoid_negative_ts",
25052520
"disabled",
25062521
"-movie_timescale",

apps/media-server/src/lib/recording-packet-proof.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ function time(value: string | undefined, base: string) {
116116
return `${numerator / a}/${BigInt(match[2]) / a}`;
117117
}
118118

119-
async function readAudioTail(path: string, signal: AbortSignal) {
119+
export async function readRecordingAudioTail(
120+
path: string,
121+
signal: AbortSignal,
122+
countPackets = false,
123+
) {
120124
const input = new Input({ formats: [MP4], source: new FilePathSource(path) });
121125
const dispose = () => input.dispose();
122126
signal.addEventListener("abort", dispose, { once: true });
@@ -155,8 +159,24 @@ async function readAudioTail(path: string, signal: AbortSignal) {
155159
packet.sequenceNumber < 0
156160
)
157161
throw new Error("Recording audio duration is not exact");
162+
const packetCount = countPackets
163+
? (
164+
await tracks[0].computePacketStats(Number.POSITIVE_INFINITY, {
165+
metadataOnly: true,
166+
skipLiveWait: true,
167+
})
168+
).packetCount
169+
: undefined;
170+
if (
171+
packetCount !== undefined &&
172+
(!Number.isSafeInteger(packetCount) || packetCount <= 0)
173+
)
174+
throw new Error("Recording audio packet count is invalid");
158175
signal.throwIfAborted();
159176
return {
177+
packetCount,
178+
durationTicks: ticks,
179+
timeScale: scale,
160180
size: packet.byteLength,
161181
duration: time(String(ticks), `1/${scale}`),
162182
hash: `SHA256:${createHash("sha256").update(packet.data).digest("hex")}`,
@@ -221,7 +241,8 @@ async function readTrack(
221241
const startTime = startNumerator / startDenominator;
222242
if (!Number.isFinite(startTime))
223243
throw new Error("Recording start time is invalid");
224-
const tail = kind === "audio" ? await readAudioTail(path, signal) : undefined;
244+
const tail =
245+
kind === "audio" ? await readRecordingAudioTail(path, signal) : undefined;
225246
const hash = createHash("sha256");
226247
let count = 0;
227248
let pendingPacket: Record<string, string> | undefined;

0 commit comments

Comments
 (0)