refactor(goLive): revert to dank-faithful demuxer — no custom pacing clock

Per user direction ('pakai dank sebagai referensi karena itu yg berhasil'):
drop the custom setInterval/tail-drop emission clock entirely. The demuxer
now writes each access unit straight to vPipe with a monotonic PTS and lets
BaseMediaStream (ported 1:1 from @dank074) handle pacing via sleep-PTS + A/V
sync, exactly like the upstream library. The custom clocks were the source of
the blank tile (IDR delivery race) and the lag (head-drop watching 10s-old
frames).

Adds proper backpressure: pause ffmpeg stdout when vPipe.write() returns
false, resume on drain — mirrors dank's 'resume &&= vPipe.write(packet)' so the
encoder self-throttles to the WebRTC sender's real pace instead of bursting.
This commit is contained in:
asepharyana
2026-08-13 18:12:34 +07:00
parent 7c376ea66a
commit 6e188f81d6
+36 -64
View File
@@ -375,18 +375,12 @@ export async function demux(
// NALs and flush one frame per slice, prepending the parameter sets that // NALs and flush one frame per slice, prepending the parameter sets that
// precede it, and timestamp it as ONE frame at the video frame rate. // precede it, and timestamp it as ONE frame at the video frame rate.
// EMISSION CLOCK: a steady setInterval at the video frame period is the // EMISSION: faithful to @dank074/discord-video-stream — the demuxer does NOT
// authoritative real-time clock for OUTPUT. Whatever the encoder's actual // pace. It writes each access unit straight to vPipe (objectMode, HWM 128)
// production rate (it bursts ~330fps in production because ffmpeg `-re` // with a monotonically increasing PTS; BaseMediaStream (ported 1:1 from dank)
// does not reliably throttle YouTube-DASH webm and may read a local file // then paces playback via sleep-PTS + A/V sync and applies backpressure when
// instantly), we always emit exactly ONE frame per tick — the NEWEST one // the WebRTC sender can't keep up. This is what works upstream; the custom
// we have buffered — and discard everything older. This is TAIL-DROP: the // setInterval/tail-drop clocks we tried broke IDR delivery → blank tiles.
// viewer always sees the freshest picture, so video and audio stay in sync
// and the in-flight buffer can never grow (we keep at most one frame). The
// previous token-bucket design used HEAD-DROP (emit in arrival order,
// dropping later frames) which, under the encoder burst, left the viewer
// watching frames ~10s behind live → frozen / "patah-patah" video while
// audio (not rate-limited) played current → desync. Tail-drop fixes that.
let videoBuf = Buffer.alloc(0); let videoBuf = Buffer.alloc(0);
let frameCount = 0; let frameCount = 0;
let pendingNals: Buffer[] = []; let pendingNals: Buffer[] = [];
@@ -397,17 +391,23 @@ export async function demux(
// setting); it drives both RTP timestamp advance and pacing. // setting); it drives both RTP timestamp advance and pacing.
const videoFps = const videoFps =
opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30); opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30);
const emitIntervalMs = 1000 / videoFps;
// The frames we will emit. Keyframes (IDR) get their OWN slot that P-frames // Write one frame to the video pipe with backpressure: if the pipe buffer is
// can never supersede — a missing IDR means the decoder has no reference and // full, pause ffmpeg's stdout so the encoder self-throttles (instead of
// shows a BLANK tile. P-frames keep only the newest (tail-drop); older ones // building an unbounded backlog). Resumed on drain. Faithful to dank's
// are skipped. A P-frame is only emitted once we have already shown at least // `resume &&= vPipe.write(packet)` in LibavDemuxer.
// one keyframe (reference established), otherwise it is dropped. const writeFrame = (frame: {
let pendingKey: Buffer | null = null; // most recent IDR, not yet emitted data: Buffer;
let latestP: Buffer | null = null; // newest P-frame, not yet emitted pts: number;
let haveReference = false; // an IDR has been shown duration: number;
let skippedFrames = 0; // P-frames superseded/useless before emit timeBase: { num: number; den: number };
flags: number;
streamIndex: number;
free: () => void;
}): void => {
const ok = vPipe.write(frame);
if (!ok) proc.stdout?.pause();
};
const flushAccessUnit = () => { const flushAccessUnit = () => {
if (pendingNals.length === 0) return; if (pendingNals.length === 0) return;
@@ -421,42 +421,14 @@ export async function demux(
pendingNals = []; pendingNals = [];
pendingHasSlice = false; pendingHasSlice = false;
pendingIsKey = false; pendingIsKey = false;
if (isKey) { // Write directly to the video pipe (with backpressure via writeFrame).
// Keyframe: always kept in its own slot, never superseded by a later // PTS advances one frame per output frame at videoFps (duration=1 in a
// P-frame (that was the previous bug → decoder got no IDR → blank). // 1/fps timebase) so BaseMediaStream computes the correct frametime and the
pendingKey = au; // WebRTC RTP timestamp advances by clockRate/fps per frame (3000 @ 30fps /
} else { // 90kHz). Keyframes carry AV_PKT_FLAG_KEY so the decoder re-establishes a
// P-frame: keep only the newest; drop the previous one. // reference.
if (latestP !== null) skippedFrames++; writeFrame({
latestP = au;
}
};
// Steady real-time emission clock. Emits the freshest frame once per tick
// (≈ videoFps); never buffers more than one of each kind, so no backlog and
// no lag. This — not the encoder rate — defines playback speed. Order: an
// IDR is always emitted first when present so the decoder re-establishes a
// reference; otherwise emit the newest P-frame once a reference exists.
const emitTick = () => {
let au: Buffer | null = null;
let isKey = false;
if (pendingKey !== null) {
au = pendingKey;
isKey = true;
pendingKey = null;
haveReference = true;
} else if (latestP !== null && haveReference) {
au = latestP;
isKey = false;
latestP = null;
}
if (au === null) return;
vPipe.write({
data: au, data: au,
// One frame at videoFps: duration=1 in a 1/fps timebase →
// BaseMediaStream computes frametime=1000/fps ms → the RTP timestamp
// advances clockRate/fps per frame (3000 @ 30fps / 90kHz), which is
// what Discord's receiver expects for real-time video.
pts: frameCount, pts: frameCount,
duration: 1, duration: 1,
timeBase: { num: 1, den: videoFps }, timeBase: { num: 1, den: videoFps },
@@ -467,14 +439,13 @@ export async function demux(
frameCount++; frameCount++;
if (frameCount === 1 || frameCount % 30 === 0) { if (frameCount === 1 || frameCount % 30 === 0) {
console.log( console.log(
`[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey} skipped=${skippedFrames}`, `[goLive:Demuxer] frames=${frameCount} last=${au.length}B key=${isKey}`,
); );
} }
}; };
const emitTimer = setInterval(emitTick, emitIntervalMs);
if (proc.stdout) { if (proc.stdout) {
vPipe.on("drain", () => proc.stdout?.resume());
proc.stdout.on("data", (chunk: Buffer) => { proc.stdout.on("data", (chunk: Buffer) => {
videoBuf = Buffer.concat([videoBuf, chunk]); videoBuf = Buffer.concat([videoBuf, chunk]);
// Find start codes (00 00 01 or 00 00 00 01) and split NALs // Find start codes (00 00 01 or 00 00 00 01) and split NALs
@@ -549,23 +520,24 @@ export async function demux(
videoBuf = videoBuf.subarray(videoBuf.length - 3); videoBuf = videoBuf.subarray(videoBuf.length - 3);
} }
}); });
// Backpressure (faithful to dank's `resume &&= vPipe.write`): when the
// downstream pipe's buffer is full, stop reading from ffmpeg's stdout so
// the encoder self-throttles instead of building an unbounded backlog.
// Resume on drain.
vPipe.on("drain", () => proc.stdout?.resume());
proc.stdout.on("end", () => { proc.stdout.on("end", () => {
flushAccessUnit(); flushAccessUnit();
emitTick(); // flush the final frame if any
clearInterval(emitTimer);
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();
}); });
} }
proc.on("close", () => { proc.on("close", () => {
clearInterval(emitTimer);
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();
}); });
const close = () => { const close = () => {
clearInterval(emitTimer);
proc.kill("SIGTERM"); proc.kill("SIGTERM");
vPipe.end(); vPipe.end();
aPipe.end(); aPipe.end();