Skip to content

Pre-encoded H.265: length-prefixed ingest, pacer tuning, leaner publish path - #1579

Draft
haffi96 wants to merge 3 commits into
livekit:mainfrom
haffi96:feat/h265-pre-encoded-length-prefixed
Draft

haffi96 wants to merge 3 commits into
livekit:mainfrom
haffi96:feat/h265-pre-encoded-length-prefixed

Conversation

@haffi96

@haffi96 haffi96 commented Oct 27, 2026 •

Copy link
Copy Markdown

Publishing pre-encoded H.265 from hardware encoder bridges, which can deliver 4-byte length-prefixed NAL units (hev1). One commit per stage, each with its own changeset:

1. Publish length-prefixed pre-encoded H.265

  • livekit-capture: LengthPrefixedAccessUnitParser (previously test-only, H.264 only) handles H.264 and H.265, with an opt-in single-slice flush that completes a picture as soon as its slice arrives instead of a frame later. Adds access_unit_from_h265_hvcc, hev1 caps in the GStreamer source, and frame_metadata_from_sei for the LKTS packet-trailer SEI (the Go SDK's ReaderTrackWithPacketTrailer convention).
  • livekit: single-layer video tracks announce their codec in simulcast_codecs, as the Go SDK does.
  • local_video: new encoded_publisher example that publishes a length-prefixed stream received over TCP.

2. Let pre-encoded tracks leave the pacer promptly
A pre-encoded stream arrives at its encoder's bitrate, but the starting estimate is capped at 1 Mbps, so packets queued in the pacer for ~8 s after publishing (~510 ms per packet at the peak for 1080p30 at 4 Mbps).

  • webrtc-sys: read field trials from LIVEKIT_WEBRTC_FIELD_TRIALS. With WebRTC-Video-Pacing/factor:10/ the startup delay drops to ~4 ms, then ~0.
  • livekit: pre-encoded tracks are exempt from the starting-estimate cap, like screen share (peak ~510 ms → ~210–440 ms on its own).
  • encoded_publisher --stats logs codec, frames sent and pacer delay from standard WebRTC stats.

3. Trim the publish path

  • The length-prefixed parser copied each access unit twice and rescanned it byte by byte; it now converts in place and classifies from known NAL ranges: 321 µs → 3.4 µs per 1080p access unit. find_start_code also skips between 0x01 bytes, which speeds up every Annex-B path (an exhaustive test pins it to the old behaviour).
  • Pre-encoded video is published as a single layer. FFI clients such as the C++ SDK leave simulcast on by default; the pass-through then announced 360p/180p layers carrying copies of the full stream and requested a keyframe on almost every frame (352 requests in 15 s, vs 28 after).

Testing

  • cargo test -p livekit-capture --features source-gstreamer (53 pass), cargo test -p livekit --lib, cargo test -p webrtc-sys --lib.
  • Manual: gst-launch-1.0 videotestsrc ! nvcudah265enc ! h265parse config-interval=-1 ! video/x-h265,stream-format=hev1,alignment=au ! tcpserversink into encoded_publisher --single-slice-flush --stats against livekit-server --dev. Cross-checked end to end with a packet capture and a subscriber: steady-state publish latency p50 0.84 ms (Go SDK: 0.49 ms).

Notes

  • Tested on Linux with NVENC/NVDEC. On a host without a local H.265 encoder, H.265 also needs jetson h265: encoder #1451 to be negotiable.

🤖 Generated with Claude Code

haffi96 and others added 3 commits October 27, 2026 09:14
Pre-encoded H.265 could only be ingested as Annex-B, and a single-layer
pre-encoded H.265 track could not be published at all.

- livekit-capture: `LengthPrefixedAccessUnitParser` (was the test-only
  AVC parser) handles H.264 (avc3) and H.265 (hev1) length-prefixed
  streams, with an opt-in single-slice flush that completes an access
  unit as soon as its slice arrives instead of a frame later.
- livekit-capture: `access_unit_from_h265_hvcc` mirrors
  `access_unit_from_h264_avc`; the GStreamer source accepts `hev1` caps.
- livekit-capture: `frame_metadata_from_sei` reads the LKTS
  packet-trailer SEI (the Go SDK `ReaderTrackWithPacketTrailer`
  convention) for the pump's frame metadata callback.
- livekit: announce the codec of single-layer video tracks in
  AddTrackRequest. Without it the SFU answered with its default (VP8),
  so a pre-encoded H.265 track negotiated VP8 and the pass-through
  dropped every access unit. The Go SDK always sends it.
- local_video: `encoded_publisher` publishes a length-prefixed stream
  received over TCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
libwebrtc paces packets at 2.5x its bandwidth estimate, which starts at
1 Mbps for camera tracks. A pre-encoded stream arrives at the bitrate its
upstream encoder chose and cannot slow down, so for the first ~9 s frames
queued in the pacer: up to ~420 ms (Rust) / ~570 ms (C++) at p99 for a
1080p30 ~5 Mbps H.265 stream, and 16-50 ms after each keyframe in some
runs once the estimate settled.

- webrtc-sys: read field trials from LIVEKIT_WEBRTC_FIELD_TRIALS
  ("Name/Value/" format), taking precedence over the SDK's own. With
  "WebRTC-Video-Pacing/factor:10/" startup p99 drops to ~1.5-2.2 ms.
- livekit: exempt pre-encoded tracks from the 1 Mbps cap on
  x-google-start-bitrate, like screen share (halves the startup penalty
  on its own).
- local_video: `encoded_publisher --stats` logs the pacer delay per
  packet (totalPacketSendDelay / packetsSent), target bitrate and
  bandwidth estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- livekit-capture: the length-prefixed parser copied each access unit
  twice and rescanned it byte by byte for start codes to classify the
  frame (~205 us per 1080p access unit). It now converts in place during
  one copy and classifies from the NAL ranges it already has (~2.5 us).
  find_start_code jumps between 0x01 bytes instead of matching at every
  offset, which speeds up every Annex-B path; an exhaustive test pins it
  to the old behaviour. Steady-state publish latency p50 1.21 -> 0.81 ms.
- livekit: publish pre-encoded video without simulcast. FFI clients
  such as the C++ SDK leave simulcast on by default; the track then
  announced 360p/180p layers whose senders forwarded copies of the full
  stream, requested keyframes on almost every frame, and competed with
  the real layer in the pacer.
- local_video: `encoded_publisher` counts keyframe requests.

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 5 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

if let Some(split_at) = self.split_index()? {
return self.take_access_unit(split_at);
}
if at_eof && !self.nal_ranges.is_empty() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Trailing AUD becomes a bogus video frame

When an encoded stream ends with a trailing AUD, flush emits it as a delta frame without a picture. The pump forwards that frame to subscribers.

(Refers to this code)

Learn more

A length-prefixed NAL stream can end just after an access unit delimiter (AUD), which starts the next access unit but contains no slice. The new public parser treats any remaining complete NAL as a final access unit. EncodedVideoPump::run forwards it once it has seen the initial keyframe. This produces an extra video frame without picture data even when all input pictures were valid.

Example: With single-slice flush, push VPS, SPS, PPS, IDR, AUD, drain the IDR, then call flush() at EOF. The parser emits the AUD as a delta frame; the expected result is no remaining picture.

Recommended fix: Before flushing a pending unit, require a VCL NAL in its ranges and discard a non-VCL-only tail. Cover a trailing AUD with and without single-slice flush.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

impl TcpLengthPrefixedSource {
fn stream(&mut self) -> Result<&mut TcpStream, SourceError> {
if self.stream.is_none() {
let stream = TcpStream::connect(&self.addr).map_err(SourceError::new)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Publisher hangs during encoder connection

If the encoder address blackholes connections, TcpStream::connect blocks without checking the stop handle. Ctrl-C then waits for the pump thread until TCP times out.

Learn more

The pump opens the TCP socket on a dedicated blocking thread. A connect to a reachable address succeeds or fails promptly, but a blackholed address can keep the OS connect call blocked for much longer. main stops the pump on Ctrl-C and then awaits its thread; the stop flag cannot interrupt a blocking connect.

Example: Set --src to a routed address that silently drops SYN packets, then press Ctrl-C. The process waits for the OS connection timeout rather than exiting promptly.

Recommended fix: Resolve and connect with a short TcpStream::connect_timeout per attempt, checking PumpStop between attempts, or use a cancellable nonblocking connection process.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +254 to +256
if args.stats {
tokio::spawn(log_sender_stats(track));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Stats task outlives the published track

With --stats, tokio::spawn leaves an endless task holding the track after the pump stops. Neither cancellation nor joining precedes room shutdown.

Learn more

The stats loop in log_sender_stats has no exit condition. main discards its join handle and closes the room after the pump exits, while the task still holds a clone of the local track. The root AGENTS.md requires each spawned task to have a named owner and deterministic cancellation and join.

Example: Start the publisher with --stats, let the encoder stream end, and observe that the stats loop still exists when room.close() begins; only the Tokio runtime's subsequent shutdown stops it.

Recommended fix: Keep the stats task's JoinHandle in main, signal it to stop when the pump ends, and await its completion before closing the room.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

info!("Connected to encoder at {}", self.addr);
self.stream = Some(stream);
}
Ok(self.stream.as_mut().unwrap())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Socket setup violates the no-unwrap rule

The new stream method uses unwrap outside tests, despite the repository's explicit rule to avoid it. An invariant message is required if unwrapping cannot be avoided.

Suggested change
Ok(self.stream.as_mut().unwrap())
Ok(self.stream.as_mut().expect("TCP stream initialized above"))

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

fn now_us() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as i64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Pre-epoch clock panics the publisher

If the system clock precedes 1970, now_us panics while timestamping a frame. Its production unwrap also violates the repository's no-unwrap rule.

Learn more

SystemTime::duration_since(UNIX_EPOCH) returns an error for dates before 1970. next_access_unit stamps every emitted frame with now_us, so a misconfigured clock stops publishing through a pump-thread panic. The root AGENTS.md requires avoiding unwrap outside tests; the existing wall_clock_now_us shows a fallible clock-read pattern.

Example: If the host clock reads 1969-12-31, the first completed frame triggers a panic instead of being timestamped safely or returning an error.

Recommended fix: Handle duration_since without unwrap and define a safe nonzero fallback, as wall_clock_now_us does.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@haffi96
haffi96 marked this pull request as draft October 27, 2026 09:20
@haffi96

haffi96 commented Oct 27, 2026 •

Copy link
Copy Markdown
Author

The main motivation here is to enable the H265 pre-encoded ingest path with length prefixed NAL units more or less trying to mirror Livekit Go SDK pre-encoded publish path

Along the way, benchmarking the changes against a Go publisher client, I found that there was differences in the pacer logic in libwebrtc and some smaller gains in the publish path that could be made. I've addressed those here in a separate commits so they can easily be excluded if risky

This PR can probably be broken down but I chose to put them all in one as they go hand in hand for testing/verification. Happy to break this down into a smaller piece or whatever is preferable

This branch has not been deployed

No deployments
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.

1 participant