fix(gateway): output raw H264+Opus (not NUT) so Demuxer parses NAL keyframes correctly

Root cause: prepareStream muxed video+audio into a NUT container on pipe:1.
The Demuxer scans pipe:1 for AnnexB start codes (00 00 01) to split NAL
units into access units and classify keyframes (nal_type 5). NUT container
framing bytes sat in the stream and were scanned as NALs — NAL type 0
(NUT header) instead of 5 (IDR) → every frame classified key=false →
Discord decoder never got a decodable frame → static/black GoLive tile.

Fix: output raw H264 AnnexB on pipe:1 (demuxer target) and Ogg Opus on
fd3/pipe:3 for audio. NUT is only needed for *input* parsing (single
pipe carries both streams); output is demuxed into separate raw streams.
This commit is contained in:
asepharyana
2026-08-12 15:39:47 +07:00
parent 196cb1d3af
commit 392bc35a0d
@@ -28,8 +28,8 @@ export interface PrepareStreamResult {
height: number; height: number;
frameRate?: number; frameRate?: number;
includeAudio: boolean; includeAudio: boolean;
/** Container the encoder muxes to: "nut" (audio-capable) or "h264" (raw). */ /** Output container — always raw H264 AnnexB (audio demuxed to pipe:3). */
format: "nut" | "h264"; format: "h264";
} }
function isFiniteNonZero(n: unknown): n is number { function isFiniteNonZero(n: unknown): n is number {
@@ -183,10 +183,22 @@ export function prepareStream(
); );
} }
// Audio // Audio (transcode to libopus for encoding). Video output is ALWAYS raw
// H264 AnnexB on pipe:1 — NOT NUT. NUT container framing on stdout corrupts
// the AnnexB start-code scanner in Demuxer (NUT headers misread as NAL type 0
// → every frame classified non-keyframe → static tile). NUT is only needed
// for the *input* (one pipe carries both streams); output demuxes each stream
// to its own raw format.
// Video output: raw H264 AnnexB on pipe:1 — demux() scans start codes.
args.push("-f", "h264", "pipe:1");
// Audio output: Ogg Opus on fd3 (pipe:3), separate from video stdout so it
// never pollutes the AnnexB start-code stream on pipe:1. Map the audio
// stream here (after the video -map 0:v:0 above) so it targets only this
// output.
if (mergedOptions.includeAudio) { if (mergedOptions.includeAudio) {
args.push("-map", "0:a:0?");
args.push( args.push(
"-map",
"0:a:0?",
"-c:a", "-c:a",
"libopus", "libopus",
"-b:a", "-b:a",
@@ -195,18 +207,14 @@ export function prepareStream(
"48000", "48000",
"-ac", "-ac",
"2", "2",
"-f",
"opus",
"pipe:3",
); );
} else { } else {
args.push("-an"); args.push("-an");
} }
args.push(...mergedOptions.customFfmpegFlags);
// NUT muxer carries video+audio; the raw h264 muxer cannot ("h264 muxer
// does not support any stream of type audio" → header write fails →
// empty stdout → black tile). Audio delivery requires NUT.
const outFormat = mergedOptions.includeAudio ? "nut" : "h264";
args.push("-f", outFormat, "pipe:1");
const isUrl = typeof input === "string"; const isUrl = typeof input === "string";
const proc: ChildProcess = isUrl const proc: ChildProcess = isUrl
? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] }) ? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] })
@@ -255,7 +263,7 @@ export function prepareStream(
height: mergedOptions.height, height: mergedOptions.height,
frameRate: mergedOptions.frameRate, frameRate: mergedOptions.frameRate,
includeAudio: !!mergedOptions.includeAudio, includeAudio: !!mergedOptions.includeAudio,
format: outFormat, format: "h264",
}; };
} }