fix(goLive): backpressure-throttle screen-share pipeline to 1x (video freezes while audio plays)
Root cause: prepareStream's ffmpeg consumed a YouTube VOD at download/CPU speed (~10x real-time), so the demuxer buffered a huge frame backlog. The sender paces at 30fps but always emitted the OLDEST buffered frames, so the viewer saw frozen/laggy video while audio (tiny, jitter-buffer recoverable) stayed smooth. That is exactly the 'video stuck, voice normal' symptom reported live. Fix: propagate vPipe backpressure UP to the demuxer's ffmpeg stdout — when the sender can't keep up, pause the source, which stalls the demuxer and back-pressures the encoder, pinning the whole pipeline to 1x. Also add a realtime (-re) option for file/URL inputs (no-op for the streaming path, which is what screen share uses). Verified: 10s test clip encodes in 1.8s without -re vs 9.5s with it; tsc --noEmit clean.
This commit is contained in:
@@ -365,6 +365,32 @@ export async function demux(
|
|||||||
// frames, each with a near-zero RTP timestamp delta). We therefore buffer
|
// frames, each with a near-zero RTP timestamp delta). We therefore buffer
|
||||||
// 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.
|
||||||
|
|
||||||
|
// BACKPRESSURE: the encoder (prepareStream ffmpeg) produces frames at the
|
||||||
|
// download/CPU rate, which for a fast VOD is ~10x real-time. The sender
|
||||||
|
// (BaseMediaStream) paces at 30fps. Without a gate, the demuxer buffers an
|
||||||
|
// unbounded backlog and the sender always emits the OLDEST frames → the
|
||||||
|
// viewer sees frozen/laggy video while audio (tiny, jitter-buffer
|
||||||
|
// recoverable) stays smooth. That is the "video stuck, voice normal"
|
||||||
|
// symptom. So we propagate vPipe backpressure UP to the demuxer's ffmpeg
|
||||||
|
// stdout: when vPipe is full we pause it, which stalls the demuxer, which
|
||||||
|
// stalls its stdin, which back-pressures the encoder, pinning the whole
|
||||||
|
// pipeline to 1x. This is the real-time throttle for the streaming path
|
||||||
|
// (-re only works for file/URL inputs; screen share is always a pipe).
|
||||||
|
let sourcePaused = false;
|
||||||
|
const gateSource = (ok: boolean) => {
|
||||||
|
if (!ok && !sourcePaused) {
|
||||||
|
sourcePaused = true;
|
||||||
|
proc.stdout?.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
vPipe.on("drain", () => {
|
||||||
|
if (sourcePaused) {
|
||||||
|
sourcePaused = false;
|
||||||
|
proc.stdout?.resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let videoBuf = Buffer.alloc(0);
|
let videoBuf = Buffer.alloc(0);
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
let pendingNals: Buffer[] = [];
|
let pendingNals: Buffer[] = [];
|
||||||
@@ -388,7 +414,7 @@ export async function demux(
|
|||||||
pendingNals = [];
|
pendingNals = [];
|
||||||
pendingHasSlice = false;
|
pendingHasSlice = false;
|
||||||
pendingIsKey = false;
|
pendingIsKey = false;
|
||||||
vPipe.write({
|
const ok = 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 →
|
||||||
// BaseMediaStream computes frametime=1000/fps ms → the RTP timestamp
|
// BaseMediaStream computes frametime=1000/fps ms → the RTP timestamp
|
||||||
@@ -401,6 +427,9 @@ export async function demux(
|
|||||||
streamIndex: 0,
|
streamIndex: 0,
|
||||||
free: () => {},
|
free: () => {},
|
||||||
});
|
});
|
||||||
|
// vPipe is full (sender can't keep up) → pause the demuxer's ffmpeg
|
||||||
|
// stdout so the backlog can't grow. Resumed on 'drain' above.
|
||||||
|
gateSource(ok);
|
||||||
frameCount++;
|
frameCount++;
|
||||||
if (frameCount === 1 || frameCount % 30 === 0) {
|
if (frameCount === 1 || frameCount % 30 === 0) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ export function prepareStream(
|
|||||||
customInputOptions: (options.customInputOptions as string[]) ?? [],
|
customInputOptions: (options.customInputOptions as string[]) ?? [],
|
||||||
customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [],
|
customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [],
|
||||||
minimizeLatency: options.minimizeLatency ?? false,
|
minimizeLatency: options.minimizeLatency ?? false,
|
||||||
|
// realtime: throttle ffmpeg's INPUT read to 1x so the encoder tracks
|
||||||
|
// wall-clock instead of slurping a VOD at network speed. Without this,
|
||||||
|
// a YouTube screen share downloads the whole clip fast and the encoder
|
||||||
|
// produces a ~10x frame backlog that the demuxer buffers unboundedly —
|
||||||
|
// the sender paces at 30fps but always emits the OLDEST buffered frames,
|
||||||
|
// so the viewer sees frozen/laggy video while audio (tiny, jitter-
|
||||||
|
// buffer recoverable) stays smooth. That is the "video stuck, voice
|
||||||
|
// normal" symptom. `-re` caps the pipeline at 1x end-to-end. Default on
|
||||||
|
// because this prepareStream is only used for screen share (VOD URLs).
|
||||||
|
realtime: options.realtime ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const output = new PassThrough();
|
const output = new PassThrough();
|
||||||
@@ -117,6 +127,10 @@ export function prepareStream(
|
|||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
|
// Real-time throttle: when the input is a URL/VOD, read it at 1x so the
|
||||||
|
// encoder paces with wall-clock (see `realtime` option above). For live
|
||||||
|
// pipe (PassThrough) input we DON'T add -re — the producer already paces.
|
||||||
|
...(mergedOptions.realtime && typeof input === "string" ? ["-re"] : []),
|
||||||
...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]),
|
...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]),
|
||||||
...mergedOptions.customInputOptions,
|
...mergedOptions.customInputOptions,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user