Tune in faster: first frame ~1 s sooner #1

Merged
Grandiras merged 2 commits from faster-tune-in into main 2026-10-09 23:25:54 +00:00
Owner

Makes live streams start faster, especially in Surf.

I traced every stage of a real tune-in. The largest delays were on our side:

Stage Before After
WHEP request in Surf queued behind thumbnails (1–3 s each) and chat on the shared libsoup session; sometimes no answer within 2 s its own session, opened ahead of time; ~0.55 s (server time)
Jitter buffer at start held the first packets 600 ms before decoding faststart-min-packets: first frame decoded and shown at once, motion starts on the full buffer as before
"Tuning in…" → playing next watchdog tick (≤ 500 ms) when the first frame is painted
Offer + ICE gathering polled at 50/100 ms (~150 ms) ~15 ms

Result: about 1.6 s to the first frame in Surf (was ~3 s, often more), and ~1.8 s on the watch page (was ~2.5 s). A 1080p60 stream still showed 0 dropped frames over 20 s. HLS (native build) still plays.

Not fixable on our side:

  • Server answer ~0.5 s.
  • ICE + DTLS ~0.6 s (webrtcbin always uses regular nomination).
  • Waiting for the next keyframe, 0–0.7 s. A PLI at start made no difference.

🤖 Generated with Claude Code

Makes live streams start faster, especially in Surf. I traced every stage of a real tune-in. The largest delays were on our side: | Stage | Before | After | |---|---|---| | WHEP request in Surf | queued behind thumbnails (1–3 s each) and chat on the shared libsoup session; sometimes no answer within 2 s | its own session, opened ahead of time; ~0.55 s (server time) | | Jitter buffer at start | held the first packets 600 ms before decoding | `faststart-min-packets`: first frame decoded and shown at once, motion starts on the full buffer as before | | "Tuning in…" → playing | next watchdog tick (≤ 500 ms) | when the first frame is painted | | Offer + ICE gathering | polled at 50/100 ms (~150 ms) | ~15 ms | **Result:** about 1.6 s to the first frame in Surf (was ~3 s, often more), and ~1.8 s on the watch page (was ~2.5 s). A 1080p60 stream still showed 0 dropped frames over 20 s. HLS (native build) still plays. **Not fixable on our side:** - Server answer ~0.5 s. - ICE + DTLS ~0.6 s (webrtcbin always uses regular nomination). - Waiting for the next keyframe, 0–0.7 s. A PLI at start made no difference. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Measured with a stage-by-stage trace of real tune-ins:

- The WHEP request shared libsoup's connections with the Live page's
  thumbnails (1-3 s each) and the chat websocket, so in Surf it often
  waited seconds for a free connection. It now has its own session,
  opened ahead of time (at startup and when hovering a stream card), and
  a request for a stream you've already flipped past is cancelled.
- The jitter buffer held the first packets for the whole latency (600 ms)
  before decoding. faststart lets the first frame through at once; it
  shows as a still until the buffer is full, so smoothness is unchanged.
- "Tuning in…" ends when the first frame is painted instead of at the
  watchdog's next half-second tick.
- Offer and ICE gathering take milliseconds; we no longer poll them at
  50/100 ms.

Surf: ~3 s (sometimes much longer) to first frame → ~1.6 s. Watch page:
~2.5 s → ~1.8 s. The rest is the server (answer ~0.5 s, ICE + DTLS
~0.6 s, then the next keyframe); a PLI at start didn't bring the
keyframe sooner.

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

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Watchplace/pr-1/run-2026-10-09T23-22-14-071Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/840); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Watchplace/pr-1/run-840.md) — updating every ~5 minutes while the task runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-reviewer approved these changes 2026-10-09 23:22:47 +00:00
Dismissed
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

The change looks sound, and I found nothing that should block merging. I read the diff only and did not build or run it.

Things to be aware of, none of them blocking:

  • promise.wait() in Session::start: it blocks the GTK main thread. The wait is only the few milliseconds of create-offer, which webrtcbin handles on its own thread, so this is fine. If webrtcbin ever needed the main loop to answer, it would deadlock. Your trace shows it doesn't.
  • faststart-min-packets: set_property on a property the element doesn't have panics. The property has been in rtpjitterbuffer for several GStreamer releases, so recent installs are fine. An older GStreamer would crash on the first tune-in. Checking el.find_property("faststart-min-packets") first would make this safe.
  • warm_up() on every card hover: each hover issues a new preconnect. libsoup reuses an idle connection, so the cost is small. A cheap "already warm" timestamp would avoid the repeated calls.
  • playing_on_first_frame: it calls set_state from inside the invalidate-contents signal. That is fine as long as set_state listeners don't trigger paintable invalidation themselves, which is worth keeping in mind.

