Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 27 additions & 4 deletions apps/media-server/src/lib/media-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
const stdout = await probeVideoStreamEntries(
inputPath,
"stream=pix_fmt",
abortSignal,
);
return stdout?.split(/\s+/)[0] || null;
}

async function probeVideoRotation(
inputPath: string,
abortSignal?: AbortSignal,
Expand Down Expand Up @@ -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)
Expand Down
Loading