6.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
# Build
cargo build
# Run
cargo run
# Build (Nix)
nix build
# Dev shell (provides clang + mold linker)
nix develop
No tests exist yet.
Architecture
This is an RTMP-to-WHEP bridge: it accepts an RTMP video publish stream and re-streams it to browsers via WebRTC (using the WHEP signaling protocol).
Signal flow:
OBS/encoder → RTMP (port 8123) → H264Parser → async_broadcast channel
↓
Browser ← WebRTC/UDP ← str0m Rtc ← WHEP HTTP (port 5000)
RTMP ingestion (port 8123)
Each incoming TCP connection is handled in a detached smol task:
- Handshake — reads C0+C1 (1537 bytes) using
rml_rtmp::Handshake, sends S0+S1+S2, reads C2 (1536 bytes) to complete the handshake. - Session setup — creates an
rml_rtmp::ServerSessionand writes its initial response bytes to the stream. - Event loop — reads 4096-byte chunks and calls
rtmp_session.handle_input. The library returns a mix ofOutboundResponsepackets (written back immediately) andRaisedEventvalues:ConnectionRequested→ accepted unconditionally.PublishStreamRequested→ accepted only ifstream_key == "test", rejected otherwise. On accept, aStreamSessionholding anasync_broadcast::Sender<Arc<VideoFrame>>is inserted into theAppStateDashMap.VideoDataReceived→ checked for HEVC (bytes 1–4 ==hvc1; connection dropped if true), then passed toH264Parser::parse. If a frame is returned it is broadcast on the channel.PublishStreamFinished→ the entry is removed from theDashMap.
H.264 parsing (AVCC → Annex-B)
H264Parser processes the raw VideoDataReceived payload bytes:
- Byte 0: upper nibble = frame type (1 = keyframe), lower nibble = codec ID (7 = H.264; anything else is dropped).
- Byte 1: AVC packet type —
0= sequence header (AVCDecoderConfigurationRecord),1= NAL unit data. - Bytes 2–4: composition time offset (ignored).
- Bytes 5+: payload.
For packet type 0 the parser walks the AVCDecoderConfigurationRecord to cache the raw SPS and PPS byte arrays.
For packet type 1 the parser converts from AVCC (each NALU preceded by a 4-byte big-endian length) to Annex-B (each NALU preceded by the 00 00 00 01 start code). Before the first NALU of every keyframe it prepends the cached SPS and PPS in Annex-B form so that str0m's RTP packetizer can bundle them into a STAP-A alongside the IDR NALU.
WHEP signaling (port 5000)
The browser opens a RTCPeerConnection, adds a recvonly video transceiver, calls createOffer, and POSTs the SDP to POST /whep/test.
tiny_http (running in an OS thread) receives the request, sends the tuple ("", sdp_body) on offer_tx, then blocks on accept_rx waiting for the answer SDP. The 201 response with Content-Type: application/sdp is sent once the answer arrives.
WebRTC negotiation and media loop
The smol Webrtc task receives the offer from offer_rx:
- Binds a UDP socket to
127.0.0.1:0— this is the only ICE candidate advertised (host, UDP, loopback). Remote candidates coming from the browser are handled by str0m internally; the socket just needs to be reachable from the browser on the same machine. - Builds an
Rtcwith H.264 explicitly configured for payload types 102, 104, and 106 (profiles0x42e01f,0x4d001f,0x64001f). The default H.264 support is disabled first so only these three PTs are offered. - Adds a
SendOnlyvideo media track, then callschanges.accept_offer(offer_sdp)to produce the SDP answer. - Sends the answer back on
accept_tx(unblocking the HTTP thread), then detaches a per-connection async loop.
Per-connection loop (Webrtc::detach_connection):
- Calls
rtc.poll_output()in a tight loop until it returnsOutput::Timeout(deadline). Each iteration either sends a UDP datagram (Output::Transmit) or handles an event:Event::MediaAdded— iterates the writer's payload params and picks the PT with the highestprofile_level_id, storing it invideo_pt.Event::Connected— setsconnected = true; media sending begins after this.
- Once connected, on each iteration it lazily subscribes to the
async_broadcastchannel for stream key"test"(if not already subscribed), then drains all available frames withtry_recvand writes each one viawriter.write(pt, now, rtp_time, frame.data). Thertp_timeis constructed fromframe.timestamp_msasMediaTime::from_millis. - Then waits with
smol::future::orfor either the str0m deadline or a UDP datagram. Incoming datagrams are fed tortc.handle_input(Input::Receive(...))for ICE/DTLS/RTCP processing; a timeout firesrtc.handle_input(Input::Timeout(...)).
Module breakdown:
-
main.rs— Entry point. OwnsAppState(aDashMap<String, StreamSession>). Spawns the HTTP and WebRTC tasks, then loops accepting RTMP TCP connections. Each RTMP connection runs therml_rtmphandshake and session, parses H.264 frames viaH264Parser, and broadcasts them on a per-streamasync_broadcastchannel stored inAppState. -
src/http.rs— Blockingtiny_httpserver on port 5000 running in its own OS thread. Handles CORS and WHEPPOST /whep/*requests. Forwards the SDP offer to the WebRTC task viasmol::channeland blocks waiting for the SDP answer before responding. -
src/webrtc.rs— Async task (smol) that receives SDP offers, builds astr0mRtcinstance with H.264 codec config, negotiates the answer, and detaches a per-connection loop. That loop pollsstr0mfor output (packets to send), listens on a per-connection UDP socket for incoming DTLS/ICE, and drains theasync_broadcastvideo channel to write Annex-B frames into thestr0mwriter. -
src/media.rs—H264Parserconverts raw RTMPVideoDataReceivedpayloads (AVCC format) to Annex-B. Sequence-header packets (AVC packet type 0) update cached SPS/PPS; NAL unit packets (type 1) prepend SPS+PPS before each keyframe and convert length-prefixed NALUs to start-code NALUs. -
src/rtmp.rs— An early stub for a manual RTMP handshake implementation, unused in the current flow (the actual RTMP handling usesrml_rtmpdirectly inmain.rs).
Key design choices:
- Only the hardcoded stream key
"test"is accepted; other keys are rejected. - HEVC/H.265 is explicitly rejected at ingestion time.
async_broadcastchannels are overflow-enabled (old frames are silently dropped if no subscriber drains fast enough).- The runtime is
smol(not tokio). The HTTP server runs in a dedicated OS thread (std::thread::spawn) becausetiny_httpis synchronous. str0mhandles RTP packetization, DTLS, and ICE internally; the code only provides Annex-B video bytes and a UDP socket.
Test page: index.html — open in a browser to view the stream via WHEP without any extra tooling.