Cleanly done: the dedicated WHEP session, aborting the negotiation task via stop_whep when switching streams, and removing the hand-rolled oneshot.


🧠 claude-sonnet-5-5 · ⏱ 0m 14s · 2 turns · tokens: 22.9k in / 1.8k out (+0 cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. The change looks sound, and I found nothing that should block merging. I read the diff only and did not build or run it. Things to be aware of, none of them blocking: - **`promise.wait()` in `Session::start`:** it blocks the GTK main thread. The wait is only the few milliseconds of `create-offer`, which webrtcbin handles on its own thread, so this is fine. If webrtcbin ever needed the main loop to answer, it would deadlock. Your trace shows it doesn't. - **`faststart-min-packets`:** `set_property` on a property the element doesn't have panics. The property has been in rtpjitterbuffer for several GStreamer releases, so recent installs are fine. An older GStreamer would crash on the first tune-in. Checking `el.find_property("faststart-min-packets")` first would make this safe. - **`warm_up()` on every card hover:** each hover issues a new preconnect. libsoup reuses an idle connection, so the cost is small. A cheap "already warm" timestamp would avoid the repeated calls. - **`playing_on_first_frame`:** it calls `set_state` from inside the `invalidate-contents` signal. That is fine as long as `set_state` listeners don't trigger paintable invalidation themselves, which is worth keeping in mind. Cleanly done: the dedicated WHEP session, aborting the negotiation task via `stop_whep` when switching streams, and removing the hand-rolled oneshot. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 14s · 2 turns · tokens: 22.9k in / 1.8k out (+0 cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Watchplace/pr-1/run-840.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:eb4bc96dda4208ce3c3f8cdfbbd32fc74636dfed -->
opencode-reviewer approved these changes 2026-10-09 23:23:49 +00:00
Dismissed
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Good change; the lifecycle handling around it holds up.

Checked in context:

  • stop_whep/abort ordering — aborting whep_task before taking the session is sound: the future is cancelled at its next await point, self.whep still holds an Rc<Session> so its Drop (pipeline → NULL) runs reliably, and nothing can call whep_failed() from a stale attempt since Rc::ptr_eq already guarded that. play_url/play_vod/stop_pipelines behavior is unchanged relative to the old inline whep.take().
  • playing_on_first_frame — one connection per paintable (hls once at construction, one per WHEP session), so no signal leak across tune-ins; the State::Loading + frames guard makes repeat invalidate_contents fires during playback a no-op, and restart() resets frames so subsequent HLS tune-ins still trigger it. Old sessions can't paint while HLS is Loading because their pipeline is NULLed in stop_whep.
  • faststart-min-packets = 1 — verified against the GStreamer docs: the property exists (since 1.14), default 0 means faststart is disabled, and the doc text ("number of consecutive packets needed to start… the jitterbuffer will by default start after the latency has elapsed") matches the intended effect. guint → u32 is right.
  • Dedicated WHEP session + preconnect_async — correct libsoup3 API for the job; the preconnect only establishes the connection, no HTTP exchange, so repeated warm_up() on hover is cheap. SESSION.with(|s| s.clone()) stays on the main thread (all futures here are spawn_future_local), which is what libsoup requires.
  • Tighter ICE polling — 10 ms / 500 iters keeps the same 5 s ceiling as before, just finer-grained.

Two non-blocking notes:

  • promise.wait() in Session::start now blocks the main thread with no cap, where the old oneshot bailed after 5 s. webrtcbin does resolve create-offer from its own task thread, so no deadlock, and since the main loop is blocked during the wait, stop_whep can't race it either — just be aware the UI thread relies on that promise always replying. poll_stats already used the same wait() pattern, so this is consistent with the codebase.
  • whep::warm_up() at startup opens a connection to the node at launch even if the user never opens a live stream (and hover fires it before the hover_previews check). Intentional per the comment and cheap, but worth knowing it's a network touch on app open.

Numbers and "not fixable on our side" caveats look plausible and the risk profile is low; nothing blocking.


⏱ 1m 36s · 5 steps · $0.0123 · tokens: 47.6k in / 850 out (+43.8k cache-read) · Go: 5h 0% · wk 8% · mo 95%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Good change; the lifecycle handling around it holds up. Checked in context: - **`stop_whep`/abort ordering** — aborting `whep_task` before taking the session is sound: the future is cancelled at its next await point, `self.whep` still holds an `Rc<Session>` so its `Drop` (pipeline → NULL) runs reliably, and nothing can call `whep_failed()` from a stale attempt since `Rc::ptr_eq` already guarded that. `play_url`/`play_vod`/`stop_pipelines` behavior is unchanged relative to the old inline `whep.take()`. - **`playing_on_first_frame`** — one connection per paintable (hls once at construction, one per WHEP session), so no signal leak across tune-ins; the `State::Loading` + `frames` guard makes repeat `invalidate_contents` fires during playback a no-op, and `restart()` resets `frames` so subsequent HLS tune-ins still trigger it. Old sessions can't paint while HLS is `Loading` because their pipeline is NULLed in `stop_whep`. - **`faststart-min-packets = 1`** — verified against the GStreamer docs: the property exists (since 1.14), default 0 means faststart is disabled, and the doc text ("number of consecutive packets needed to start… the jitterbuffer will by default start after the latency has elapsed") matches the intended effect. `guint` → `u32` is right. - **Dedicated WHEP session + `preconnect_async`** — correct libsoup3 API for the job; the preconnect only establishes the connection, no HTTP exchange, so repeated `warm_up()` on hover is cheap. `SESSION.with(|s| s.clone())` stays on the main thread (all futures here are `spawn_future_local`), which is what libsoup requires. - **Tighter ICE polling** — 10 ms / 500 iters keeps the same 5 s ceiling as before, just finer-grained. Two non-blocking notes: - `promise.wait()` in `Session::start` now blocks the main thread with no cap, where the old oneshot bailed after 5 s. webrtcbin does resolve create-offer from its own task thread, so no deadlock, and since the main loop is blocked during the wait, `stop_whep` can't race it either — just be aware the UI thread relies on that promise always replying. `poll_stats` already used the same `wait()` pattern, so this is consistent with the codebase. - `whep::warm_up()` at startup opens a connection to the node at launch even if the user never opens a live stream (and hover fires it before the `hover_previews` check). Intentional per the comment and cheap, but worth knowing it's a network touch on app open. Numbers and "not fixable on our side" caveats look plausible and the risk profile is low; nothing blocking. --- ⏱ 1m 36s · 5 steps · $0.0123 · tokens: 47.6k in / 850 out (+43.8k cache-read) · Go: 5h 0% · wk 8% · mo 95% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Watchplace/pr-1/run-2026-10-09T23-22-14-071Z.md) <!-- opencode-reviewed-head:eb4bc96dda4208ce3c3f8cdfbbd32fc74636dfed --> <!-- opencode-verdict:approve -->
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/841); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Watchplace/pr-1/run-841.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

