From 6b2a1ec1325e5f1bd9629f6a9f9a2de560fde047 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:01:11 +0000 Subject: [PATCH] fix(media-server): re-encode H.264 uploads that are not 8-bit 4:2:0 --- .../lib/media-video.integration.test.ts | 67 +++++++++++++++++++ apps/media-server/src/lib/media-video.ts | 31 +++++++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/media-server/src/__tests__/lib/media-video.integration.test.ts b/apps/media-server/src/__tests__/lib/media-video.integration.test.ts index e3b8d3e7f73..e40fa56a2bf 100644 --- a/apps/media-server/src/__tests__/lib/media-video.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/media-video.integration.test.ts @@ -70,6 +70,23 @@ function readH264Level(filePath: string): number { return Number.parseInt(output, 10); } +function readVideoPixelFormat(filePath: string): string { + return execFileSync("ffprobe", [ + "-hide_banner", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=pix_fmt", + "-of", + "default=noprint_wrappers=1:nokey=1", + filePath, + ]) + .toString() + .trim(); +} + function readDecodedStreamHash(filePath: string, stream: "v" | "a") { return execFileSync("ffmpeg", [ "-hide_banner", @@ -1314,6 +1331,56 @@ describe("processVideo integration tests", () => { } }, 120000); + test("re-encodes h264 input that is not 8-bit 4:2:0", async () => { + const workDir = mkdtempSync(join(tmpdir(), "cap-h264-pixel-format-")); + try { + for (const source of [ + { pixelFormat: "yuv444p", level: "3.0" }, + { pixelFormat: "yuv420p10le", level: "3.0" }, + { pixelFormat: "yuv444p", level: "6.2" }, + ]) { + const sourcePath = join( + workDir, + `${source.pixelFormat}-${source.level}.mp4`, + ); + execFileSync("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + TEST_VIDEO_WITH_AUDIO, + "-c:v", + "libx264", + "-pix_fmt", + source.pixelFormat, + "-level:v", + source.level, + "-c:a", + "copy", + sourcePath, + ]); + const metadata = await probeVideo(`file://${sourcePath}`); + + const tempFile = await processVideo(sourcePath, metadata, { + maxWidth: metadata.width, + maxHeight: metadata.height, + normalizeH264Level: true, + }); + tempFiles.push(tempFile.path); + + expect(readVideoPixelFormat(tempFile.path)).toBe("yuv420p"); + const outputMetadata = await probeVideo(`file://${tempFile.path}`); + expect(outputMetadata.videoCodec).toBe("h264"); + expect(outputMetadata.audioCodec).toBe("aac"); + + await tempFile.cleanup(); + } + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }, 120000); + test("transcodes raw webm input into a valid mp4 output", async () => { const workDir = mkdtempSync(join(tmpdir(), "cap-webm-transcode-")); try { diff --git a/apps/media-server/src/lib/media-video.ts b/apps/media-server/src/lib/media-video.ts index 1baf6817d9a..68498a51710 100644 --- a/apps/media-server/src/lib/media-video.ts +++ b/apps/media-server/src/lib/media-video.ts @@ -1577,6 +1577,21 @@ async function probeH264Level( return Number.isFinite(level) && level > 0 ? level : null; } +// Safari, phones and Firefox on Windows play H.264 through hardware decoders that only take 8-bit 4:2:0. +const BROWSER_H264_PIXEL_FORMATS = new Set(["yuv420p", "yuvj420p"]); + +async function probeH264PixelFormat( + inputPath: string, + abortSignal?: AbortSignal, +): Promise { + const stdout = await probeVideoStreamEntries( + inputPath, + "stream=pix_fmt", + abortSignal, + ); + return stdout?.split(/\s+/)[0] || null; +} + async function probeVideoRotation( inputPath: string, abortSignal?: AbortSignal, @@ -1609,22 +1624,30 @@ export async function processVideo( const outputTempFile = await createTempFile(".mp4"); const remuxOnly = opts.remuxOnly; - const sourceH264Level = + const [sourceH264Level, sourcePixelFormat] = !remuxOnly && metadata.videoCodec === "h264" - ? await probeH264Level(inputPath, abortSignal) - : null; + ? await Promise.all([ + probeH264Level(inputPath, abortSignal), + probeH264PixelFormat(inputPath, abortSignal), + ]) + : [null, null]; abortSignal?.throwIfAborted(); + const browserPixelFormat = + sourcePixelFormat === null || + BROWSER_H264_PIXEL_FORMATS.has(sourcePixelFormat); const targetH264Level = pickMobileSafeH264Level(metadata, opts); const normalizeH264Level = opts.normalizeH264Level && metadata.videoCodec === "h264" && + browserPixelFormat && sourceH264Level !== null && sourceH264Level > targetH264Level.value && metadata.width <= opts.maxWidth && metadata.height <= opts.maxHeight; const videoTranscode = remuxOnly ? false - : needsVideoTranscode(metadata, opts, sourceH264Level); + : !browserPixelFormat || + needsVideoTranscode(metadata, opts, sourceH264Level); const audioTranscode = remuxOnly ? false : needsAudioTranscode(metadata); const extraInputArgs = resilientFlags ? buildExtraInputFlags(resilientFlags)