From 392bc35a0d34cd35bf48468b115c2bb613ba28eb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 12 Aug 2026 15:37:05 +0700 Subject: [PATCH] fix(gateway): output raw H264+Opus (not NUT) so Demuxer parses NAL keyframes correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/goLive/prepareStream.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/services/discord-gateway/src/goLive/prepareStream.ts b/services/discord-gateway/src/goLive/prepareStream.ts index 4b82bea..56e561b 100644 --- a/services/discord-gateway/src/goLive/prepareStream.ts +++ b/services/discord-gateway/src/goLive/prepareStream.ts @@ -28,8 +28,8 @@ export interface PrepareStreamResult { height: number; frameRate?: number; includeAudio: boolean; - /** Container the encoder muxes to: "nut" (audio-capable) or "h264" (raw). */ - format: "nut" | "h264"; + /** Output container — always raw H264 AnnexB (audio demuxed to pipe:3). */ + format: "h264"; } 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) { - args.push("-map", "0:a:0?"); args.push( + "-map", + "0:a:0?", "-c:a", "libopus", "-b:a", @@ -195,18 +207,14 @@ export function prepareStream( "48000", "-ac", "2", + "-f", + "opus", + "pipe:3", ); } else { 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 proc: ChildProcess = isUrl ? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] }) @@ -255,7 +263,7 @@ export function prepareStream( height: mergedOptions.height, frameRate: mergedOptions.frameRate, includeAudio: !!mergedOptions.includeAudio, - format: outFormat, + format: "h264", }; }