Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { join } from "node:path";
import app from "../../app";
import * as jobManager from "../../lib/job-manager";
import { deleteJob, getJob } from "../../lib/job-manager";
import * as mediaProbe from "../../lib/media-probe";
import * as mediaVideo from "../../lib/media-video";
import { validateVideoInput } from "../../lib/video-input-validation";

Expand All @@ -31,8 +32,16 @@ describe("raw recording input validation", () => {
const cleanIvf = join(directory, "clean.ivf");
const cleanMp4 = join(directory, "clean.mp4");
const truncated = join(directory, "truncated.webm");
const damagedTruncated = join(directory, "damaged-truncated.webm");
const damagedStreamed = join(directory, "damaged-streamed.webm");
const audioOnly = join(directory, "audio-only.webm");
const damagedWithAudio = join(directory, "damaged-with-audio.webm");
const damagedVideoFullAudio = join(
directory,
"damaged-video-full-audio.webm",
);

beforeAll(() => {
const encodeVp9Ivf = (output: string, keyframeInterval: number) =>
execFileSync("ffmpeg", [
"-hide_banner",
"-v",
Expand All @@ -50,32 +59,83 @@ describe("raw recording input validation", () => {
"-threads",
"2",
"-g",
"60",
String(keyframeInterval),
"-lag-in-frames",
"0",
"-auto-alt-ref",
"0",
"-f",
"ivf",
cleanIvf,
output,
]);
const bytes = readFileSync(cleanIvf);
let offset = 32;
for (let frame = 0; frame < 45; frame++)
offset += 12 + bytes.readUInt32LE(offset);
const packetSize = bytes.readUInt32LE(offset);
const header = Buffer.from(bytes.subarray(offset, offset + 12));
header.writeUInt32LE(32);
const breakIvfPackets = (
source: string,
output: string,
brokenFrames: (frame: number) => boolean,
) => {
const bytes = readFileSync(source);
const chunks = [bytes.subarray(0, 32)];
for (let offset = 32, frame = 0; offset < bytes.length; frame++) {
const packetSize = bytes.readUInt32LE(offset);
if (brokenFrames(frame)) {
const header = Buffer.from(bytes.subarray(offset, offset + 12));
header.writeUInt32LE(32);
chunks.push(header, bytes.subarray(offset + 12, offset + 44));
} else {
chunks.push(bytes.subarray(offset, offset + 12 + packetSize));
}
offset += 12 + packetSize;
}
writeFileSync(output, Buffer.concat(chunks));
};

beforeAll(() => {
encodeVp9Ivf(cleanIvf, 60);
const brokenIvf = join(directory, "corrupt.ivf");
writeFileSync(
breakIvfPackets(cleanIvf, brokenIvf, (frame) => frame === 45);
execFileSync("ffmpeg", [
"-hide_banner",
"-v",
"error",
"-i",
brokenIvf,
Buffer.concat([
bytes.subarray(0, offset),
header,
bytes.subarray(offset + 12, offset + 44),
bytes.subarray(offset + 12 + packetSize),
]),
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=4",
"-c:v",
"copy",
"-c:a",
"libopus",
damagedWithAudio,
]);
const singleKeyframeIvf = join(directory, "single-keyframe.ivf");
const brokenSingleKeyframeIvf = join(
directory,
"single-keyframe-corrupt.ivf",
);
encodeVp9Ivf(singleKeyframeIvf, 300);
breakIvfPackets(
singleKeyframeIvf,
brokenSingleKeyframeIvf,
(frame) => frame >= 45,
);
execFileSync("ffmpeg", [
"-hide_banner",
"-v",
"error",
"-i",
brokenSingleKeyframeIvf,
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=4",
"-c:v",
"copy",
"-c:a",
"libopus",
damagedVideoFullAudio,
]);
for (const [source, output] of [
[cleanIvf, clean],
[brokenIvf, corrupt],
Expand All @@ -96,6 +156,42 @@ describe("raw recording input validation", () => {
truncated,
cleanBytes.subarray(0, Math.floor(cleanBytes.length / 2)),
);
writeFileSync(
damagedStreamed,
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-v",
"error",
"-i",
corrupt,
"-c",
"copy",
"-f",
"webm",
"pipe:1",
],
{ maxBuffer: 64 * 1024 * 1024 },
),
);
const corruptBytes = readFileSync(corrupt);
writeFileSync(
damagedTruncated,
corruptBytes.subarray(0, Math.floor(corruptBytes.length * 0.6)),
);
Comment on lines +178 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing-duration inputs remain untested

The new truncation test uses a seekable WebM produced by ffmpeg. Removing its tail leaves the original duration in the header. This misses inputs without a stored duration: probeMedia falls back to computeDuration, so the recovery check can use the shortened upload as its reference. Add a damaged, truncated WebM without stored duration and check that recovery does not silently accept the missing tail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/media-server/src/__tests__/lib/video-input-validation.integration.test.ts
Line: 101-105

Comment:
**Missing-duration inputs remain untested**

The new truncation test uses a seekable WebM produced by ffmpeg. Removing its tail leaves the original duration in the header. This misses inputs without a stored duration: `probeMedia` falls back to `computeDuration`, so the recovery check can use the shortened upload as its reference. Add a damaged, truncated WebM without stored duration and check that recovery does not silently accept the missing tail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

execFileSync("ffmpeg", [
"-hide_banner",
"-v",
"error",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=2",
"-c:a",
"libopus",
audioOnly,
]);
execFileSync("ffmpeg", [
"-v",
"error",
Expand All @@ -115,36 +211,98 @@ describe("raw recording input validation", () => {
afterEach(() => mock.restore());

test("accepts a clean browser-compatible VP9 stream", async () => {
await expect(validateVideoInput(clean)).resolves.toBeUndefined();
await expect(validateVideoInput(clean)).resolves.toBe("clean");
});

test("rejects a damaged reference packet before conversion can hide it", async () => {
await expect(validateVideoInput(corrupt)).rejects.toThrow(
"original upload has been preserved",
);
test("reports a damaged reference packet so processing can recover around it", async () => {
await expect(validateVideoInput(corrupt)).resolves.toBe("damaged");
expect(readFileSync(corrupt).length).toBeGreaterThan(0);
});

test("rejects a recording without a video stream", async () => {
await expect(validateVideoInput(audioOnly)).rejects.toThrow(
"The recording has no video",
);
});

for (const [
name,
source,
expectedPhase,
extension,
importedOriginal,
expectedError,
expectedProcessCalls,
] of [
["clean", clean, "complete", ".webm", false, null],
["damaged", corrupt, "error", ".webm", false, "damaged or unreadable"],
["imported-damaged", corrupt, "complete", ".webm", true, null],
["clean", clean, "complete", ".webm", false, null, 1],
["damaged", corrupt, "complete", ".webm", false, null, 1],
["damaged-streamed", damagedStreamed, "complete", ".webm", false, null, 1],
[
"damaged-with-audio",
damagedWithAudio,
"complete",
".webm",
false,
null,
1,
],
[
"damaged-short-recovered-audio",
damagedWithAudio,
"error",
".webm",
false,
"damaged or unreadable",
1,
],
[
"damaged-unreadable-source-tracks",
corrupt,
"complete",
".webm",
false,
null,
1,
],
[
"damaged-video-full-audio",
damagedVideoFullAudio,
"error",
".webm",
false,
"damaged or unreadable",
1,
],
[
"damaged-busy-output-probe",
corrupt,
"error",
".webm",
false,
"Server is busy",
1,
],
[
"damaged-truncated",
damagedTruncated,
"error",
".webm",
false,
"damaged or unreadable",
1,
],
["no-video", audioOnly, "error", ".webm", false, "has no video", 0],
["imported-damaged", corrupt, "complete", ".webm", true, null, 1],
[
"imported-truncated",
truncated,
"error",
".webm",
true,
"could not be fully recovered",
1,
],
["fragmented-mp4", cleanMp4, "complete", ".mp4", false, null],
["fragmented-mp4", cleanMp4, "complete", ".mp4", false, null, 1],
] as const) {
test(`processing route handles ${name} source before conversion`, async () => {
spyOn(jobManager, "canAcceptNewVideoProcess").mockReturnValue(true);
Expand All @@ -156,6 +314,35 @@ describe("raw recording input validation", () => {
cleanup: async () => rmSync(downloaded, { force: true }),
});
const process = spyOn(mediaVideo, "processVideo");
if (name === "damaged-short-recovered-audio") {
const spans = mediaProbe.probeTrackSpans;
spyOn(mediaProbe, "probeTrackSpans").mockImplementation(
async (path) => {
const measured = await spans(path);
return path.endsWith(".mp4")
? { ...measured, audio: (measured.audio ?? 0) / 2 }
: measured;
},
);
}
if (name === "damaged-unreadable-source-tracks") {
const spans = mediaProbe.probeTrackSpans;
spyOn(mediaProbe, "probeTrackSpans").mockImplementation((path) =>
path.endsWith(".mp4")
? spans(path)
: Promise.reject(new Error("Media input could not be read")),
);
}
if (name === "damaged-busy-output-probe") {
const probe = mediaProbe.probeVideoFile;
spyOn(mediaProbe, "probeVideoFile").mockImplementation((path) =>
path.endsWith(".mp4")
? Promise.reject(
new Error("Server is busy, please try again later"),
)
: probe(path),
);
}
const upload = spyOn(mediaVideo, "uploadFileToS3").mockResolvedValue({});
const secret =
globalThis.process.env.MEDIA_SERVER_WEBHOOK_SECRET ?? "test-secret";
Expand Down Expand Up @@ -192,12 +379,11 @@ describe("raw recording input validation", () => {
await Bun.sleep(10);
}
expect(getJob(jobId)?.phase).toBe(expectedPhase);
expect(process).toHaveBeenCalledTimes(expectedProcessCalls);
if (expectedError) {
expect(getJob(jobId)?.error).toContain(expectedError);
expect(process).toHaveBeenCalledTimes(importedOriginal ? 1 : 0);
expect(upload).not.toHaveBeenCalled();
} else {
expect(process).toHaveBeenCalledTimes(1);
expect(upload).toHaveBeenCalledTimes(1);
}
expect(readFileSync(source)).toEqual(original);
Expand Down
47 changes: 46 additions & 1 deletion apps/media-server/src/lib/media-probe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AudioCodec, MediaCodec, VideoCodec } from "mediabunny";
import type {
AudioCodec,
InputTrack,
MediaCodec,
VideoCodec,
} from "mediabunny";
import type { VideoMetadata } from "./job-manager";
import {
createMediaInput,
Expand Down Expand Up @@ -141,6 +146,46 @@ export async function probeVideoFile(filePath: string): Promise<VideoMetadata> {
);
}

export type TrackSpans = { video: number; audio: number | null };

async function computeTrackSpans(path: string): Promise<TrackSpans> {
const input = createMediaInput(path);
try {
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video stream found");
}
const audioTrack = await input.getPrimaryAudioTrack();
const span = async (track: InputTrack) => {
const [start, end] = await Promise.all([
track.getFirstTimestamp(),
track.computeDuration({ skipLiveWait: true }),
]);
return end - start;
};
const [video, audio] = await Promise.all([
span(videoTrack),
audioTrack ? span(audioTrack) : null,
]);
return { video, audio };
} finally {
input.dispose();
}
}

export async function probeTrackSpans(filePath: string): Promise<TrackSpans> {
if (!canAcceptNewProbeOperation()) {
throw new Error("Server is busy, please try again later");
}

return await withMediaOperation("probe", () =>
withTimeout(
computeTrackSpans(normalizeLocalPath(filePath)),
PROBE_TIMEOUT_MS,
),
);
}

export async function checkVideoAccessible(videoUrl: string): Promise<boolean> {
try {
const response = await probeFetch(videoUrl, {
Expand Down
Loading
Loading