Anchor the picture to the first frame, not to zero

Gating frames on absolute stream position overshot in the other
direction: audio starts flowing before the first frame is decoded, so
pinning frame 0 to audio second 0 puts the whole clip permanently in
debt, and frames arrive already past due.

The first frame now records the audio position it arrived at, and the
rest keep their spacing from there — the drift correction stays, the
startup difference is no longer treated as lateness. That offset is
logged, so the residual can be trimmed with VIDEO_SYNC_OFFSET_MS, which
the publisher now applies itself instead of ffmpeg (one mechanism, and it
works in both directions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 02:59:12 +03:00
co-authored by Claude Opus 5
parent bc567e953e
commit 623bab775f
2 changed files with 14 additions and 7 deletions
+14 -1
View File
@@ -62,6 +62,8 @@ export class VideoPublisher {
private produced = 0;
private published = 0;
private dropped = 0;
/** Audio position when the first frame showed up; the picture is hung off it. */
private anchor: { audioSeconds: number; frameIndex: number } | null = null;
private pump: NodeJS.Timeout | null = null;
private firstFrameTimer: NodeJS.Timeout | null = null;
@@ -152,10 +154,20 @@ export class VideoPublisher {
const { fps } = this.options;
const now = this.options.audioClock();
const tolerance = 1 / (2 * fps);
const offset = config.VIDEO_SYNC_OFFSET_MS / 1000;
while (this.queue.length > 0) {
const frame = this.queue[0] as QueuedFrame;
const dueAt = frame.index / fps;
// The first frame sets the relationship between the two streams instead of
// assuming both start at zero: audio begins flowing before the picture is
// decoded, and pinning frames to an absolute zero puts the whole clip in
// debt. From there the spacing is held, which is what stops the drift.
if (!this.anchor) {
this.anchor = { audioSeconds: now, frameIndex: frame.index };
log.info({ audioSeconds: Number(now.toFixed(2)) }, "video anchored to the audio clock");
}
const dueAt =
this.anchor.audioSeconds + (frame.index - this.anchor.frameIndex) / fps + offset;
if (now + tolerance < dueAt) break;
this.queue.shift();
@@ -214,5 +226,6 @@ export class VideoPublisher {
this.produced = 0;
this.published = 0;
this.dropped = 0;
this.anchor = null;
}
}
-6
View File
@@ -64,11 +64,6 @@ export function openVideoPipeline(
const filters = [`scale=${width}:${height}`, `fps=${fps}`, "format=yuv420p"].join(",");
const seek = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
// Hand-tuning room for lip sync; the two tracks are published separately.
const offset =
config.VIDEO_SYNC_OFFSET_MS !== 0
? ["-itsoffset", (config.VIDEO_SYNC_OFFSET_MS / 1000).toFixed(3)]
: [];
// Split: fd 0 video in, fd 3 audio in, fd 4 frames out.
// Progressive: fd 0 everything in, fd 3 frames out.
const videoOutFd = split ? 4 : 3;
@@ -79,7 +74,6 @@ export function openVideoPipeline(
"-hide_banner",
"-loglevel",
"error",
...offset,
...seek,
"-re",
"-i",