fix(goLive): keep IDR in own slot so decoder always has a reference (was blank)

The tail-drop rewrite let a P-frame supersede a pending keyframe before the
emit tick fired, so the decoder never received an IDR → blank GoLive tile.
Give keyframes their own slot (pendingKey) that P-frames cannot steal, and
only emit a P-frame once at least one IDR has been shown (haveReference).
IDR is always emitted first when present so the reference re-establishes.
This commit is contained in:
asepharyana
2026-08-13 17:43:16 +07:00
parent 8ee32b8df8
commit 7c376ea66a
+33 -21
View File
@@ -399,11 +399,15 @@ export async function demux(
opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30); opts.frameRate ?? (vInfo.framerate_num / vInfo.framerate_den || 30);
const emitIntervalMs = 1000 / videoFps; const emitIntervalMs = 1000 / videoFps;
// The single frame we will emit on the next tick. Only the newest survives; // The frames we will emit. Keyframes (IDR) get their OWN slot that P-frames
// keyframes are never superseded so the decoder always gets its IDRs. // can never supersede — a missing IDR means the decoder has no reference and
let latestAu: Buffer | null = null; // shows a BLANK tile. P-frames keep only the newest (tail-drop); older ones
let latestIsKey = false; // are skipped. A P-frame is only emitted once we have already shown at least
let skippedFrames = 0; // frames superseded before they could be shown // one keyframe (reference established), otherwise it is dropped.
let pendingKey: Buffer | null = null; // most recent IDR, not yet emitted
let latestP: Buffer | null = null; // newest P-frame, not yet emitted
let haveReference = false; // an IDR has been shown
let skippedFrames = 0; // P-frames superseded/useless before emit
const flushAccessUnit = () => { const flushAccessUnit = () => {
if (pendingNals.length === 0) return; if (pendingNals.length === 0) return;
@@ -417,28 +421,36 @@ export async function demux(
pendingNals = []; pendingNals = [];
pendingHasSlice = false; pendingHasSlice = false;
pendingIsKey = false; pendingIsKey = false;
// Tail-drop: keep only the newest frame. A keyframe always wins (never
// superseded by later non-keys in the same tick window) so the decoder
// keeps getting IDRs; a non-key only replaces a pending non-key.
if (isKey) { if (isKey) {
latestAu = au; // Keyframe: always kept in its own slot, never superseded by a later
latestIsKey = true; // P-frame (that was the previous bug → decoder got no IDR → blank).
} else if (latestAu === null || latestIsKey) { pendingKey = au;
latestAu = au;
latestIsKey = false;
} else { } else {
skippedFrames++; // P-frame: keep only the newest; drop the previous one.
if (latestP !== null) skippedFrames++;
latestP = au;
} }
}; };
// Steady real-time emission clock. Emits the freshest buffered frame once // Steady real-time emission clock. Emits the freshest frame once per tick
// per tick (≈ videoFps); never buffers more than one, so no backlog and // (≈ videoFps); never buffers more than one of each kind, so no backlog and
// no lag. This — not the encoder rate — defines playback speed. // 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 = () => { const emitTick = () => {
if (latestAu === null) return; let au: Buffer | null = null;
const au = latestAu; let isKey = false;
const isKey = latestIsKey; if (pendingKey !== null) {
latestAu = 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({ vPipe.write({
data: au, data: au,
// One frame at videoFps: duration=1 in a 1/fps timebase → // One frame at videoFps: duration=1 in a 1/fps timebase →