The earlier review had no blocking issues, and I found nothing new that blocks this version. I read the diff and did not build or run it.

The latest commit's change is set_idle_timeout(300) on the dedicated WHEP session. It matches the stated goal of keeping the tune-in connection open as long as the node does. preconnect_async is called with a valid signature.

The four non-blocking notes from my last pass still apply:

  • promise.wait() on the main thread: this is safe only while webrtcbin answers create-offer on its own thread, which your trace shows it does.
  • faststart-min-packets: set_property panics if the element lacks the property, so an older GStreamer would crash on the first tune-in. A guard with el.find_property("faststart-min-packets") would make it safe.
  • warm_up() on every card hover: each hover issues a fresh preconnect, which is cheap because libsoup reuses an idle connection. A timestamp check would avoid the repeated calls.
  • playing_on_first_frame: it calls set_state from inside invalidate-contents, which is fine as long as set_state listeners never trigger a paintable invalidation themselves.

The dedicated session, the abort of the negotiation task in stop_whep, and the removal of the hand-rolled oneshot are all clean.


🧠 claude-sonnet-5-5 · ⏱ 0m 9s · 2 turns · tokens: 10.7k in / 1.3k out (+13.0k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. The earlier review had no blocking issues, and I found nothing new that blocks this version. I read the diff and did not build or run it. The latest commit's change is `set_idle_timeout(300)` on the dedicated WHEP session. It matches the stated goal of keeping the tune-in connection open as long as the node does. `preconnect_async` is called with a valid signature. The four non-blocking notes from my last pass still apply: - **`promise.wait()` on the main thread:** this is safe only while webrtcbin answers `create-offer` on its own thread, which your trace shows it does. - **`faststart-min-packets`:** `set_property` panics if the element lacks the property, so an older GStreamer would crash on the first tune-in. A guard with `el.find_property("faststart-min-packets")` would make it safe. - **`warm_up()` on every card hover:** each hover issues a fresh preconnect, which is cheap because libsoup reuses an idle connection. A timestamp check would avoid the repeated calls. - **`playing_on_first_frame`:** it calls `set_state` from inside `invalidate-contents`, which is fine as long as `set_state` listeners never trigger a paintable invalidation themselves. The dedicated session, the abort of the negotiation task in `stop_whep`, and the removal of the hand-rolled oneshot are all clean. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 9s · 2 turns · tokens: 10.7k in / 1.3k out (+13.0k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Watchplace/pr-1/run-841.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:444cb5cd1b3572d9ee3f07a5449d0ac71d05b2a3 -->
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Watchplace/pr-1/run-2026-10-09T23-24-49-207Z.md) — updating every ~5 minutes while the review runs.
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review: the only change since the last pass is set_idle_timeout(300) on the dedicated WHEP session (src/whep.rs:33). It is a valid libsoup3 property (seconds, guint) and matches the comment's rationale that the node keeps idle connections for minutes, closing the loop I noted earlier about keeping the preconnected connection alive. Everything else is as reviewed and approved before: stop_whep aborts the negotiation task while Rc<Session> Drop reliably NULLs the pipeline; playing_on_first_frame connects once per paintable and is guarded by State::Loading + frames; faststart-min-packets is a real property (since 1.14); preconnect_async usage is correct.

