5.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.
Workspace layout
crates/
server/ — main binary (RTMP ingestion, HTTP API, WebRTC)
entity/ — SeaORM entity definitions
migration/ — SeaORM migrations
Architecture
This is an RTMP-to-WHEP bridge: accepts an RTMP video publish stream and re-streams it to browsers via WebRTC (WHEP signaling protocol).
Signal flow:
OBS/encoder → RTMP (port 8123) → H264Parser → async_broadcast channel
↓
Browser ← WebRTC/UDP ← str0m Rtc ← WHEP HTTP (port 3000)
Runtime: tokio (not smol). The HTTP server runs as a tokio task using hyper.
RTMP ingestion (port 8123) — crates/server/src/main.rs
Each incoming TCP connection is handled in a detached tokio task:
- Handshake — reads C0+C1 (1537 bytes) via
rml_rtmp::Handshake, sends S0+S1+S2, reads C2 (1536 bytes). - Session setup — creates an
rml_rtmp::ServerSession, writes its initial response bytes. - Event loop — reads 4096-byte chunks, calls
rtmp_session.handle_input. Handles:ConnectionRequested→ accepted unconditionally.PublishStreamRequested→ accepted for any stream key; aStreamSessionwith anasync_broadcast::Sender<Arc<VideoFrame>>is inserted intoAppState.VideoDataReceived→ HEVC check (bytes 1–4 ==hvc1; drops connection if true), then passed toH264Parser::parse. Parsed frames are broadcast on the channel.PublishStreamFinished→ entry removed fromAppState.
AppState is a Arc<Mutex<AppState>> wrapping a DashMap<String, StreamSession>.
HTTP API (port 3000) — crates/server/src/http.rs
Async hyper server running in a tokio task. Routes:
GET /api/catalog— returns JSON{ active_streams: [String] }listing currently publishing stream keys.GET /api/meow— returns"meow"(health check / placeholder).- WHEP signaling is not yet wired into this HTTP server (see webrtc.rs for the channel plumbing).
SDP offer/answer exchange uses tokio::sync::mpsc channels between HttpServer and the Webrtc task.
WebRTC negotiation and media loop — crates/server/src/webrtc.rs
The Webrtc task receives (stream_key, sdp_body) tuples from offer_rx:
- Binds a UDP socket to
127.0.0.1:0— only ICE candidate advertised (host, UDP, loopback). - Builds
str0m::Rtcwith H.264 explicitly configured for PTs 102, 104, 106 (profiles0x42e01f,0x4d001f,0x64001f). Default H.264 support is disabled first. - Adds a
SendOnlyvideo media track, callschanges.accept_offer(offer_sdp)to produce the SDP answer. - Sends the answer back on
accept_tx, then spawns a per-connection tokio task.
Per-connection loop (Webrtc::detach_connection):
- Drains
rtc.poll_output()untilOutput::Timeout. Each iteration sends UDP datagrams (Output::Transmit) or handles events:Event::MediaAdded— picks the PT with the highestprofile_level_id, stores invideo_pt.Event::Connected— setsconnected = true; media sending begins.
- When connected, lazily subscribes to the
async_broadcastchannel for the stream key, then drains up to 8 frames per iteration viatry_recv, writing each withwriter.write(pt, now, rtp_time, frame.data).rtp_timeis computed asMediaTime::from_90khz(timestamp_ms * 90). - Waits (capped at 20 ms) with
tokio::select!for either the str0m deadline or a UDP datagram. Incoming datagrams are fed tortc.handle_input(Input::Receive(...)).
H.264 parsing — crates/server/src/media.rs
H264Parser converts raw RTMP VideoDataReceived payloads (AVCC) to Annex-B:
- Byte 0: upper nibble = frame type (1 = keyframe), lower nibble = codec ID (7 = H.264; anything else dropped).
- Byte 1: AVC packet type —
0= sequence header,1= NAL unit data. - Bytes 5+: payload.
Packet type 0 walks AVCDecoderConfigurationRecord to cache SPS and PPS byte arrays.
Packet type 1 converts AVCC (4-byte big-endian length prefix per NALU) to Annex-B (00 00 00 01 start code). Before the first NALU of every keyframe, prepends SPS+PPS in Annex-B form.
Unused stub — crates/server/src/rtmp.rs
Early manual RTMP handshake implementation, not used in the current flow.
Database (SeaORM + SQLite)
DB file: stream.db. Migrations: crates/migration/. Entities: crates/entity/.
Run migrations: Migrator::up(&db, None).await? — idempotent, tracked in seaql_migrations.
Entities
users (crates/entity/src/users.rs)
- Fields:
id,username,hashed_password - Relations:
has_many→stream_key Entity::create(db, username, hashed_password)— inserts a new userActiveModel::update_username(db, username)— updates usernameActiveModel::update_password(db, hashed_password)— updates password hash- Passwords must be hashed before being passed to these methods
stream_key (crates/entity/src/stream_key.rs)
- Fields:
id,key_value(unique),user_id,label,is_active,is_unlisted,created_at - Relations:
belongs_to→users,has_many→stream_session
stream_session (crates/entity/src/stream_session.rs)
- Fields:
id,stream_key_id,started_at,ended_at(nullable) - Relations:
belongs_to→stream_key
SeaORM conventions
- Query methods go on
Entity(e.g.Entity::find_by_x). - Mutation helpers that intercept save logic (e.g. setting timestamps, pre-save transforms) go on
ActiveModel. - For destructive schema changes in prod, use expand-contract: add new structure → backfill → switch app code → drop old structure in a later migration.
Test page: index.html — open in a browser to view the stream via WHEP without any extra tooling.