Files
simple-instant-stream/CLAUDE.md
T
2026-06-22 15:19:46 +01:00

5.9 KiB
Raw Blame History

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:

  1. Handshake — reads C0+C1 (1537 bytes) via rml_rtmp::Handshake, sends S0+S1+S2, reads C2 (1536 bytes).
  2. Session setup — creates an rml_rtmp::ServerSession, writes its initial response bytes.
  3. Event loop — reads 4096-byte chunks, calls rtmp_session.handle_input. Handles:
    • ConnectionRequested → accepted unconditionally.
    • PublishStreamRequested → accepted for any stream key; a StreamSession with an async_broadcast::Sender<Arc<VideoFrame>> is inserted into AppState.
    • VideoDataReceived → HEVC check (bytes 14 == hvc1; drops connection if true), then passed to H264Parser::parse. Parsed frames are broadcast on the channel.
    • PublishStreamFinished → entry removed from AppState.

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:

  1. Binds a UDP socket to 127.0.0.1:0 — only ICE candidate advertised (host, UDP, loopback).
  2. Builds str0m::Rtc with H.264 explicitly configured for PTs 102, 104, 106 (profiles 0x42e01f, 0x4d001f, 0x64001f). Default H.264 support is disabled first.
  3. Adds a SendOnly video media track, calls changes.accept_offer(offer_sdp) to produce the SDP answer.
  4. Sends the answer back on accept_tx, then spawns a per-connection tokio task.

Per-connection loop (Webrtc::detach_connection):

  • Drains rtc.poll_output() until Output::Timeout. Each iteration sends UDP datagrams (Output::Transmit) or handles events:
    • Event::MediaAdded — picks the PT with the highest profile_level_id, stores in video_pt.
    • Event::Connected — sets connected = true; media sending begins.
  • When connected, lazily subscribes to the async_broadcast channel for the stream key, then drains up to 8 frames per iteration via try_recv, writing each with writer.write(pt, now, rtp_time, frame.data). rtp_time is computed as MediaTime::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 to rtc.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_manystream_key
  • Entity::create(db, username, hashed_password) — inserts a new user
  • ActiveModel::update_username(db, username) — updates username
  • ActiveModel::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_tousers, has_manystream_session

stream_session (crates/entity/src/stream_session.rs)

  • Fields: id, stream_key_id, started_at, ended_at (nullable)
  • Relations: belongs_tostream_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.