Remaining non-blocking notes (also on the thread already): promise.wait() on the main thread is safe only while webrtcbin resolves create-offer on its own thread; set_property("faststart-min-packets") would panic on an older GStreamer without the property (a find_property guard makes it safe); hover-triggered warm_up() could be timestamp-gated; calling set_state from inside invalidate-contents relies on no listener invalidating the paintable in turn. Nothing blocks merging.


⏱ 0m 14s · 3 steps · $0.0044 · tokens: 22.6k in / 588 out (+13.4k cache-read) · Go: 5h 0% · wk 8% · mo 95%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Re-review: the only change since the last pass is `set_idle_timeout(300)` on the dedicated WHEP session (`src/whep.rs:33`). It is a valid libsoup3 property (seconds, guint) and matches the comment's rationale that the node keeps idle connections for minutes, closing the loop I noted earlier about keeping the preconnected connection alive. Everything else is as reviewed and approved before: `stop_whep` aborts the negotiation task while `Rc<Session>` Drop reliably NULLs the pipeline; `playing_on_first_frame` connects once per paintable and is guarded by `State::Loading` + `frames`; `faststart-min-packets` is a real property (since 1.14); `preconnect_async` usage is correct. Remaining non-blocking notes (also on the thread already): `promise.wait()` on the main thread is safe only while webrtcbin resolves `create-offer` on its own thread; `set_property("faststart-min-packets")` would panic on an older GStreamer without the property (a `find_property` guard makes it safe); hover-triggered `warm_up()` could be timestamp-gated; calling `set_state` from inside `invalidate-contents` relies on no listener invalidating the paintable in turn. Nothing blocks merging. --- ⏱ 0m 14s · 3 steps · $0.0044 · tokens: 22.6k in / 588 out (+13.4k cache-read) · Go: 5h 0% · wk 8% · mo 95% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Watchplace/pr-1/run-2026-10-09T23-24-49-207Z.md) <!-- opencode-reviewed-head:444cb5cd1b3572d9ee3f07a5449d0ac71d05b2a3 --> <!-- opencode-verdict:approve -->
Grandiras deleted branch faster-tune-in 2026-10-09 23:25:54 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
5 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Watchplace!1
No description provided.