Skip to content

fix(whip): keep a slot's video PTS moving forward frame to frame - #893

Closed
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-video-pts
Closed

wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:wagenet/whip-slot-video-pts

Conversation

@wagenet

@wagenet wagenet commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (AI), on behalf of @wagenet.

Current behaviour

A WHIP publisher joining a slot, including a reconnect or a second device on a reused slot, can take the whole flow down. With a GPU vision mixer downstream, the flow stops with Internal data stream error from appsrc_overlay, queue_overlay and the slot's appsrc_video_0. The GStreamer log shows the cause:

ERROR videoaggregator ...:<vmix:mixer:sink_0> Need timestamped buffers!

On a local rig (browser publisher, same flow shape as the production that hit it, with the aspect fix #894 applied) this happened in 5 of 8 runs of "publish, switch camera, leave, second publisher joins".

Fix

When the slot bridge forwards a video packet that starts a new RTP frame, and its PTS is at most 1 ms behind the previous frame's, the packet gets the previous frame's PTS plus 1 ns. Packets of one frame keep sharing a PTS, and any other PTS passes through unchanged. With the fix, the same rig had 0 of 8 deaths.

Why frames lose their timestamp
  • When a session starts, its jitterbuffer can release a backlog of packets from different frames (their RTP timestamps advance about 3000 ticks, i.e. 33 ms at 90 kHz) all carrying one and the same PTS. Captured on the rig with rtpbasedepayload:6: seqnums 22859-22882, eight RTP timestamps, one PTS 0:00:21.537999057.
  • rtph264depay passes that PTS on to every access unit. h264parse (baseparse) takes an input PTS only when it differs from the previous one (gstbaseparse.c, prev_pts != pts), so every later frame of the run leaves the parser with no PTS.
  • Browser H.264 carries no frame timing (framerate=0/1), so the decoder cannot interpolate one. vtdec passed PTS NONE through (videodecoder:6: chain PTS 99:99:99.999999999 for every frame after the second), and glvideomixer returns GST_FLOW_ERROR on the first frame without a timestamp.

A standalone check (x264 → RTP → appsink/appsrc bridge → rtph264depay ! h264parse), with frames 10-25 forced onto one PTS: 15 of 60 frames leave h264parse without a PTS; with the ordering rule, 0.

Only a PTS at most 1 ms behind the previous frame's is moved. A larger step back is real timing and passes through: a publisher whose encoder uses B-frames sends a frame after the one it precedes. Browsers never send B-frames, but other WHIP clients can. A frame far ahead does not hold back the frames after it.

How to review

Tests

  • New whip::tests::video_frames_on_one_pts_reach_the_slot_in_order drives forward_sample_to_slot with real RTP packets. With the ordering bypassed it fails ([1000000000, 1000000000, 1000000000, 1000000000, 1100000000]); with it, it passes.
  • New video_b_frames_keep_their_pts and video_frame_far_ahead_does_not_hold_back_later_frames fail if every late frame is moved past the previous one (the rule without the 1 ms bound).
  • cargo test --workspace on this branch, macOS: 1063 passed, 0 failed.
  • Rig: 8 alternating runs each, fresh server per run, three-shape sequence (1280x720 → switch to 720x1280 → leave → 640x480 publisher). Both arms had fix(vision mixer): keep a source's new shape when it changes size #894 applied: without this fix 5 of 8 flows died, with it 0 of 8. The rig is Chrome publishing canvas tracks through the real /player/whip-ingest page. Not run: a phone over cellular.

🤖 Generated with Claude Code

A session's jitterbuffer can release a run of packets from different
frames on one PTS, as it does when a publisher joins a slot. The
slot's h264parse takes a PTS only when it differs from the previous
buffer's, so the later frames of the run leave it with none. Browser
video carries no frame timing to fill one in from, and the GPU vision
mixer fails the whole flow on a frame without a timestamp ("Need
timestamped buffers!").

When the slot bridge forwards a video packet from a new RTP frame, give
it at least 1 ns more than the previous frame's PTS. Packets of one
frame keep sharing theirs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new frame moves past the previous frame's PTS only when its own is at
most 1 ms behind it, the case a jitterbuffer run on one PTS produces.
A larger step back is real timing and keeps its PTS: an encoder with
B-frames sends a frame after the one it precedes, and moving it past
that frame would bunch the decoded video. A frame far ahead does not
hold back the frames after it, which would freeze the seat until real
time caught up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens
srperens previously approved these changes Sep 30, 2026

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve — the mechanism is traced end to end (one PTS across several frames, then baseparse keeps only a PTS that changed). The fix is bounded to that one shape, and B-frames and outliers keep their timing. The three new tests drive the real forward_sample_to_slot and all three ran in CI. It changes the same function as #759; either order works, see Overlaps.

Non-blocking notes

  1. The RTP read fails silently: backend/src/blocks/builtin/whip.rs:1361 — adjusted = order.order(rtp.timestamp(), adjusted); runs only if the header maps. That is right as long as the bridge carries RTP, which the rig and decode=false's own description (outputs RTP (application/x-rtp)) both say it does. But backend/src/blocks/builtin/whip.rs:1201 — // whipserversrc autoplugs RTP depayloaders inside its own bin, so this says the opposite. If that comment is stale, correcting it here would stop the next reader doubting this fix. If an upstream change ever depayloads before the bridge, the fix turns into a silent no-op.
  2. The body says the packets of one frame "keep sharing a PTS". The code enforces that: backend/src/blocks/builtin/whip.rs:1271 — if last_rtptime == u64::from(rtptime) { returns the first packet's PTS for every later packet of the frame. With rtpbin's default slave mode these already match, so in practice nothing changes. It is still a behaviour worth one line in the doc comment.

Claims

Claim Verdict Evidence
Only a step back of at most 1 ms is moved; B-frames and far-ahead frames pass CONFIRMED backend/src/blocks/builtin/whip.rs:1275 — last_rtptime != u64::MAX && pts <= last_pts && last_pts - pts <= MAX_PTS_NUDGE_NS;, and the tests video_b_frames_keep_their_pts and video_frame_far_ahead_does_not_hold_back_later_frames pin both sides
The regression test fails with the ordering bypassed CONFIRMED video_frames_on_one_pts_reach_the_slot_in_order asserts out[2] > out[1] for packets (4000, 1000) after (1000, 1000). Without order() both leave at 1000 ms
No lock and no new allocation on the per-packet path CONFIRMED Relaxed atomics plus a read-only RTPBuffer map. The copy happens only when the PTS changed, as it already did whenever offset_ns != 0
baseparse ignores a repeated PTS; vtdec/glvideomixer fail on PTS NONE EXTERNAL upstream behaviour, taken from the body's traces

Diagnosis — Right layer. The bridge is the last point that still sees RTP timestamps before h264parse loses the PTS. Deriving PTS from RTP-time deltas would be the obvious alternative, and it is worse: a backlog released at once would be stamped ahead of real time, and the frames after it would then step backwards. The +1 ns nudge avoids that. Coverage is BOUNDED to runs of at most ~1e6 frames on one PTS, which in practice is every run.

Radius — LOCAL: the video bridge of every WHIP Input slot, codec-agnostic, for decode=true and decode=false. Audio is untouched. VideoPtsOrder is created per session pad, so a reused slot starts clean.

Overlaps — #759 (Approve at 9ba9941) changes the same code path: it moves forward_sample_to_slot into SessionBridge. Landing #759 first is the smaller rebase, because VideoPtsOrder moves as one unit. #854 (draft) regenerates after either. #901 restarts the slot decodebin, which is a different mechanism: a fresh h264parse still drops the repeated PTS.

Tests & CI — All Linux, WASM and API checks green at e90f52b. The job log shows all three new whip::tests::video_* tests ok, and pipeline_lifecycle_test ran. macOS/Windows skipped; nothing here is platform-specific.

Confidence: HIGH

Eyevinn#756, Eyevinn#792 and Eyevinn#805 drop AtomicU64 from whip.rs's imports once nothing
else there uses it. Naming it by path keeps this struct compiling in
either merge order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Written by Claude (AI) on behalf of @srperens.

This fix is now part of #957, which consolidates the WHIP Input fixes into one series against current main. Your commits are in it with you as author.

Adapted: VideoPtsOrder now runs on the session PTS before #759's SessionBridge rebases it, and copies the buffer only when the PTS actually moves. The bridge offset is constant, so the result is the same. All three of your tests are in, and each fails with its behaviour reverted. #894 is on the integration branch next to it for a rig run.

Suggest closing this once #957 merges.

@srperens

srperens commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Closing in favour of #957, which carries this work (see the comment above).

@srperens srperens closed this Oct 2, 2026
srperens added a commit that referenced this pull request Oct 2, 2026
… and #943 (#957)

* fix(whip): stop an unstamped buffer at the session bridge before qtmux sees it

A WHIP session's appsink -> appsrc bridge forwarded buffers with no PTS.
A recorder downstream answers one with "Buffer has no PTS" from qtmux and
GST_FLOW_ERROR, which takes the whole flow down: on a live rig, a second
participant joining killed the first.

The slot bridge now goes through gst::pipeline_bridge::SessionBridge: a
buffer with no PTS is dropped and counted (warned on the first and every
hundredth), the shared A/V offset is set once per session with
compare_exchange and taken from the first stamped buffer, DTS is shifted
with PTS, and a sample without caps no longer panics.

From #759 (cfc4518, f62e777, 9ba9941), adapted to main:
- The slot callback keeps the session_finished gate from #753 and the
  ingress liveness stamp from #756, wrapped in install_slot_bridge so the
  wiring test drives the real callback.
- The Media Player half of #759 is left out. #945 replaced that bridge's
  timing with its own, so SessionBridge no longer fits there.

Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(whip): keep a slot's video PTS moving forward frame to frame

A WHIP publisher joining a slot can take the whole flow down with a GPU
vision mixer downstream: "Need timestamped buffers!" from videoaggregator.
The session's jitterbuffer can release a run of packets from different
frames on one PTS; h264parse takes a PTS only when it differs from the
previous one, so the later frames of the run leave it with none, and a
browser stream has no framerate to derive one from.

A video packet that starts a new RTP frame and whose PTS is at most 1 ms
behind the previous frame's now gets that PTS plus 1 ns. Packets of one
frame keep sharing a PTS, and any other PTS (a B-frame, a step in the
sender's clock, a frame far ahead) passes through. On the rig, 5 of 8
runs of publish / switch camera / leave / rejoin died before, 0 of 8
after.

From #893 (745ce02, e90f52b, 2540522), adapted to the SessionBridge from
#759: the ordering runs on the session PTS before the bridge rebases it,
and copies the buffer only when the PTS moves.

Co-Authored-By: Per Enstedt <per.enstedt@eyevinn.se>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(whip): stop a session's second stream stalling below PLAYING

A WHIP session pipeline is already PLAYING when whipserversrc adds its
pads, and each pad gets a `tee -> fakesink + appsink` branch synced into
it. The appsink ran with `GstBaseSink`'s default `async=true`, so it
answered that state change with ASYNC and waited for a preroll. The pads
arrive one after the other within a millisecond, so the second branch
lands while the first is still waiting, and the second is left below
PLAYING: it takes a single buffer and then blocks its streaming thread for
good.

That seat's audio or video then never reaches the slot's appsrc, so its
decodebin exposes no pad, and the flow's recorder and mixer wait on a
track that will never arrive. Nothing in the log says so —
`sync_state_with_parent` reports the state change as accepted either way.

Turn `async` off, as the fakesink beside it already does. Measured on a
five-seat meeting rig, 18 fresh-server trials per arm: 7/18 seats failed
to start before, 0/18 after.

The branch construction moves into `attach_session_branch` so the
regression test builds the real thing rather than a copy of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(thumbnail): turn off async on the thumbnail tap appsink

The thumbnail branch is attached to a flow pipeline that is already
running, and its appsink ran with GstBaseSink's default async=true, so it
answered that state change with ASYNC and waited for a preroll. The
activation guard admits the pipeline in PAUSED with PLAYING pending,
which is that wait, and a branch attached inside one is left below
PLAYING: it takes a single buffer and blocks its streaming thread.

Activation returns without waiting for a preroll, and a branch is created
per block from an HTTP request, so a client asking for several blocks'
thumbnails serializes on one mutex but not on the async cycles behind it.

Same defect and same fix as the WHIP session branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(whip): give a reused slot a fresh video decoder

A WHIP Input slot keeps its decode chain while publishers come and go.
The video decoder that decodebin plugged for the first session kept
that session's reference frames, reorder queue and configuration, and a
later session with equal caps sends none to reconfigure it. On macOS,
VideoToolbox then decoded a second Safari publisher at a few frames per
second and flagged every frame of a third as an error, so the slot
showed no video while its audio played.

When a session claims a slot whose video decodebin has already run,
restart that decodebin from an IDLE probe on the slot appsrc: unlink,
set it to NULL, relink so the sticky events are sent again, and sync it
with the pipeline. The existing pad-added handler links the new chain
when data arrives. Audio keeps its running chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(whip): guard a codec change on a reused slot

The test reuses a slot with a VP8 publisher after an H.264 one and
asserts the VP8 session decodes. With the restart disabled, the kept
decoder refuses the VP8 caps and the slot's source stops with
not-negotiated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(whip): ask for a keyframe when a running session's video breaks

A WHIP session asked its publisher for a keyframe only until video first
decoded. After that, nothing asked again. A browser sends a keyframe only
when asked, so a loss the jitterbuffer could not repair left the slot's
decoder broken for the rest of the session: smeared pictures, or every
frame rejected with an error flag, until the watchdog tore the session
down and the guest rejoined.

The slot's decodebin now reports damage through a per-slot flag:

- a gap: the depayloader marks the first access unit after a sequence
  gap DISCONT; damaged until a keyframe with no gap in front of it
  leaves the depayloader (a repair keyframe can lose a packet too).
- a silent decoder: data lost with no gap (the depayloader drops a
  fragment it cannot place) makes the decoder reject every frame without
  a bus message. 30 access units in with no picture out counts as
  damage, cleared when pictures come out again.

Both are buffer probes that touch atomics only. A per-session thread on
the session side reads the flag and sends the keyframe request on the
whipserversrc pad, where it reaches the publisher: at once, then at most
once a second, 10 times per damage episode, and never on healthy video.
Each episode logs its recovery time and request count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(whip): guard that a reused slot reports its video as decoding

The keyframe requester stops once the slot's video_decoding flag is set,
and that flag is set from decodebin's pad-added. Before the decoder
restart on reuse, pad-added fired once per flow, so every reconnecting
publisher was asked for the full request budget while its video decoded
fine (#805). The restart brings pad-added back for each session.

The reuse test now re-arms the flag as a new session does and asserts the
second session sets it again. With the restart disabled it fails with
"the second session decoded, but the slot never reported its video as
decoding".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Peter Wagenet <peter.wagenet@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants