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:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user