diff --git a/CHANGELOG.md b/CHANGELOG.md index e25a7fa..734805b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,27 @@ begin at `1.0.0`. ## [Unreleased] +## [0.4.0] — 2026-07-15 + ### Added +- Server-side E-RTMP v2 connect negotiation on the live session path: + `fourCcList`, numeric `capsEx` capability bitmask, `videoFourCcInfoMap`, + `reconnect`, and NetConnection `_error` responses when capability negotiation + fails. +- Multitrack media support (E-RTMP v2 `AudioPacketType::Multitrack` / + `VideoPacketType::Multitrack`): opaque relay of full multitrack messages, + per-track `on_frame_cb` delivery with `Frame.track_id`, and init-cache replay + of multitrack sequence-start headers to late-joining players. +- Enhanced init-frame classification via `exvideo`/`exaudio` parsers (HEVC, AV1, + Opus, AAC) in `media/init_cache.rs`, replacing legacy nibble-only detection. +- `onMetaData` script caching in `StreamCache` and replay to players that join + after the publisher has already sent metadata. +- Client receive path for AMF0/AMF3 `onMetaData` and RTMP Aggregate messages + (`0x16`), unpacking sub-tags into the normal A/V and script frame callbacks. +- Legacy RTMP commands on the server session path: `pause`, `seek`, + `receiveAudio`, `receiveVideo`, and `closeStream`. +- User Control message handling for `StreamBegin`, `StreamEOF`, and + `SetBufferLength`; AMF3 Shared Object messages are accepted as no-ops. - `examples/dump_frames.rs` — play a stream and print one line per received frame (type, timestamp, size, codec details) for debugging live publishers. - libFuzzer harnesses under `fuzz/` for chunk reading, handshake parsing, AMF0 @@ -23,9 +43,41 @@ begin at `1.0.0`. libFuzzer smoke runs). ### Changed +- The built-in relay fan-out budget is configurable through + `Server::max_relay_sends_per_poll` (default: 4096 sends per poll). The first + eligible frame in a poll is always processed even when its audience exceeds + the budget, preventing an oversized fan-out frame from being re-queued forever. +- Connect AMF helpers now parse and write E-RTMP v2 capability representations, + including wildcard FourCC entries, numeric `capsEx`, and per-codec + `videoFourCcInfoMap` masks. The built-in client continues to connect without + advertising capabilities; negotiation is active on the server session path. +- `Frame` now carries populated codec/header fields (`video_fourcc`, `audio_fourcc`, + composition time, etc.) and optional `track_id` for multitrack callbacks. +- Corrected E-RTMP audio/video packet-type constants for sequence-end, + multichannel, and multitrack values. - Removed `docs/roadmap.md`; release status lives in `README.md` and `CHANGELOG.md`. +### Fixed +- ModEx prefix bytes are normalized only when the ModEx capability was negotiated + and the leading bytes form an unambiguous ModEx wrapper; enhanced + ExVideo/ExAudio, legacy AAC, and multitrack tags are preserved. Relay always + forwards the original payload. +- Aggregate processing preserves relative sub-tag timestamps for A/V and metadata, + rejects malformed or truncated payloads, and routes script tags through the + normal metadata path. +- Re-enabling `receiveAudio` or `receiveVideo` schedules init-cache replay; + paused players and disabled media types are filtered during relay. +- Enhanced `CodedFramesX` keyframes and multitrack sequence headers are classified + correctly for late-join cache replay. +- Complex RTMP handshake (digest/HMAC) is not implemented; peers requesting it + still receive a legacy simple S1/S2 response so ffmpeg and similar clients connect. + +### Security +- Bound E-RTMP capability blobs to 4 KiB and Aggregate messages to 4096 sub-tags + on both server and client receive paths; stream-cache resource accounting now + includes metadata and per-track headers. + ## [0.3.1] — 2026-07-13 ### Fixed @@ -245,7 +297,8 @@ and others. - Protocol mapping documents for legacy, E-RTMP v1, and E-RTMP v2 - `CONTRIBUTING.md` guidelines -[Unreleased]: https://github.com/OpenRTMP/librtmp2/compare/v0.3.1...HEAD +[Unreleased]: https://github.com/OpenRTMP/librtmp2/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/OpenRTMP/librtmp2/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/OpenRTMP/librtmp2/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/OpenRTMP/librtmp2/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/OpenRTMP/librtmp2/compare/v0.2.0...v0.2.1 diff --git a/Cargo.toml b/Cargo.toml index 3fdcc0b..a81abd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librtmp2" -version = "0.3.1" +version = "0.4.0" edition = "2024" rust-version = "1.93" authors = ["OpenRTMP ", "AlexanderWagnerDev "] diff --git a/README.md b/README.md index 8b9b76b..b5ffee9 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,20 @@ A modern, open-source **Rust library** for Legacy RTMP and Enhanced RTMP v1/v2. ## Overview -`librtmp2` implements the lowest protocol layer of RTMP: handshake, chunking, AMF, commands, audio/video tags, Enhanced RTMP extensions, state machine, and a clean callback API. +`librtmp2` is a Rust RTMP protocol library: handshake, chunking, AMF commands, live publish/play relay, and an `extern "C"` FFI layer. It is meant to be embedded in custom servers, clients, and relay tools. -It is designed to be embedded into custom servers, clients, relay tools, OBS/FFmpeg integrations, or any future product that needs a solid RTMP foundation. - -**What it is:** -- A complete Legacy RTMP implementation (handshake, chunk streams, message reassembly, commands, control messages) -- E-RTMP v1 support: ExVideoTagHeader, FourCC codecs (`hvc1`, `av01`, `vp09`), HDR metadata -- E-RTMP v2 support: capability negotiation (`capsEx`, `videoFourCcInfoMap`), reconnect, multitrack, ModEx -- An idiomatic Rust API plus an `extern "C"` FFI layer for use from C, Go, Python, PHP, and others +**What it is good at today:** +- Live ingest and relay for OBS/FFmpeg-style workflows (H.264/AAC and Enhanced-RTMP passthrough for HEVC/AV1) +- Minimal RTMP server and client (`connect` → `createStream` → `publish` / `play`) - RTMPS (RTMP over TLS) via the optional `tls` Cargo feature (OpenSSL), enabled by default +- Parser/serializer modules for E-RTMP v1/v2 structures (usable from embedders; not all are wired into the session layer yet) **What it is not:** -- Not an HTTP server or web UI -- Not a media server with business logic -- Not a push-relay to third-party platforms -- Not an FFmpeg wrapper +- Not a complete Adobe RTMP 1.0 implementation (no VOD commands, shared objects, encrypted handshake, etc.) +- Not a full E-RTMP v2 session stack (`capsEx` negotiation, reconnect, multitrack, ModEx are library code only today) +- Not an HTTP server, media policy layer, or FFmpeg wrapper + +See [Implementation status](#implementation-status) for the code-accurate breakdown. --- @@ -39,15 +37,14 @@ OBS / FFmpeg / App │ ▼ librtmp2 ← this library - ├── Handshake - ├── Chunking - ├── AMF (AMF0 / AMF3) - ├── RTMP Commands - ├── E-RTMP v1 - └── E-RTMP v2 + ├── Handshake / Chunking / Control + ├── AMF0 commands (connect, publish, play, …) + ├── Session relay (publisher → players) + ├── ertmp/ + flv/ parsers (library modules) + └── RTMPS transport (optional) ``` -The library processes bytes, frames, commands, and protocol states. What a host program does with them is decided entirely via callbacks and configuration structures. +The live path is implemented in `session/conn.rs` and `server/mod.rs`. Parser modules under `ertmp/` and `flv/` can be used directly by embedders even when they are not yet called from the default session flow. --- @@ -57,14 +54,15 @@ The library processes bytes, frames, commands, and protocol states. What a host TCP_ACCEPTED → HANDSHAKE → CONNECTED - → CAPS_NEGOTIATED (E-RTMP v2) - → APP_CONNECTED + → APP_CONNECTED ← connect today skips CAPS_NEGOTIATED → STREAM_CREATED → PUBLISHING | PLAYING → CLOSING → CLOSED ``` +`CAPS_NEGOTIATED` exists in `ConnState` for a future E-RTMP v2 capability exchange but is not entered by the current session code. + --- ## Build @@ -168,16 +166,14 @@ librtmp2/ │ ├── buffer.rs Growable byte buffers │ ├── bytes.rs Big-endian byte helpers │ ├── chunk.rs Chunk reader/writer/state (per-csid) -│ ├── client.rs Outbound client: connect → publish/play -│ ├── ertmp.rs E-RTMP v1/v2 extensions -│ ├── flv.rs FLV audio/video/script tag parsing +│ ├── client/ Outbound client: connect → publish/play +│ ├── ertmp/ E-RTMP v1/v2 parsers (see Implementation status) +│ ├── flv/ FLV tag parsers (library; not used in live relay path) │ ├── handshake.rs C0/C1/C2 ↔ S0/S1/S2 -│ ├── log.rs Logging -│ ├── message.rs Message reassembly, control, commands -│ ├── net.rs Network helpers -│ ├── server.rs Listening socket, accept loop, per-connection poll -│ ├── session.rs Connection state machine, stream bookkeeping -│ ├── transport.rs TLS/plaintext transport abstraction +│ ├── message/ Message reassembly, control, commands +│ ├── server/ Listening socket, accept loop, relay +│ ├── session/ Connection state, publish/play handling +│ ├── transport.rs TLS/plaintext transport │ └── types.rs Shared types ├── tests/ │ ├── interop/ @@ -189,19 +185,58 @@ librtmp2/ --- -## Roadmap - -| Feature | Status | -|---------|--------| -| Legacy RTMP server — minimal | Implemented | -| Legacy RTMP client — minimal | Implemented | -| E-RTMP v1 receive (HEVC/AV1 detection) | Implemented | -| E-RTMP v1 send | Implemented | -| E-RTMP v2 capability layer | Implemented | -| Multitrack / reconnect / ModEx | Implemented | -| RTMPS (TLS) | Implemented | -| End-to-end test suites | In progress | -| Performance benchmarks | Planned | +## Implementation status + +Status reflects what is **wired into the live session path** (`conn.rs`, `server/mod.rs`, `client/mod.rs`), not merely what exists as parser code elsewhere in the repo. + +### Legacy RTMP — live path + +| Area | Status | +|------|--------| +| Standard handshake (C0–C2) | Done | +| Encrypted / Adobe-digest handshake | Not implemented | +| Chunking, control messages, ping | Done | +| Commands `connect`, `createStream`, `publish`, `play` | Done | +| Commands `pause`, `seek`, `receiveAudio`, `receiveVideo`, `closeStream` | Not implemented | +| `FCPublish` / `releaseStream` | Ignored (no-op) | +| `FCUnpublish` / `deleteStream` | Partial (publish route cleanup only) | +| Audio / video ingest and relay | Done | +| Aggregate messages | Done (unpack → relay) | +| Publisher `onMetaData` parsing (stats) | Done — not relayed to players | +| AMF3 shared objects | Not implemented | +| User Control `StreamBegin` / `StreamEOF` / `SetBufferLength` | Encode/decode helpers only — not sent or handled in session | +| One stream per connection (`current_stream`) | By design today | +| Init-frame cache for late joiners | Legacy H.264 (`0x17`) + AAC only | + +### E-RTMP v1 + +| Area | Status | +|------|--------| +| Enhanced A/V passthrough (HEVC/AV1/Opus from FFmpeg/OBS) | Done (opaque byte relay) | +| `exvideo_parse` / `exaudio_parse` in session hot path | Not wired — codec detection uses lightweight heuristics | +| `fourCcList` in `connect` | Skipped on read; not sent on connect | +| HDR / `colorInfo` (`metadata.rs`) | Parser only | +| Enhanced sequence-start cache for players | Not implemented (see init-frame cache above) | +| `exvideo_write` / `exaudio_write` helpers | Not present — send raw enhanced payloads via `send_frame` | + +### E-RTMP v2 + +| Area | Status | +|------|--------| +| `capsEx`, `videoFourCcInfoMap`, `reconnect`, `multitrack`, `modex` parse/write | Library code + unit tests | +| v2 capability negotiation in session | Not implemented | +| Reconnect / multitrack / ModEx in session | Not implemented | + +### Client, TLS, tests + +| Area | Status | +|------|--------| +| Minimal publish client | Done | +| Minimal play client (A/V receive callback) | Done — no metadata/aggregate on play | +| RTMPS | Done | +| Loopback + FFmpeg interop tests | Present (`tests/`, `tests/interop/`) | + +Parser-level details and spec mappings: [`docs/protocol-mapping-legacy.md`](docs/protocol-mapping-legacy.md), [`docs/protocol-mapping-ertmp-v1.md`](docs/protocol-mapping-ertmp-v1.md), [`docs/protocol-mapping-ertmp-v2.md`](docs/protocol-mapping-ertmp-v2.md). --- diff --git a/benches/protocol.rs b/benches/protocol.rs index d390c6c..41d7486 100644 --- a/benches/protocol.rs +++ b/benches/protocol.rs @@ -1,6 +1,6 @@ use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; use librtmp2::buffer::Buffer; -use librtmp2::chunk::reader::{chunk_read, ChunkMessage}; +use librtmp2::chunk::reader::{ChunkMessage, chunk_read}; use librtmp2::chunk::state::ChunkRegistry; use librtmp2::chunk::writer::chunk_write; use librtmp2::ertmp::fourcc; @@ -67,6 +67,7 @@ fn bench_amf_connect(c: &mut Criterion) { "FMLE/3.0", 3191, 252, + None, ) .unwrap(); black_box(buf.write_pos()) diff --git a/benches/relay.rs b/benches/relay.rs index 75baead..e7be230 100644 --- a/benches/relay.rs +++ b/benches/relay.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::{Duration, Instant}; -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use librtmp2::client::Client; use librtmp2::server::Server; use librtmp2::types::*; @@ -35,9 +35,7 @@ fn relay_n_frames(frame_count: u32) { tls_insecure: 0, }) .unwrap(); - server - .listen(&format!("127.0.0.1:{RTMP_PORT}")) - .unwrap(); + server.listen(&format!("127.0.0.1:{RTMP_PORT}")).unwrap(); server.on_publish_cb = Some(allow); server.on_play_cb = Some(allow); @@ -74,14 +72,13 @@ fn relay_n_frames(frame_count: u32) { video_fourcc: FourCc::default(), video_frame_type: 1, is_metadata: 0, + track_id: u8::MAX, }; publisher.send_frame(&frame).unwrap(); } let deadline = Instant::now() + Duration::from_secs(10); - while RECEIVED.load(Ordering::SeqCst) < frame_count as usize - && Instant::now() < deadline - { + while RECEIVED.load(Ordering::SeqCst) < frame_count as usize && Instant::now() < deadline { player.poll(10).unwrap(); } }); diff --git a/debian/changelog b/debian/changelog index 050673c..3f99832 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,18 @@ +librtmp2 (0.4.0-1) UNRELEASED; urgency=medium + + * Server-side E-RTMP v2 connect negotiation (fourCcList, capsEx bitmask, + videoFourCcInfoMap, reconnect) with capability-aware multitrack relay, + per-track callbacks, and init-cache replay for late-joining players. + * Enhanced init-frame classification for HEVC, AV1, Opus, AAC, and + CodedFramesX keyframes; onMetaData caching and timestamp-preserving relay. + * Client AMF0/AMF3 metadata and Aggregate receive callbacks, populated + codec/track fields, and legacy pause/seek/receiveAudio/receiveVideo/ + closeStream command handling on the server session path. + * Correct E-RTMP packet-type constants and per-codec capability masks; + bound capability blobs, Aggregate sub-tags, and multitrack cache storage. + + -- OpenRTMP Wed, 15 Jul 2026 00:00:00 +0000 + librtmp2 (0.3.1-1) UNRELEASED; urgency=medium * Harden DNS resolution, RTMP ping keepalive, and publishing send-path diff --git a/examples/dump_frames.rs b/examples/dump_frames.rs index 4293330..215cd01 100644 --- a/examples/dump_frames.rs +++ b/examples/dump_frames.rs @@ -21,7 +21,11 @@ static FRAME_COUNT: AtomicUsize = AtomicUsize::new(0); static MAX_FRAMES: AtomicUsize = AtomicUsize::new(usize::MAX); fn fourcc_str(fourcc: &FourCc) -> String { - let end = fourcc.cc.iter().position(|&b| b == 0).unwrap_or(fourcc.cc.len()); + let end = fourcc + .cc + .iter() + .position(|&b| b == 0) + .unwrap_or(fourcc.cc.len()); String::from_utf8_lossy(&fourcc.cc[..end]).into_owned() } @@ -138,14 +142,17 @@ fn main() -> ExitCode { eprintln!("[dump_frames] play failed"); return ExitCode::from(1); } - eprintln!("[dump_frames] playing (timeout={timeout_s}s, max_frames={})", { - let cap = MAX_FRAMES.load(Ordering::SeqCst); - if cap == usize::MAX { - "unlimited".to_string() - } else { - cap.to_string() + eprintln!( + "[dump_frames] playing (timeout={timeout_s}s, max_frames={})", + { + let cap = MAX_FRAMES.load(Ordering::SeqCst); + if cap == usize::MAX { + "unlimited".to_string() + } else { + cap.to_string() + } } - }); + ); let deadline = Instant::now() + Duration::from_secs(timeout_s); while Instant::now() < deadline { diff --git a/fuzz/fuzz_targets/chunk_read.rs b/fuzz/fuzz_targets/chunk_read.rs index 6dd1e3e..c1fca76 100644 --- a/fuzz/fuzz_targets/chunk_read.rs +++ b/fuzz/fuzz_targets/chunk_read.rs @@ -2,7 +2,7 @@ use libfuzzer_sys::fuzz_target; use librtmp2::buffer::Buffer; -use librtmp2::chunk::reader::{chunk_read, ChunkMessage}; +use librtmp2::chunk::reader::{ChunkMessage, chunk_read}; use librtmp2::chunk::state::ChunkRegistry; fuzz_target!(|data: &[u8]| { diff --git a/fuzz/fuzz_targets/handshake_server.rs b/fuzz/fuzz_targets/handshake_server.rs index 56747f9..521dacb 100644 --- a/fuzz/fuzz_targets/handshake_server.rs +++ b/fuzz/fuzz_targets/handshake_server.rs @@ -2,7 +2,7 @@ use libfuzzer_sys::fuzz_target; use librtmp2::buffer::Buffer; -use librtmp2::handshake::{server_init, server_read_c0, server_read_c1, server_read_c2, Handshake}; +use librtmp2::handshake::{Handshake, server_init, server_read_c0, server_read_c1, server_read_c2}; fuzz_target!(|data: &[u8]| { let mut hs = Handshake::default(); diff --git a/src/amf/amf0.rs b/src/amf/amf0.rs index b42bbdf..8e9bc5a 100644 --- a/src/amf/amf0.rs +++ b/src/amf/amf0.rs @@ -130,7 +130,12 @@ pub fn write_string(buf: &mut Buffer, s: &str) -> Result<()> { /// Write an AMF0 long string (4-byte length + UTF-8). pub fn write_long_string(buf: &mut Buffer, s: &str) -> Result<()> { - let len = s.len(); + write_long_string_bytes(buf, s.as_bytes()) +} + +/// Write an AMF0 long string from raw bytes. +pub fn write_long_string_bytes(buf: &mut Buffer, data: &[u8]) -> Result<()> { + let len = data.len(); if len > u32::MAX as usize { return Err(ErrorCode::Amf); } @@ -138,7 +143,7 @@ pub fn write_long_string(buf: &mut Buffer, s: &str) -> Result<()> { .map_err(|_| ErrorCode::Internal)?; buf.write(&(len as u32).to_be_bytes()) .map_err(|_| ErrorCode::Internal)?; - buf.write(s.as_bytes()).map_err(|_| ErrorCode::Internal)?; + buf.write(data).map_err(|_| ErrorCode::Internal)?; Ok(()) } diff --git a/src/chunk/reader.rs b/src/chunk/reader.rs index e6ea48e..4cf5524 100644 --- a/src/chunk/reader.rs +++ b/src/chunk/reader.rs @@ -4,9 +4,7 @@ use crate::buffer::Buffer; use crate::bytes::{hton24, ntoh32}; -use crate::chunk::state::{ - ChunkRegistry, ChunkStream, DEFAULT_CHUNK_SIZE, -}; +use crate::chunk::state::{ChunkRegistry, ChunkStream, DEFAULT_CHUNK_SIZE}; use crate::types::ErrorCode; use crate::types::Result; @@ -258,10 +256,7 @@ pub fn chunk_read( // After a complete message, reassembly_bytes_read is 0 but inherited // header fields remain — that is valid for the next constant-size frame // (common for AAC) or a fmt=3 header reuse per RTMP spec. - if (fmt == 2 || fmt == 3) - && stream.reassembly_bytes_read == 0 - && stream.type0_msg_length == 0 - { + if (fmt == 2 || fmt == 3) && stream.reassembly_bytes_read == 0 && stream.type0_msg_length == 0 { return Err(ErrorCode::Chunk); } @@ -341,7 +336,9 @@ pub fn chunk_read( // invalidate a pointer into its storage. Reuse last_payload's // allocation to avoid allocator churn on every complete message. stream.last_payload.clear(); - stream.last_payload.extend_from_slice(stream.reassembly_buf.peek()); + stream + .last_payload + .extend_from_slice(stream.reassembly_buf.peek()); *payload_len = stream.last_payload.len(); *payload = stream.last_payload.as_ptr(); @@ -435,14 +432,7 @@ mod tests { let mut next = Buffer::new(); next.write(&fmt3_wire(3, b"again")).expect("fmt3 wire"); - let result = chunk_read( - &mut next, - &mut reg, - None, - &mut out_msg, - &mut ptr, - &mut len, - ); + let result = chunk_read(&mut next, &mut reg, None, &mut out_msg, &mut ptr, &mut len); assert_eq!(result.unwrap(), 1); assert!(out_msg.is_complete); @@ -471,28 +461,13 @@ mod tests { let mut out_msg = ChunkMessage::default(); let mut ptr = std::ptr::null(); let mut len = 0; - chunk_read( - &mut wire, - &mut reg, - None, - &mut out_msg, - &mut ptr, - &mut len, - ) - .unwrap(); + chunk_read(&mut wire, &mut reg, None, &mut out_msg, &mut ptr, &mut len).unwrap(); assert!(out_msg.is_complete); let mut next = Buffer::new(); next.write(&[2 << 6 | 5, 0, 0, 1]).unwrap(); next.write(b"next").unwrap(); - let result = chunk_read( - &mut next, - &mut reg, - None, - &mut out_msg, - &mut ptr, - &mut len, - ); + let result = chunk_read(&mut next, &mut reg, None, &mut out_msg, &mut ptr, &mut len); assert_eq!(result.unwrap(), 1); assert!(out_msg.is_complete); assert_eq!(out_msg.msg_type_id, 0x08); @@ -527,14 +502,20 @@ mod tests { fmt1.write(&[1 << 6 | 5, 0, 0, 33, 0, 0, 4, 0x08]).unwrap(); fmt1.write(b"next").unwrap(); chunk_read(&mut fmt1, &mut reg, None, &mut out_msg, &mut ptr, &mut len).unwrap(); - assert_eq!(out_msg.timestamp, 1033, "fmt=1 timestamp must add the delta to the running total"); + assert_eq!( + out_msg.timestamp, 1033, + "fmt=1 timestamp must add the delta to the running total" + ); // fmt=2 header: timestamp(3)=33 (delta only; length/type inherited). let mut fmt2 = Buffer::new(); fmt2.write(&[2 << 6 | 5, 0, 0, 33]).unwrap(); fmt2.write(b"next").unwrap(); chunk_read(&mut fmt2, &mut reg, None, &mut out_msg, &mut ptr, &mut len).unwrap(); - assert_eq!(out_msg.timestamp, 1066, "fmt=2 timestamp must add the delta to the running total"); + assert_eq!( + out_msg.timestamp, 1066, + "fmt=2 timestamp must add the delta to the running total" + ); // fmt=3 starting a brand new message (no message currently in // flight) implicitly repeats the last delta (33) per RTMP spec @@ -561,14 +542,7 @@ mod tests { let mut out_msg = ChunkMessage::default(); let mut ptr = std::ptr::null(); let mut len = 0; - let result = chunk_read( - &mut next, - &mut reg, - None, - &mut out_msg, - &mut ptr, - &mut len, - ); + let result = chunk_read(&mut next, &mut reg, None, &mut out_msg, &mut ptr, &mut len); assert!(matches!(result, Err(ErrorCode::Chunk))); } @@ -695,8 +669,7 @@ mod tests { let mut out_msg = ChunkMessage::default(); loop { - let (rc, owned) = - chunk_read_owned(&mut wire, &mut reg, &mut out_msg).unwrap(); + let (rc, owned) = chunk_read_owned(&mut wire, &mut reg, &mut out_msg).unwrap(); if rc == 1 { assert_eq!(owned.len(), payload.len()); break; @@ -716,4 +689,4 @@ mod tests { "chunk_read_owned must not retain completed payloads in last_payload" ); } -} \ No newline at end of file +} diff --git a/src/chunk/state.rs b/src/chunk/state.rs index d9eef2b..331b4c5 100644 --- a/src/chunk/state.rs +++ b/src/chunk/state.rs @@ -236,7 +236,7 @@ mod tests { #[test] fn completed_messages_do_not_pin_multi_megabyte_reassembly_capacity() { - use crate::chunk::reader::{chunk_read, ChunkMessage}; + use crate::chunk::reader::{ChunkMessage, chunk_read}; use crate::chunk::writer::chunk_write; let payload = vec![0xAB_u8; 256 * 1024]; @@ -292,4 +292,4 @@ mod tests { assert!(stream.last_payload.is_empty()); assert_eq!(stream.last_payload.capacity(), 0); } -} \ No newline at end of file +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 50514a5..427b350 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -4,14 +4,16 @@ use std::net::{TcpStream, ToSocketAddrs}; use std::os::unix::io::IntoRawFd; -use std::sync::{mpsc, Mutex}; +use std::sync::{Mutex, mpsc}; use std::time::{Duration, Instant}; use crate::buffer::Buffer; -use crate::chunk::reader::{chunk_read_owned, ChunkMessage}; +use crate::chunk::reader::{ChunkMessage, chunk_read_owned}; use crate::chunk::state::{ChunkRegistry, DEFAULT_MAX_MSG_LENGTH}; use crate::chunk::writer::chunk_write; +use crate::ertmp::multitrack_media::foreach_track; use crate::handshake::{self, Handshake}; +use crate::media::{is_on_metadata_payload, populate_av_frame, populate_multitrack_frame}; use crate::message::command; use crate::message::control; use crate::message::message as msg_dispatch; @@ -19,6 +21,9 @@ use crate::net; use crate::transport::Transport; use crate::types::*; +/// Cap aggregate sub-tags per message (matches server-side limit). +const MAX_AGGREGATE_SUBTAGS: usize = 4096; + /// Handshake payload size (mirrors `handshake::HANDSHAKE_SIZE`, which is private). const HANDSHAKE_SIZE: usize = 1536; @@ -64,7 +69,8 @@ const FIRST_CHUNK_EXTRA_OVERHEAD_BYTES: usize = 11; /// two max-size messages at the minimum practical chunk size don't get /// rejected before they can be reassembled. const MAX_RECV_BUFFER_BYTES: usize = MAX_RECV_BUFFER_PAYLOAD_BYTES - + (MAX_RECV_BUFFER_PAYLOAD_BYTES / MIN_PRACTICAL_CHUNK_SIZE + 1) * MAX_CHUNK_HEADER_OVERHEAD_BYTES + + (MAX_RECV_BUFFER_PAYLOAD_BYTES / MIN_PRACTICAL_CHUNK_SIZE + 1) + * MAX_CHUNK_HEADER_OVERHEAD_BYTES + 2 * FIRST_CHUNK_EXTRA_OVERHEAD_BYTES; /// Cap inbound Ping-Request reflections to prevent trivial outbound /// bandwidth/CPU amplification from a malicious RTMP(S) server. @@ -287,7 +293,17 @@ impl Client { let scheme = if use_tls { "rtmps" } else { "rtmp" }; let tc_url = format!("{scheme}://{host}:{port}/{app}"); let mut connect_amf = Buffer::with_capacity(512); - command::build_connect(&mut connect_amf, app, &tc_url, "", "", "FMLE/3.0", 0, 0)?; + command::build_connect( + &mut connect_amf, + app, + &tc_url, + "", + "", + "FMLE/3.0", + 0, + 0, + None, + )?; self.send_command_msg(0, connect_amf.as_slice())?; let mut result = self.wait_for_command("_result")?; command::read_connect_result(&mut result)?; @@ -356,13 +372,7 @@ impl Client { } cmsg.fmt = 0; - chunk_write( - &mut self.send_buffer, - &cmsg, - payload, - payload.len(), - 128, - )?; + chunk_write(&mut self.send_buffer, &cmsg, payload, payload.len(), 128)?; // Non-blocking flush: a malicious server that stops reading must not // stall the embedder's thread for up to 10s per frame via blocking send. @@ -454,12 +464,7 @@ impl Client { }; if n > 0 { let chunk_len = n as usize; - if self - .recv_buffer - .available() - .saturating_add(chunk_len) - > MAX_RECV_BUFFER_BYTES - { + if self.recv_buffer.available().saturating_add(chunk_len) > MAX_RECV_BUFFER_BYTES { return Err(ErrorCode::Protocol); } self.recv_buffer @@ -515,25 +520,30 @@ impl Client { } else if msg.msg_type_id == msg_dispatch::RTMP_MSG_AUDIO || msg.msg_type_id == msg_dispatch::RTMP_MSG_VIDEO { - if let Some(ref cb) = self.on_frame_cb { - // Move the already-owned payload into the - // connection-scoped scratch buffer so Frame.data - // stays valid until the next callback, not just - // for the duration of this call. - self.frame_cb_scratch = payload; - let frame = Frame { - frame_type: if msg.msg_type_id == msg_dispatch::RTMP_MSG_AUDIO { - FrameType::Audio - } else { - FrameType::Video - }, - timestamp: msg.timestamp, - size: self.frame_cb_scratch.len() as u32, - data: self.frame_cb_scratch.as_ptr(), - ..Default::default() + if let Some(cb) = self.on_frame_cb { + let frame_type = if msg.msg_type_id == msg_dispatch::RTMP_MSG_AUDIO { + FrameType::Audio + } else { + FrameType::Video }; - cb(&frame); + self.deliver_av_frame_cb(cb, frame_type, msg.timestamp, payload); } + } else if msg.msg_type_id == msg_dispatch::RTMP_MSG_AMF0_DATA + || msg.msg_type_id == msg_dispatch::RTMP_MSG_AMF3_DATA + { + let data_payload = if msg.msg_type_id == msg_dispatch::RTMP_MSG_AMF3_DATA + && !payload.is_empty() + && payload[0] == 0x00 + { + payload[1..].to_vec() + } else { + payload + }; + if let Some(cb) = self.on_frame_cb { + self.deliver_script_frame_cb(cb, msg.timestamp, &data_payload); + } + } else if msg.msg_type_id == msg_dispatch::RTMP_MSG_AGGREGATE { + self.handle_aggregate_message(msg.timestamp, &payload)?; } } Ok(_) => break, @@ -543,8 +553,159 @@ impl Client { Ok(()) } + /// Unpack aggregate A/V/script sub-tags for play-side frame callbacks. + fn handle_aggregate_message(&mut self, base_timestamp: u32, payload: &[u8]) -> Result<()> { + let mut pos = 0usize; + let mut have_base = false; + let mut sub_base_ts: u32 = 0; + let mut subtags = 0usize; + + while pos + 11 <= payload.len() { + if subtags >= MAX_AGGREGATE_SUBTAGS { + return Err(ErrorCode::Protocol); + } + subtags += 1; + + let tag_type = payload[pos]; + let data_size = ((payload[pos + 1] as u32) << 16) + | ((payload[pos + 2] as u32) << 8) + | (payload[pos + 3] as u32); + let ts = ((payload[pos + 4] as u32) << 16) + | ((payload[pos + 5] as u32) << 8) + | (payload[pos + 6] as u32) + | ((payload[pos + 7] as u32) << 24); + let body = pos + 11; + let data_size = data_size as usize; + if body + data_size > payload.len() { + return Err(ErrorCode::Protocol); + } + if !have_base { + sub_base_ts = ts; + have_base = true; + } + let out_ts = base_timestamp.wrapping_add(ts.wrapping_sub(sub_base_ts)); + let tag_payload = &payload[body..body + data_size]; + + if let Some(cb) = self.on_frame_cb { + match tag_type { + msg_dispatch::RTMP_MSG_AUDIO => { + self.deliver_av_frame_cb( + cb, + FrameType::Audio, + out_ts, + tag_payload.to_vec(), + ); + } + msg_dispatch::RTMP_MSG_VIDEO => { + self.deliver_av_frame_cb( + cb, + FrameType::Video, + out_ts, + tag_payload.to_vec(), + ); + } + msg_dispatch::RTMP_MSG_AMF0_DATA => { + self.deliver_script_frame_cb(cb, out_ts, tag_payload); + } + _ => { + pos = body + data_size + 4; + continue; + } + } + } + + pos = body + data_size + 4; + } + Ok(()) + } + // ── Internal helpers ── + fn deliver_av_frame_cb( + &mut self, + cb: fn(&Frame), + frame_type: FrameType, + timestamp: u32, + payload: Vec, + ) { + let had_multitrack = foreach_track(frame_type, &payload, |track| { + self.invoke_multitrack_on_frame_cb( + cb, + frame_type, + timestamp, + track.track_id, + track.fourcc, + track.packet_type, + track.video_frame_type, + track.payload, + ); + }); + if !had_multitrack { + self.invoke_on_frame_cb(cb, frame_type, timestamp, u8::MAX, &payload); + } + } + + fn invoke_multitrack_on_frame_cb( + &mut self, + cb: fn(&Frame), + frame_type: FrameType, + timestamp: u32, + track_id: u8, + fourcc: [u8; 4], + packet_type: u8, + video_frame_type: u8, + payload: &[u8], + ) { + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let mut frame = Frame { + frame_type, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + track_id, + ..Default::default() + }; + populate_multitrack_frame(&mut frame, fourcc, packet_type, video_frame_type); + cb(&frame); + } + fn invoke_on_frame_cb( + &mut self, + cb: fn(&Frame), + frame_type: FrameType, + timestamp: u32, + track_id: u8, + payload: &[u8], + ) { + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let mut frame = Frame { + frame_type, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + track_id, + ..Default::default() + }; + populate_av_frame(&mut frame, &self.frame_cb_scratch); + cb(&frame); + } + + fn deliver_script_frame_cb(&mut self, cb: fn(&Frame), timestamp: u32, payload: &[u8]) { + let is_metadata = u8::from(is_on_metadata_payload(payload)); + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let frame = Frame { + frame_type: FrameType::Script, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + is_metadata, + ..Default::default() + }; + cb(&frame); + } + fn queue_user_control_message(&mut self, payload: &[u8]) -> Result<()> { let mut cmsg = ChunkMessage::default(); cmsg.csid = 2; @@ -552,13 +713,7 @@ impl Client { cmsg.msg_length = payload.len() as u32; cmsg.msg_type_id = msg_dispatch::RTMP_MSG_USER_CONTROL; cmsg.msg_stream_id = 0; - chunk_write( - &mut self.send_buffer, - &cmsg, - payload, - payload.len(), - 128, - )?; + chunk_write(&mut self.send_buffer, &cmsg, payload, payload.len(), 128)?; Ok(()) } @@ -614,24 +769,37 @@ impl Client { if payload.len() < 6 { return Ok(()); } - let (event_type, param1, _) = control::read_user_control(payload, false)?; - if event_type == control::UCTRL_PING_REQUEST { - let now = Instant::now(); - if let Some(start) = self.inbound_ping_window_start { - if now.duration_since(start) >= INBOUND_PING_WINDOW { + let event_type = ((payload[0] as u16) << 8) | (payload[1] as u16); + let (event_type, param1, param2) = if event_type == control::UCTRL_SET_BUFFER_LENGTH { + control::read_user_control(payload, true)? + } else { + let (ty, p1, _) = control::read_user_control(payload, false)?; + (ty, p1, None) + }; + match event_type { + control::UCTRL_PING_REQUEST => { + let now = Instant::now(); + if let Some(start) = self.inbound_ping_window_start { + if now.duration_since(start) >= INBOUND_PING_WINDOW { + self.inbound_ping_window_start = Some(now); + self.inbound_ping_responses = 0; + } + } else { self.inbound_ping_window_start = Some(now); - self.inbound_ping_responses = 0; } - } else { - self.inbound_ping_window_start = Some(now); + if self.inbound_ping_responses >= MAX_INBOUND_PING_RESPONSES { + return Err(ErrorCode::Protocol); + } + self.inbound_ping_responses += 1; + let mut buf = Buffer::with_capacity(6); + control::write_user_control_ping_response(&mut buf, param1)?; + self.send_user_control_message_nonblocking(buf.as_slice())?; } - if self.inbound_ping_responses >= MAX_INBOUND_PING_RESPONSES { - return Err(ErrorCode::Protocol); + control::UCTRL_STREAM_BEGIN | control::UCTRL_STREAM_EOF => {} + control::UCTRL_SET_BUFFER_LENGTH => { + let _ = param2; } - self.inbound_ping_responses += 1; - let mut buf = Buffer::with_capacity(6); - control::write_user_control_ping_response(&mut buf, param1)?; - self.send_user_control_message_nonblocking(buf.as_slice())?; + _ => {} } Ok(()) } @@ -677,16 +845,10 @@ impl Client { }; if n > 0 { let chunk_len = n as usize; - if self - .recv_buffer - .available() - .saturating_add(chunk_len) - > MAX_RECV_BUFFER_BYTES - { + if self.recv_buffer.available().saturating_add(chunk_len) > MAX_RECV_BUFFER_BYTES { return Err(ErrorCode::Protocol); } - self - .recv_buffer + self.recv_buffer .write(&buf[..chunk_len]) .map_err(|_| ErrorCode::Internal)?; bytes_drained += chunk_len; @@ -844,12 +1006,7 @@ impl Client { }; if n > 0 { let chunk_len = n as usize; - if self - .recv_buffer - .available() - .saturating_add(chunk_len) - > MAX_RECV_BUFFER_BYTES - { + if self.recv_buffer.available().saturating_add(chunk_len) > MAX_RECV_BUFFER_BYTES { return Err(ErrorCode::Protocol); } *recv_budget -= chunk_len; @@ -881,7 +1038,11 @@ fn poll_for_transport_direction(fd: i32, again: i32, timeout_ms: i32) -> Result< libc::POLLIN }; loop { - let mut pfd = libc::pollfd { fd, events, revents: 0 }; + let mut pfd = libc::pollfd { + fd, + events, + revents: 0, + }; let rc = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; if rc == 0 { return Err(ErrorCode::Timeout); @@ -983,14 +1144,7 @@ mod tests { cmsg.msg_length = payload_len as u32; cmsg.msg_type_id = msg_dispatch::RTMP_MSG_USER_CONTROL; cmsg.msg_stream_id = 0; - chunk_write( - &mut wire, - &cmsg, - payload.as_slice(), - payload_len, - 128, - ) - .unwrap(); + chunk_write(&mut wire, &cmsg, payload.as_slice(), payload_len, 128).unwrap(); wire.peek().to_vec() } @@ -1008,8 +1162,9 @@ mod tests { let chunks = payload.div_ceil(MIN_PRACTICAL_CHUNK_SIZE); // Each message's first chunk (fmt=0) carries the larger message // header on top of the shared continuation-chunk overhead. - let worst_case_wire_bytes = - payload + chunks * MAX_CHUNK_HEADER_OVERHEAD_BYTES + 2 * FIRST_CHUNK_EXTRA_OVERHEAD_BYTES; + let worst_case_wire_bytes = payload + + chunks * MAX_CHUNK_HEADER_OVERHEAD_BYTES + + 2 * FIRST_CHUNK_EXTRA_OVERHEAD_BYTES; assert!(MAX_RECV_BUFFER_BYTES >= worst_case_wire_bytes); } @@ -1085,7 +1240,9 @@ mod tests { client.on_frame_cb = Some(|_| {}); let mut messages_processed = 0; - client.drain_ready_messages(&mut messages_processed).unwrap(); + client + .drain_ready_messages(&mut messages_processed) + .unwrap(); // Frame.data must still be valid (i.e. frame_cb_scratch must still // hold the delivered payload) after the callback has returned, not @@ -1094,6 +1251,76 @@ mod tests { assert_eq!(client.frame_cb_scratch.as_slice(), &video_payload[..]); } + #[test] + fn script_callbacks_only_mark_on_metadata_events() { + use std::sync::{LazyLock, Mutex}; + + static FLAGS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + + let mut client = Client::new(); + FLAGS.lock().unwrap().clear(); + + let mut cue_point = Buffer::new(); + crate::amf::amf0::write_string(&mut cue_point, "onCuePoint").unwrap(); + client.deliver_script_frame_cb( + |frame| FLAGS.lock().unwrap().push(frame.is_metadata), + 10, + cue_point.as_slice(), + ); + + let mut metadata = Buffer::new(); + crate::amf::amf0::write_string(&mut metadata, "@setDataFrame").unwrap(); + crate::amf::amf0::write_string(&mut metadata, "onMetaData").unwrap(); + client.deliver_script_frame_cb( + |frame| FLAGS.lock().unwrap().push(frame.is_metadata), + 20, + metadata.as_slice(), + ); + + assert_eq!(*FLAGS.lock().unwrap(), vec![0, 1]); + } + + #[test] + fn drain_ready_messages_splits_multitrack_video() { + use std::sync::{LazyLock, Mutex}; + + static SEEN: LazyLock)>>> = LazyLock::new(|| Mutex::new(Vec::new())); + + let payload = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC, 0x01, + 0x00, 0x00, 0x02, 0xDD, 0xEE, + ]; + let mut wire = Buffer::new(); + let mut cmsg = ChunkMessage::default(); + cmsg.csid = 6; + cmsg.fmt = 0; + cmsg.msg_length = payload.len() as u32; + cmsg.msg_type_id = msg_dispatch::RTMP_MSG_VIDEO; + cmsg.msg_stream_id = 1; + chunk_write(&mut wire, &cmsg, &payload, payload.len(), 128).unwrap(); + + let mut client = Client::new(); + client.recv_buffer.write(wire.peek()).unwrap(); + SEEN.lock().unwrap().clear(); + client.on_frame_cb = Some(|frame| { + let data = + unsafe { std::slice::from_raw_parts(frame.data, frame.size as usize).to_vec() }; + SEEN.lock().unwrap().push((frame.track_id, data)); + }); + + let mut messages_processed = 0; + client + .drain_ready_messages(&mut messages_processed) + .unwrap(); + + let seen = SEEN.lock().unwrap().clone(); + assert_eq!(seen.len(), 2); + assert_eq!(seen[0].0, 0); + assert_eq!(seen[0].1, vec![0xAA, 0xBB, 0xCC]); + assert_eq!(seen[1].0, 1); + assert_eq!(seen[1].1, vec![0xDD, 0xEE]); + } + #[test] fn poll_drains_leftover_messages_before_enforcing_staging_cap() { use std::io::Write; diff --git a/src/ertmp/connect_amf.rs b/src/ertmp/connect_amf.rs new file mode 100644 index 0000000..9912a06 --- /dev/null +++ b/src/ertmp/connect_amf.rs @@ -0,0 +1,553 @@ +//! AMF0 bridge for E-RTMP connect-object capability fields. + +use crate::amf::amf0::{self, Amf0Type}; +use crate::buffer::Buffer; +use crate::types::{ + CAPS_EX_MASK_MODEX, CAPS_EX_MASK_MULTITRACK, CAPS_EX_MASK_RECONNECT, + CAPS_EX_MASK_SERVER_DEFAULT, CAPS_EX_MASK_TIMESTAMP_NANO, CapsExit, ErrorCode, + FOUR_CC_INFO_ALL, FOUR_CC_INFO_CAN_FORWARD, FourCcList, NegotiatedCaps, Reconnect, Result, + VideoFourCcInfoMap, +}; + +use super::connect_caps::{ + caps_exit_parse, caps_exit_write, fourcc_list_add, fourcc_list_init, fourcc_list_parse, + fourcc_list_write, video_fourcc_info_map_parse, video_fourcc_info_map_write, +}; +use super::reconnect::{reconnect_parse, reconnect_write}; + +const MAX_CAPS_BLOB_BYTES: usize = 4096; + +/// Read a `fourCcList` AMF value into a [`FourCcList`]. +pub fn read_four_cc_list_amf(buf: &mut Buffer, list: &mut FourCcList) -> Result<()> { + fourcc_list_init(list); + let ty = amf0::read_type(buf)?; + match ty { + Amf0Type::StrictArray => read_four_cc_strict_array(buf, list), + Amf0Type::LongString => { + let data = read_amf_binary_blob(buf, ty)?; + fourcc_list_parse(list, &data).map(|_| ()) + } + _ => Err(ErrorCode::Amf), + } +} + +/// Read a `videoFourCcInfoMap` AMF value. +pub fn read_video_fourcc_info_map_amf( + buf: &mut Buffer, + map: &mut VideoFourCcInfoMap, +) -> Result<()> { + *map = VideoFourCcInfoMap::default(); + let ty = amf0::read_type(buf)?; + match ty { + Amf0Type::StrictArray => { + let count = read_u32(buf)? as usize; + if count > crate::types::MAX_FOURCCS { + return Err(ErrorCode::Amf); + } + for _ in 0..count { + let mut cc = [0u8; 8]; + let n = amf0::read_string(buf, &mut cc)?; + add_video_map_entry(map, &cc[..n], FOUR_CC_INFO_ALL)?; + } + Ok(()) + } + Amf0Type::LongString => { + let data = read_amf_binary_blob(buf, ty)?; + video_fourcc_info_map_parse(map, &data).map(|_| ()) + } + Amf0Type::Object | Amf0Type::EcmaArray => { + if ty == Amf0Type::EcmaArray { + let _declared_count = read_u32(buf)?; + } + let mut keys = 0usize; + while !amf0::is_object_end(buf) { + keys += 1; + if keys > amf0::MAX_OBJECT_KEYS { + return Err(ErrorCode::Amf); + } + let mut key = [0u8; 256]; + let key_len = amf0::read_object_key(buf, &mut key)?; + let value_type = amf0::read_type(buf)?; + if value_type != Amf0Type::Number { + return Err(ErrorCode::Amf); + } + let value = amf0::read_number(buf)?; + if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 { + return Err(ErrorCode::Amf); + } + add_video_map_entry(map, &key[..key_len], value as u32)?; + } + let mut end = [0u8; 3]; + buf.read(&mut end).map_err(|_| ErrorCode::Amf)?; + Ok(()) + } + _ => Err(ErrorCode::Amf), + } +} + +fn add_video_map_entry(map: &mut VideoFourCcInfoMap, key: &[u8], mask: u32) -> Result<()> { + if map.count >= crate::types::MAX_FOURCCS { + return Err(ErrorCode::Amf); + } + let entry = &mut map.entries[map.count]; + if key == b"*" { + entry.cc[0] = b'*'; + } else if key.len() >= 4 { + entry.cc[..4].copy_from_slice(&key[..4]); + } else { + return Err(ErrorCode::Amf); + } + map.masks[map.count] = mask; + map.count += 1; + Ok(()) +} + +/// Read a `capsEx` AMF value (numeric bitmask per E-RTMP v2 spec). +pub fn read_caps_ex_amf(buf: &mut Buffer, caps: &mut CapsExit, mask: &mut u32) -> Result<()> { + let ty = amf0::read_type(buf)?; + match ty { + Amf0Type::Number => { + let value = amf0::read_number(buf)?; + if !value.is_finite() || value < 0.0 || value > u32::MAX as f64 || value.fract() != 0.0 + { + return Err(ErrorCode::Amf); + } + *mask = value as u32; + Ok(()) + } + Amf0Type::LongString => { + let data = read_amf_binary_blob(buf, ty)?; + caps_exit_parse(caps, &data)?; + // Legacy blob form carries codec ids, not a v2 bitmask. + *mask = CAPS_EX_MASK_SERVER_DEFAULT; + Ok(()) + } + Amf0Type::Object => { + caps.version = 1; + // `read_type` already consumed the object marker. + let mut keys = 0usize; + while !amf0::is_object_end(buf) { + keys += 1; + if keys > amf0::MAX_OBJECT_KEYS { + return Err(ErrorCode::Amf); + } + let mut key = [0u8; 256]; + let key_len = amf0::read_object_key(buf, &mut key)?; + let key_str = std::str::from_utf8(&key[..key_len]).unwrap_or(""); + match key_str { + "videoCodecId" | "videoCodecFourCC" => { + caps.video_codec_32 = read_fourcc_number(buf)?; + } + "audioCodecId" | "audioCodecFourCC" => { + caps.audio_codec_32 = read_fourcc_number(buf)?; + } + _ => { + amf0::skip_value(buf)?; + } + } + } + let mut end = [0u8; 3]; + buf.read(&mut end).map_err(|_| ErrorCode::Amf)?; + // Legacy object form carries codec ids, not a v2 bitmask. + *mask = CAPS_EX_MASK_SERVER_DEFAULT; + Ok(()) + } + _ => Err(ErrorCode::Amf), + } +} + +/// Read a v2 `reconnect` AMF value. +pub fn read_reconnect_amf(buf: &mut Buffer, rc: &mut Reconnect) -> Result<()> { + let ty = amf0::read_type(buf)?; + let data = read_amf_binary_blob(buf, ty)?; + reconnect_parse(rc, &data) +} + +/// Write negotiated caps into an AMF0 object (without surrounding object markers). +pub fn write_negotiated_caps(buf: &mut Buffer, caps: &NegotiatedCaps) -> Result<()> { + if caps.has_four_cc_list && caps.four_cc_list.count > 0 { + amf0::write_object_key(buf, "fourCcList")?; + write_four_cc_list_amf(buf, &caps.four_cc_list)?; + } + if caps.has_caps_ex { + amf0::write_object_key(buf, "capsEx")?; + write_caps_ex_amf(buf, caps.caps_ex_mask)?; + } + if caps.has_video_four_cc_info_map && caps.video_four_cc_info_map.count > 0 { + amf0::write_object_key(buf, "videoFourCcInfoMap")?; + write_video_fourcc_info_map_amf(buf, &caps.video_four_cc_info_map)?; + } + if caps.has_reconnect { + amf0::write_object_key(buf, "reconnect")?; + write_reconnect_amf(buf, &caps.reconnect)?; + } + Ok(()) +} + +pub fn write_four_cc_list_amf(buf: &mut Buffer, list: &FourCcList) -> Result<()> { + buf.write(&[Amf0Type::StrictArray as u8]) + .map_err(|_| ErrorCode::Internal)?; + buf.write(&(list.count as u32).to_be_bytes()) + .map_err(|_| ErrorCode::Internal)?; + for i in 0..list.count { + let cc = if list.entries[i].cc[0] == b'*' { + "*" + } else { + std::str::from_utf8(&list.entries[i].cc[..4]).unwrap_or("????") + }; + amf0::write_string(buf, cc)?; + } + Ok(()) +} + +pub fn write_video_fourcc_info_map_amf(buf: &mut Buffer, map: &VideoFourCcInfoMap) -> Result<()> { + amf0::write_object_begin(buf)?; + for i in 0..map.count { + let key = if map.entries[i].cc[0] == b'*' { + "*" + } else { + std::str::from_utf8(&map.entries[i].cc[..4]).map_err(|_| ErrorCode::Amf)? + }; + amf0::write_object_key(buf, key)?; + amf0::write_number(buf, map.masks[i] as f64)?; + } + amf0::write_object_end(buf) +} + +pub fn write_caps_ex_amf(buf: &mut Buffer, caps_ex_mask: u32) -> Result<()> { + amf0::write_number(buf, caps_ex_mask as f64) +} + +pub fn write_reconnect_amf(buf: &mut Buffer, rc: &Reconnect) -> Result<()> { + let mut blob = [0u8; 8]; + if reconnect_write(rc, &mut blob) != 8 { + return Err(ErrorCode::Internal); + } + amf0::write_long_string_bytes(buf, &blob) +} + +fn read_four_cc_strict_array(buf: &mut Buffer, list: &mut FourCcList) -> Result<()> { + let count = read_u32(buf)? as usize; + if count > crate::types::MAX_FOURCCS { + return Err(ErrorCode::Amf); + } + for _ in 0..count { + let mut cc = [0u8; 8]; + let n = amf0::read_string(buf, &mut cc)?; + if (n == 1 && cc[0] == b'*') || n >= 4 { + fourcc_list_add(list, &cc[..n])?; + } + } + Ok(()) +} + +fn read_fourcc_number(buf: &mut Buffer) -> Result { + let ty = amf0::read_type(buf)?; + if ty == Amf0Type::Number { + Ok(amf0::read_number(buf)? as i32) + } else if ty == Amf0Type::String { + let len = read_u16(buf)? as usize; + if !(4..=MAX_CAPS_BLOB_BYTES).contains(&len) || buf.available() < len { + return Err(ErrorCode::Amf); + } + let mut cc = [0u8; 4]; + buf.read(&mut cc).map_err(|_| ErrorCode::Amf)?; + if len > cc.len() { + buf.drain(len - cc.len()); + } + Ok(i32::from_be_bytes(cc)) + } else { + Err(ErrorCode::Amf) + } +} + +fn read_amf_binary_blob(buf: &mut Buffer, ty: Amf0Type) -> Result> { + let len = match ty { + Amf0Type::LongString => read_u32(buf)? as usize, + Amf0Type::String => read_u16(buf)? as usize, + _ => return Err(ErrorCode::Amf), + }; + if len > MAX_CAPS_BLOB_BYTES || buf.available() < len { + return Err(ErrorCode::Amf); + } + let mut data = vec![0u8; len]; + buf.read(&mut data).map_err(|_| ErrorCode::Amf)?; + Ok(data) +} + +fn read_u16(buf: &mut Buffer) -> Result { + let mut b = [0u8; 2]; + buf.read(&mut b).map_err(|_| ErrorCode::Amf)?; + Ok(u16::from_be_bytes(b)) +} + +fn read_u32(buf: &mut Buffer) -> Result { + let mut b = [0u8; 4]; + buf.read(&mut b).map_err(|_| ErrorCode::Amf)?; + Ok(u32::from_be_bytes(b)) +} + +fn four_cc_supported(cc: &[u8]) -> bool { + cc == b"*" + || matches!( + cc, + b"avc1" + | b"hvc1" + | b"av01" + | b"vp08" + | b"vp09" + | b"vvc1" + | b"mp4a" + | b"Opus" + | b"ac-3" + | b"ec-3" + | b".mp3" + | b"fLaC" + ) +} + +fn fourcc_entry_bytes(entry: &crate::types::FourCc) -> &[u8] { + if entry.cc[0] == b'*' { + b"*" + } else { + &entry.cc[..4] + } +} + +/// Intersect client-offered E-RTMP caps with what this server supports. +pub fn negotiate_caps(client: &crate::types::ConnectInfo) -> NegotiatedCaps { + let mut out = NegotiatedCaps::default(); + + if client.has_four_cc_list { + for i in 0..client.four_cc_list.count { + let cc = fourcc_entry_bytes(&client.four_cc_list.entries[i]); + if four_cc_supported(cc) { + let _ = fourcc_list_add(&mut out.four_cc_list, cc); + } + } + out.has_four_cc_list = out.four_cc_list.count > 0; + } + + if client.has_caps_ex { + let requested = client.caps_ex_mask; + out.caps_ex_mask = requested & CAPS_EX_MASK_SERVER_DEFAULT; + if client.has_reconnect { + out.caps_ex_mask |= CAPS_EX_MASK_RECONNECT & requested; + } + out.has_caps_ex = true; + out.multitrack_enabled = (out.caps_ex_mask & CAPS_EX_MASK_MULTITRACK) != 0; + } else { + // Legacy clients without capsEx still get multitrack relay when publishing + // enhanced multitrack payloads; capability is not gated on connect. + out.multitrack_enabled = true; + } + + if client.has_video_four_cc_info_map { + for i in 0..client.video_four_cc_info_map.count { + let cc = fourcc_entry_bytes(&client.video_four_cc_info_map.entries[i]); + let negotiated_mask = client.video_four_cc_info_map.masks[i] & FOUR_CC_INFO_CAN_FORWARD; + if negotiated_mask == 0 || !four_cc_supported(cc) { + continue; + } + let idx = out.video_four_cc_info_map.count; + if cc == b"*" { + out.video_four_cc_info_map.entries[idx].cc[0] = b'*'; + } else { + out.video_four_cc_info_map.entries[idx].cc[..4].copy_from_slice(&cc[..4]); + } + out.video_four_cc_info_map.masks[idx] = negotiated_mask; + out.video_four_cc_info_map.count += 1; + } + out.has_video_four_cc_info_map = out.video_four_cc_info_map.count > 0; + } + + if client.has_reconnect { + out.reconnect = client.reconnect; + out.has_reconnect = true; + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{FourCcList, VideoFourCcInfoMap}; + + #[test] + fn four_cc_list_strict_array_round_trip() { + let mut list = FourCcList::default(); + fourcc_list_add(&mut list, b"av01").unwrap(); + fourcc_list_add(&mut list, b"hvc1").unwrap(); + + let mut buf = Buffer::new(); + write_four_cc_list_amf(&mut buf, &list).unwrap(); + + let mut parsed = FourCcList::default(); + read_four_cc_list_amf(&mut buf, &mut parsed).unwrap(); + assert_eq!(parsed.count, 2); + assert_eq!(&parsed.entries[0].cc[..4], b"av01"); + assert_eq!(&parsed.entries[1].cc[..4], b"hvc1"); + } + + #[test] + fn video_fourcc_info_map_object_parses_fourcc_keys() { + let mut buf = Buffer::new(); + buf.write(&[Amf0Type::Object as u8]).unwrap(); + amf0::write_object_key(&mut buf, "av01").unwrap(); + amf0::write_number(&mut buf, 1.0).unwrap(); + amf0::write_object_key(&mut buf, "hvc1").unwrap(); + amf0::write_number(&mut buf, 2.0).unwrap(); + amf0::write_object_end(&mut buf).unwrap(); + + let mut map = VideoFourCcInfoMap::default(); + read_video_fourcc_info_map_amf(&mut buf, &mut map).unwrap(); + assert_eq!(map.count, 2); + assert_eq!(&map.entries[0].cc[..4], b"av01"); + assert_eq!(map.masks[0], 1); + assert_eq!(&map.entries[1].cc[..4], b"hvc1"); + assert_eq!(map.masks[1], 2); + } + + #[test] + fn caps_ex_mask_round_trip() { + let mut buf = Buffer::new(); + write_caps_ex_amf(&mut buf, CAPS_EX_MASK_MULTITRACK | CAPS_EX_MASK_MODEX).unwrap(); + + let mut parsed = CapsExit::default(); + let mut mask = 0u32; + read_caps_ex_amf(&mut buf, &mut parsed, &mut mask).unwrap(); + assert_eq!(mask, CAPS_EX_MASK_MULTITRACK | CAPS_EX_MASK_MODEX); + } + + #[test] + fn caps_ex_number_rejects_non_integral_or_out_of_range_values() { + for value in [f64::NAN, f64::INFINITY, -1.0, 1.5, u32::MAX as f64 + 1.0] { + let mut buf = Buffer::new(); + amf0::write_number(&mut buf, value).unwrap(); + let mut parsed = CapsExit::default(); + let mut mask = 0u32; + assert_eq!( + read_caps_ex_amf(&mut buf, &mut parsed, &mut mask), + Err(ErrorCode::Amf), + "value {value:?} must be rejected" + ); + } + } + + #[test] + fn caps_ex_binary_round_trip() { + let caps = CapsExit { + version: 1, + video_codec_32: i32::from_be_bytes(*b"av01"), + audio_codec_32: i32::from_be_bytes(*b"mp4a"), + }; + let mut buf = Buffer::new(); + let mut blob = [0u8; 8]; + caps_exit_write(&caps, &mut blob); + amf0::write_long_string_bytes(&mut buf, &blob).unwrap(); + + let mut parsed = CapsExit::default(); + let mut mask = 0u32; + read_caps_ex_amf(&mut buf, &mut parsed, &mut mask).unwrap(); + assert_eq!(parsed.video_codec_32, caps.video_codec_32); + assert_eq!(parsed.audio_codec_32, caps.audio_codec_32); + assert_eq!(mask, CAPS_EX_MASK_SERVER_DEFAULT); + } + + #[test] + fn caps_ex_object_parses_legacy_codec_fields() { + let mut buf = Buffer::new(); + buf.write(&[Amf0Type::Object as u8]).unwrap(); + amf0::write_object_key(&mut buf, "videoCodecId").unwrap(); + amf0::write_number(&mut buf, f64::from(i32::from_be_bytes(*b"av01"))).unwrap(); + amf0::write_object_key(&mut buf, "audioCodecId").unwrap(); + amf0::write_number(&mut buf, f64::from(i32::from_be_bytes(*b"mp4a"))).unwrap(); + amf0::write_object_end(&mut buf).unwrap(); + + let mut parsed = CapsExit::default(); + let mut mask = 0u32; + read_caps_ex_amf(&mut buf, &mut parsed, &mut mask).unwrap(); + assert_eq!(parsed.video_codec_32, i32::from_be_bytes(*b"av01")); + assert_eq!(parsed.audio_codec_32, i32::from_be_bytes(*b"mp4a")); + assert_eq!(mask, CAPS_EX_MASK_SERVER_DEFAULT); + } + + #[test] + fn negotiate_caps_honors_explicit_zero_caps_ex() { + use crate::types::ConnectInfo; + + let mut client = ConnectInfo::default(); + client.has_caps_ex = true; + client.caps_ex_mask = 0; + + let caps = negotiate_caps(&client); + assert!(caps.has_caps_ex); + assert_eq!(caps.caps_ex_mask, 0); + assert!(!caps.multitrack_enabled); + } + + #[test] + fn negotiate_caps_legacy_object_caps_ex_uses_defaults() { + use crate::types::ConnectInfo; + + let mut client = ConnectInfo::default(); + client.has_caps_ex = true; + client.caps_ex_mask = CAPS_EX_MASK_SERVER_DEFAULT; + client.caps_ex.version = 1; + client.caps_ex.video_codec_32 = i32::from_be_bytes(*b"av01"); + + let caps = negotiate_caps(&client); + assert!(caps.has_caps_ex); + assert_eq!(caps.caps_ex_mask, CAPS_EX_MASK_SERVER_DEFAULT); + assert!(caps.multitrack_enabled); + } + + #[test] + fn video_info_map_writes_object_with_numeric_masks() { + let mut map = VideoFourCcInfoMap::default(); + map.entries[0].cc[..4].copy_from_slice(b"vp09"); + map.masks[0] = FOUR_CC_INFO_CAN_FORWARD; + map.count = 1; + + let mut buf = Buffer::new(); + write_video_fourcc_info_map_amf(&mut buf, &map).unwrap(); + assert_eq!(buf.peek()[0], Amf0Type::Object as u8); + + let mut parsed = VideoFourCcInfoMap::default(); + read_video_fourcc_info_map_amf(&mut buf, &mut parsed).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(&parsed.entries[0].cc[..4], b"vp09"); + assert_eq!(parsed.masks[0], FOUR_CC_INFO_CAN_FORWARD); + } + + #[test] + fn four_cc_list_preserves_wildcard() { + let mut buf = Buffer::new(); + buf.write(&[Amf0Type::StrictArray as u8]).unwrap(); + buf.write(&1u32.to_be_bytes()).unwrap(); + amf0::write_string(&mut buf, "*").unwrap(); + + let mut parsed = FourCcList::default(); + read_four_cc_list_amf(&mut buf, &mut parsed).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(parsed.entries[0].cc[0], b'*'); + } + + #[test] + fn string_fourcc_value_rejects_oversized_input() { + let mut buf = Buffer::new(); + let oversized = "x".repeat(MAX_CAPS_BLOB_BYTES + 1); + amf0::write_string(&mut buf, &oversized).unwrap(); + assert_eq!(read_fourcc_number(&mut buf), Err(ErrorCode::Amf)); + } + #[test] + fn string_fourcc_value_is_read_after_consumed_marker() { + let mut buf = Buffer::new(); + amf0::write_string(&mut buf, "av01").unwrap(); + assert_eq!( + read_fourcc_number(&mut buf).unwrap(), + i32::from_be_bytes(*b"av01") + ); + } +} diff --git a/src/ertmp/connect_caps.rs b/src/ertmp/connect_caps.rs index 7307858..21e136f 100644 --- a/src/ertmp/connect_caps.rs +++ b/src/ertmp/connect_caps.rs @@ -2,7 +2,7 @@ //! //! Mirrors `src/ertmp/connect_caps.c`. -use crate::types::{CapsExit, ErrorCode, FourCcList, Result, VideoFourCcInfoMap}; +use crate::types::{CapsExit, ErrorCode, FOUR_CC_INFO_ALL, FourCcList, Result, VideoFourCcInfoMap}; /* ── fourCcList ── */ @@ -16,10 +16,15 @@ pub fn fourcc_list_add(list: &mut FourCcList, cc: &[u8]) -> Result<()> { if list.count >= crate::types::MAX_FOURCCS { return Err(ErrorCode::Io); } - if cc.len() < 4 { + let entry = &mut list.entries[list.count]; + *entry = Default::default(); + if cc == b"*" { + entry.cc[0] = b'*'; + } else if cc.len() >= 4 { + entry.cc[..4].copy_from_slice(&cc[..4]); + } else { return Err(ErrorCode::Io); } - list.entries[list.count].cc[..4].copy_from_slice(&cc[..4]); list.count += 1; Ok(()) } @@ -39,42 +44,45 @@ pub fn fourcc_list_parse(list: &mut FourCcList, data: &[u8]) -> Result { let mut offset = 4; for _ in 0..count { - if offset + 6 > data.len() { + if offset + 2 > data.len() { break; } - let slen = ((data[offset] as u16) << 8) | (data[offset + 1] as u16); + let slen = (((data[offset] as u16) << 8) | (data[offset + 1] as u16)) as usize; offset += 2; - if slen != 4 || offset + 4 > data.len() { + if !matches!(slen, 1 | 4) || offset + slen > data.len() { break; } - list.entries[list.count].cc[..4].copy_from_slice(&data[offset..offset + 4]); - list.count += 1; - offset += 4; + fourcc_list_add(list, &data[offset..offset + slen])?; + offset += slen; } Ok(list.count) } /// Write a FourCC list to a buffer. Returns bytes written. pub fn fourcc_list_write(list: &FourCcList, buf: &mut [u8]) -> usize { - let needed = 4 + list.count * 6; + let needed = 4 + + (0..list.count) + .map(|i| if list.entries[i].cc[0] == b'*' { 3 } else { 6 }) + .sum::(); if buf.len() < needed { return 0; } - // Write count as big-endian u32 to match fourcc_list_parse's big-endian read. let cnt = list.count as u32; - buf[0] = (cnt >> 24) as u8; - buf[1] = (cnt >> 16) as u8; - buf[2] = (cnt >> 8) as u8; - buf[3] = cnt as u8; + buf[..4].copy_from_slice(&cnt.to_be_bytes()); let mut offset = 4; for i in 0..list.count { - buf[offset] = 0; - buf[offset + 1] = 4; + let wildcard = list.entries[i].cc[0] == b'*'; + let len = if wildcard { 1 } else { 4 }; + buf[offset..offset + 2].copy_from_slice(&(len as u16).to_be_bytes()); offset += 2; - buf[offset..offset + 4].copy_from_slice(&list.entries[i].cc[..4]); - offset += 4; + if wildcard { + buf[offset] = b'*'; + } else { + buf[offset..offset + 4].copy_from_slice(&list.entries[i].cc[..4]); + } + offset += len; } offset } @@ -140,57 +148,120 @@ pub fn caps_exit_write(caps: &CapsExit, buf: &mut [u8]) -> usize { /* ── E-RTMP v2 videoFourCcInfoMap ── */ -/// Parse a video FourCC info map. +/// Parse a video FourCC info map from the legacy binary helper representation. +/// New encodings append a UI32 capability mask to each key; the old key-only +/// representation remains accepted and is interpreted as supporting all flags. pub fn video_fourcc_info_map_parse(map: &mut VideoFourCcInfoMap, data: &[u8]) -> Result { if data.len() < 4 { return Err(ErrorCode::Io); } - map.count = 0; - - let count = ((data[0] as u32) << 24) - | ((data[1] as u32) << 16) - | ((data[2] as u32) << 8) - | (data[3] as u32); - let count = count.min(crate::types::MAX_FOURCCS as u32); + *map = VideoFourCcInfoMap::default(); + let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) + .min(crate::types::MAX_FOURCCS as u32) as usize; + let legacy_key_only = data.len() == 4 + count * 6; let mut offset = 4; + for _ in 0..count { - if offset + 6 > data.len() { - break; + if offset + 2 > data.len() { + return Err(ErrorCode::Io); } - let slen = ((data[offset] as u16) << 8) | (data[offset + 1] as u16); + let slen = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize; offset += 2; - if slen != 4 || offset + 4 > data.len() { - break; + if !matches!(slen, 1 | 4) || offset + slen > data.len() { + return Err(ErrorCode::Io); } - map.entries[map.count].cc[..4].copy_from_slice(&data[offset..offset + 4]); + + let entry = &mut map.entries[map.count]; + if slen == 1 && data[offset] == b'*' { + entry.cc[0] = b'*'; + } else if slen == 4 { + entry.cc[..4].copy_from_slice(&data[offset..offset + 4]); + } else { + return Err(ErrorCode::Io); + } + offset += slen; + + map.masks[map.count] = if legacy_key_only { + FOUR_CC_INFO_ALL + } else { + if offset + 4 > data.len() { + return Err(ErrorCode::Io); + } + let mask = u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + offset += 4; + mask + }; map.count += 1; - offset += 4; } Ok(map.count) } -/// Write a video FourCC info map. Returns bytes written. +/// Write the binary helper representation with a UI32 capability mask per key. pub fn video_fourcc_info_map_write(map: &VideoFourCcInfoMap, buf: &mut [u8]) -> usize { - let needed = 4 + map.count * 6; + let needed = 4 + + (0..map.count) + .map(|i| if map.entries[i].cc[0] == b'*' { 7 } else { 10 }) + .sum::(); if buf.len() < needed { return 0; } - // Write count as big-endian u32 to match video_fourcc_info_map_parse's big-endian read. - let cnt = map.count as u32; - buf[0] = (cnt >> 24) as u8; - buf[1] = (cnt >> 16) as u8; - buf[2] = (cnt >> 8) as u8; - buf[3] = cnt as u8; - + buf[..4].copy_from_slice(&(map.count as u32).to_be_bytes()); let mut offset = 4; for i in 0..map.count { - buf[offset] = 0; - buf[offset + 1] = 4; + let wildcard = map.entries[i].cc[0] == b'*'; + let len = if wildcard { 1 } else { 4 }; + buf[offset..offset + 2].copy_from_slice(&(len as u16).to_be_bytes()); offset += 2; - buf[offset..offset + 4].copy_from_slice(&map.entries[i].cc[..4]); + if wildcard { + buf[offset] = b'*'; + } else { + buf[offset..offset + 4].copy_from_slice(&map.entries[i].cc[..4]); + } + offset += len; + buf[offset..offset + 4].copy_from_slice(&map.masks[i].to_be_bytes()); offset += 4; } offset } + +#[cfg(test)] +mod video_map_tests { + use super::*; + use crate::types::{FOUR_CC_INFO_CAN_DECODE, FOUR_CC_INFO_CAN_FORWARD}; + + #[test] + fn fourcc_list_round_trips_wildcard() { + let mut list = FourCcList::default(); + fourcc_list_add(&mut list, b"*").unwrap(); + let mut wire = [0u8; 32]; + let len = fourcc_list_write(&list, &mut wire); + let mut parsed = FourCcList::default(); + fourcc_list_parse(&mut parsed, &wire[..len]).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(parsed.entries[0].cc[0], b'*'); + } + + #[test] + fn video_info_map_round_trips_flags() { + let mut map = VideoFourCcInfoMap::default(); + map.entries[0].cc[..4].copy_from_slice(b"vp09"); + map.masks[0] = FOUR_CC_INFO_CAN_DECODE | FOUR_CC_INFO_CAN_FORWARD; + map.count = 1; + + let mut wire = [0u8; 64]; + let len = video_fourcc_info_map_write(&map, &mut wire); + let mut parsed = VideoFourCcInfoMap::default(); + video_fourcc_info_map_parse(&mut parsed, &wire[..len]).unwrap(); + + assert_eq!(parsed.count, 1); + assert_eq!(&parsed.entries[0].cc[..4], b"vp09"); + assert_eq!(parsed.masks[0], map.masks[0]); + } +} diff --git a/src/ertmp/fourcc.rs b/src/ertmp/fourcc.rs index 85091de..a4667b9 100644 --- a/src/ertmp/fourcc.rs +++ b/src/ertmp/fourcc.rs @@ -149,24 +149,15 @@ mod tests { #[test] fn video_fourcc_roundtrip() { - assert_eq!( - fourcc_to_video_codec(b"avc1").unwrap(), - VideoCodec::H264 - ); - assert_eq!( - fourcc_to_video_codec(b"hvc1").unwrap(), - VideoCodec::H265 - ); + assert_eq!(fourcc_to_video_codec(b"avc1").unwrap(), VideoCodec::H264); + assert_eq!(fourcc_to_video_codec(b"hvc1").unwrap(), VideoCodec::H265); assert_eq!(fourcc_to_video_codec(b"av01").unwrap(), VideoCodec::Av1); assert_eq!(video_codec_to_fourcc(VideoCodec::H264), "avc1"); } #[test] fn audio_fourcc_roundtrip() { - assert_eq!( - fourcc_to_audio_codec(b"mp4a").unwrap(), - AudioCodec::Aac - ); + assert_eq!(fourcc_to_audio_codec(b"mp4a").unwrap(), AudioCodec::Aac); assert_eq!(fourcc_to_audio_codec(b"Opus").unwrap(), AudioCodec::Opus); assert_eq!(audio_codec_to_fourcc(AudioCodec::Aac), "mp4a"); } diff --git a/src/ertmp/mod.rs b/src/ertmp/mod.rs index 6cb1b84..19b35f3 100644 --- a/src/ertmp/mod.rs +++ b/src/ertmp/mod.rs @@ -2,6 +2,7 @@ //! //! Mirrors `src/ertmp/` directory. +pub mod connect_amf; pub mod connect_caps; pub mod exaudio; pub mod exvideo; @@ -9,8 +10,10 @@ pub mod fourcc; pub mod metadata; pub mod modex; pub mod multitrack; +pub mod multitrack_media; pub mod reconnect; +pub use connect_amf::*; pub use connect_caps::*; pub use exaudio::*; pub use exvideo::*; @@ -18,4 +21,5 @@ pub use fourcc::*; pub use metadata::*; pub use modex::*; pub use multitrack::*; +pub use multitrack_media::*; pub use reconnect::*; diff --git a/src/ertmp/multitrack.rs b/src/ertmp/multitrack.rs index c08396a..292e99e 100644 --- a/src/ertmp/multitrack.rs +++ b/src/ertmp/multitrack.rs @@ -91,7 +91,10 @@ mod tests { // A spec-compliant AMF0 encoding of 1.0 is the IEEE-754 bit pattern // 0x3FF0000000000000, not the raw integer 1. - assert_eq!(&buf[1..9], &[0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + assert_eq!( + &buf[1..9], + &[0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + ); let mut out = Multitrack::default(); multitrack_parse(&mut out, &buf[..n]).unwrap(); diff --git a/src/ertmp/multitrack_media.rs b/src/ertmp/multitrack_media.rs new file mode 100644 index 0000000..2c8292c --- /dev/null +++ b/src/ertmp/multitrack_media.rs @@ -0,0 +1,220 @@ +//! E-RTMP v2 multitrack audio/video message parsing for the session hot path. +//! +//! Multitrack containers are relayed opaque; this module extracts per-track +//! slices and codec metadata for callbacks, authorization, and init caching. + +use crate::types::FrameType; + +pub const ERTMP_AUDIO_PACKET_TYPE_MULTITRACK: u8 = 5; +pub const ERTMP_VIDEO_PACKET_TYPE_MULTITRACK: u8 = 6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum AvMultitrackType { + OneTrack = 0, + ManyTracks = 1, + ManyTracksManyCodecs = 2, +} + +#[derive(Debug, Clone, Copy)] +pub struct MediaTrackSlice<'a> { + pub track_id: u8, + pub packet_type: u8, + pub fourcc: [u8; 4], + pub video_frame_type: u8, + pub payload: &'a [u8], +} + +fn read_u24(data: &[u8]) -> usize { + ((data[0] as usize) << 16) | ((data[1] as usize) << 8) | data[2] as usize +} + +pub fn is_multitrack_container(frame_type: FrameType, payload: &[u8]) -> bool { + if payload.is_empty() || payload[0] & 0x80 == 0 { + return false; + } + let expected = match frame_type { + FrameType::Video => ERTMP_VIDEO_PACKET_TYPE_MULTITRACK, + FrameType::Audio => ERTMP_AUDIO_PACKET_TYPE_MULTITRACK, + _ => return false, + }; + (payload[0] & 0x0F) == expected +} + +pub fn foreach_track( + frame_type: FrameType, + payload: &[u8], + mut visit: impl FnMut(&MediaTrackSlice<'_>), +) -> bool { + if !is_multitrack_container(frame_type, payload) || payload.len() < 3 { + return false; + } + + let multitrack_type = (payload[1] >> 4) & 0x0F; + let inner_packet_type = payload[1] & 0x0F; + if multitrack_type > AvMultitrackType::ManyTracksManyCodecs as u8 { + return false; + } + + let video_frame_type = if frame_type == FrameType::Video { + (payload[0] >> 4) & 0x07 + } else { + 0 + }; + let mut pos = 2usize; + let mut shared_fourcc = [0u8; 4]; + + if multitrack_type != AvMultitrackType::ManyTracksManyCodecs as u8 { + if pos + 4 > payload.len() { + return false; + } + shared_fourcc.copy_from_slice(&payload[pos..pos + 4]); + pos += 4; + } + + let mut tracks = Vec::new(); + loop { + let fourcc = if multitrack_type == AvMultitrackType::ManyTracksManyCodecs as u8 { + if pos + 4 > payload.len() { + return false; + } + let mut cc = [0u8; 4]; + cc.copy_from_slice(&payload[pos..pos + 4]); + pos += 4; + cc + } else { + shared_fourcc + }; + + if pos >= payload.len() { + return false; + } + let track_id = payload[pos]; + pos += 1; + + let track_size = if multitrack_type != AvMultitrackType::OneTrack as u8 { + if pos + 3 > payload.len() { + return false; + } + let size = read_u24(&payload[pos..pos + 3]); + pos += 3; + size + } else { + payload.len() - pos + }; + + if pos + track_size > payload.len() { + return false; + } + tracks.push(MediaTrackSlice { + track_id, + packet_type: inner_packet_type, + fourcc, + video_frame_type, + payload: &payload[pos..pos + track_size], + }); + pos += track_size; + + if multitrack_type == AvMultitrackType::OneTrack as u8 { + break; + } + if pos == payload.len() { + break; + } + } + + if pos != payload.len() || tracks.is_empty() { + return false; + } + for track in &tracks { + visit(track); + } + true +} + +pub fn first_track_fourcc(frame_type: FrameType, payload: &[u8]) -> Option<[u8; 4]> { + let mut result = None; + if foreach_track(frame_type, payload, |track| { + if result.is_none() { + result = Some(track.fourcc); + } + }) { + result + } else { + None + } +} + +pub fn multitrack_has_sequence_start(frame_type: FrameType, payload: &[u8]) -> bool { + let mut found = false; + let valid = foreach_track(frame_type, payload, |track| { + found |= track.packet_type == 0; + }); + valid && found +} + +pub fn multitrack_has_keyframe(payload: &[u8]) -> bool { + if !is_multitrack_container(FrameType::Video, payload) { + return false; + } + let packet_type = payload.get(1).map(|b| b & 0x0F); + let coded = matches!(packet_type, Some(1 | 3)); + coded && ((payload[0] >> 4) & 0x07) == 1 && foreach_track(FrameType::Video, payload, |_| {}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_many_tracks_video_message() -> Vec { + vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC, 0x01, + 0x00, 0x00, 0x02, 0xDD, 0xEE, + ] + } + + #[test] + fn detects_multitrack_container() { + let payload = build_many_tracks_video_message(); + assert!(is_multitrack_container(FrameType::Video, &payload)); + } + + #[test] + fn iterates_subtracks_with_shared_codec() { + let payload = build_many_tracks_video_message(); + let mut seen = Vec::new(); + assert!(foreach_track(FrameType::Video, &payload, |track| { + seen.push((track.track_id, track.fourcc, track.payload.to_vec())); + })); + assert_eq!(seen[0], (0, *b"avc1", vec![0xAA, 0xBB, 0xCC])); + assert_eq!(seen[1], (1, *b"avc1", vec![0xDD, 0xEE])); + } + + #[test] + fn iterates_many_tracks_many_codecs() { + let payload = vec![ + 0x86, 0x20, b'a', b'v', b'c', b'1', 0, 0, 0, 1, 0xAA, b'h', b'v', b'c', b'1', 1, 0, 0, + 1, 0xBB, + ]; + let mut codecs = Vec::new(); + assert!(foreach_track(FrameType::Video, &payload, |track| { + codecs.push(track.fourcc); + })); + assert_eq!(codecs, vec![*b"avc1", *b"hvc1"]); + } + + #[test] + fn malformed_message_delivers_no_partial_tracks() { + let mut payload = build_many_tracks_video_message(); + payload.pop(); + let mut calls = 0; + assert!(!foreach_track(FrameType::Video, &payload, |_| calls += 1)); + assert_eq!(calls, 0); + } + + #[test] + fn coded_frames_x_keyframe_is_detected() { + let payload = vec![0x96, 0x13, b'a', b'v', b'c', b'1', 0, 0, 0, 1, 0xAA]; + assert!(multitrack_has_keyframe(&payload)); + } +} diff --git a/src/flv/video_tag.rs b/src/flv/video_tag.rs index ea3d058..f696769 100644 --- a/src/flv/video_tag.rs +++ b/src/flv/video_tag.rs @@ -97,10 +97,7 @@ mod tests { fn parse_error_leaves_tag_cleared() { let mut tag = VideoTag::default(); parse(&[0x17, 0x01, 0x00, 0x01, 0x2C], &mut tag).unwrap(); - assert_eq!( - parse(&[0x18, 0x00], &mut tag), - Err(ErrorCode::Unsupported) - ); + assert_eq!(parse(&[0x18, 0x00], &mut tag), Err(ErrorCode::Unsupported)); assert_eq!(tag.codec, VideoCodec::Jpeg); assert_eq!(tag.avc_packet_type, 0); assert_eq!(tag.composition_time, 0); diff --git a/src/handshake.rs b/src/handshake.rs index 6842b74..fdb8431 100644 --- a/src/handshake.rs +++ b/src/handshake.rs @@ -98,6 +98,12 @@ fn get_time() -> u32 { .as_secs() as u32 } +/// True when C1 advertises the Adobe digest/complex handshake schema. +#[allow(dead_code)] +fn c1_requests_complex_handshake(c1: &[u8]) -> bool { + c1.len() >= HANDSHAKE_SIZE && (c1[4] != 0 || c1[5] != 0 || c1[6] != 0 || c1[7] != 0) +} + /* ── Server-side handshake ── */ /// Initialize a server-side handshake. @@ -336,7 +342,25 @@ mod tests { time2 >= before && time2 <= after, "S2 time2 must be the server's own time" ); - assert_eq!(&s2[8..], &c1[8..], "S2 random bytes must echo C1's random bytes"); + assert_eq!( + &s2[8..], + &c1[8..], + "S2 random bytes must echo C1's random bytes" + ); + } + + #[test] + fn complex_c1_still_gets_simple_s1s2_response() { + let mut hs = Handshake::default(); + server_init(&mut hs); + let mut c1 = vec![0u8; HANDSHAKE_SIZE]; + c1[..4].copy_from_slice(&0x1234_5678u32.to_be_bytes()); + c1[4..8].copy_from_slice(&1u32.to_be_bytes()); + let mut buf = Buffer::new(); + buf.write(&c1).unwrap(); + server_read_c1(&mut hs, &mut buf).unwrap(); + assert_eq!(hs.state, HandshakeState::ServerWaitC2); + assert!(hs.out.available() >= HANDSHAKE_SIZE * 2); } #[test] @@ -361,6 +385,10 @@ mod tests { time2 >= before && time2 <= after, "C2 time2 must be the client's own time" ); - assert_eq!(&c2[8..], &s1[8..], "C2 random bytes must echo S1's random bytes"); + assert_eq!( + &c2[8..], + &s1[8..], + "C2 random bytes must echo S1's random bytes" + ); } } diff --git a/src/lib.rs b/src/lib.rs index 86499ae..10231f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod ertmp; pub mod flv; pub mod handshake; pub mod log; +pub mod media; pub mod message; pub mod net; pub mod server; @@ -331,17 +332,9 @@ pub unsafe extern "C" fn lrtmp2_client_send_frame( let payload = if frame_ref.size == 0 || frame_ref.data.is_null() { Vec::new() } else { - unsafe { - std::slice::from_raw_parts(frame_ref.data, frame_ref.size as usize).to_vec() - } + unsafe { std::slice::from_raw_parts(frame_ref.data, frame_ref.size as usize).to_vec() } }; - match unsafe { - (*c).send_frame_payload( - frame_ref.frame_type, - frame_ref.timestamp, - &payload, - ) - } { + match unsafe { (*c).send_frame_payload(frame_ref.frame_type, frame_ref.timestamp, &payload) } { Ok(()) => 0, Err(e) => e as i32, } @@ -368,11 +361,7 @@ pub unsafe extern "C" fn lrtmp2_conn_get_fd(_conn: *const session::conn::Conn) - /// Check TLS support (FFI-compatible). #[unsafe(no_mangle)] pub extern "C" fn lrtmp2_tls_supported() -> i32 { - if tls_supported() { - 1 - } else { - 0 - } + if tls_supported() { 1 } else { 0 } } /// Get version string (FFI-compatible). diff --git a/src/log.rs b/src/log.rs index 399f7f1..c92d187 100644 --- a/src/log.rs +++ b/src/log.rs @@ -2,8 +2,8 @@ //! //! Mirrors `src/core/log.h` and `src/core/log.c`. -use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Mutex; +use std::sync::atomic::{AtomicU8, Ordering}; /// Log levels. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] diff --git a/src/media/init_cache.rs b/src/media/init_cache.rs new file mode 100644 index 0000000..f25d6b9 --- /dev/null +++ b/src/media/init_cache.rs @@ -0,0 +1,320 @@ +//! Init-frame classification and Frame population for the relay hot path. + +use crate::ertmp::multitrack_media::{ + is_multitrack_container, multitrack_has_keyframe, multitrack_has_sequence_start, +}; +use crate::ertmp::{exaudio, exvideo, fourcc}; +use crate::types::{AudioCodec, AudioHeader, FourCc, Frame, FrameType, VideoCodec, VideoHeader}; + +/// How a relayed media frame should be treated by the init-frame cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheFrameKind { + LiveOnly, + VideoSequenceHeader, + AudioSequenceHeader, + VideoKeyframe, +} + +/// Classify a publisher media frame for init-cache storage. +pub fn classify_cache_frame(frame_type: FrameType, payload: &[u8]) -> CacheFrameKind { + match frame_type { + FrameType::Video => classify_video(payload), + FrameType::Audio => classify_audio(payload), + FrameType::Script | FrameType::Metadata => CacheFrameKind::LiveOnly, + } +} + +fn classify_video(payload: &[u8]) -> CacheFrameKind { + if is_multitrack_container(FrameType::Video, payload) { + if multitrack_has_sequence_start(FrameType::Video, payload) { + return CacheFrameKind::VideoSequenceHeader; + } + if multitrack_has_keyframe(payload) { + return CacheFrameKind::VideoKeyframe; + } + return CacheFrameKind::LiveOnly; + } + let mut hdr = VideoHeader::default(); + if exvideo::exvideo_parse(payload, &mut hdr).is_err() { + return CacheFrameKind::LiveOnly; + } + if hdr.is_ex_header != 0 { + return match hdr.packet_type { + 0 => CacheFrameKind::VideoSequenceHeader, + 1 | 3 if hdr.frame_type == 1 => CacheFrameKind::VideoKeyframe, + _ => CacheFrameKind::LiveOnly, + }; + } + let codec_id = payload[0] & 0x0F; + let frame_type_nibble = (payload[0] >> 4) & 0x0F; + if codec_id == 7 && payload.len() >= 2 && payload[1] == 0 { + return CacheFrameKind::VideoSequenceHeader; + } + if frame_type_nibble == 1 { + return CacheFrameKind::VideoKeyframe; + } + CacheFrameKind::LiveOnly +} + +fn classify_audio(payload: &[u8]) -> CacheFrameKind { + if is_multitrack_container(FrameType::Audio, payload) + && multitrack_has_sequence_start(FrameType::Audio, payload) + { + return CacheFrameKind::AudioSequenceHeader; + } + let mut hdr = AudioHeader::default(); + if exaudio::exaudio_parse(payload, &mut hdr).is_err() { + return CacheFrameKind::LiveOnly; + } + if hdr.is_ex_header != 0 { + if hdr.packet_type == 0 { + CacheFrameKind::AudioSequenceHeader + } else { + CacheFrameKind::LiveOnly + } + } else if hdr.audio_codec == AudioCodec::Aac && hdr.aac_packet_type == 0 { + CacheFrameKind::AudioSequenceHeader + } else { + CacheFrameKind::LiveOnly + } +} + +/// Fill codec/header fields on a [`Frame`] from raw RTMP A/V payload bytes. +/// Fill codec/header fields for a per-track callback extracted from an E-RTMP +/// multitrack container. Inner track payloads do not include the enhanced tag +/// header or FourCC, so metadata comes from the track descriptor. +pub fn populate_multitrack_frame( + frame: &mut Frame, + fourcc_value: [u8; 4], + packet_type: u8, + video_frame_type: u8, +) { + let mut cc = [0u8; 5]; + cc[..4].copy_from_slice(&fourcc_value); + match frame.frame_type { + FrameType::Video => { + frame.video_fourcc = FourCc { cc }; + frame.video_frame_type = video_frame_type; + if let Ok(codec) = fourcc::fourcc_to_video_codec(&fourcc_value) { + frame.video_codec = codec; + } + frame.is_metadata = u8::from(packet_type == crate::types::ERTMP_PACKET_TYPE_METADATA); + } + FrameType::Audio => { + frame.audio_fourcc = FourCc { cc }; + if let Ok(codec) = fourcc::fourcc_to_audio_codec(&fourcc_value) { + frame.audio_codec = codec; + } + frame.is_metadata = + u8::from(packet_type == crate::types::ERTMP_AUDIO_PACKET_TYPE_METADATA); + } + FrameType::Script | FrameType::Metadata => { + frame.is_metadata = 1; + } + } +} +pub fn populate_av_frame(frame: &mut Frame, payload: &[u8]) { + match frame.frame_type { + FrameType::Video => populate_video_frame(frame, payload), + FrameType::Audio => populate_audio_frame(frame, payload), + FrameType::Script | FrameType::Metadata => { + frame.is_metadata = 1; + } + } +} + +fn populate_video_frame(frame: &mut Frame, payload: &[u8]) { + let mut hdr = VideoHeader::default(); + if exvideo::exvideo_parse(payload, &mut hdr).is_err() { + return; + } + frame.composition_time = hdr.composition_time; + frame.video_frame_type = hdr.frame_type; + frame.video_fourcc = FourCc { cc: hdr.fourcc }; + frame.video_codec = if hdr.is_ex_header != 0 { + fourcc::fourcc_to_video_codec(&hdr.fourcc).unwrap_or(VideoCodec::H264) + } else { + legacy_video_codec(payload[0] & 0x0F) + }; +} + +fn populate_audio_frame(frame: &mut Frame, payload: &[u8]) { + let mut hdr = AudioHeader::default(); + if exaudio::exaudio_parse(payload, &mut hdr).is_err() { + return; + } + frame.audio_codec = hdr.audio_codec; + frame.audio_sample_rate = hdr.sample_rate as u32; + frame.audio_channels = hdr.channels; + frame.audio_bit_depth = if hdr.sample_size != 0 { 16 } else { 8 }; + frame.audio_fourcc = FourCc { cc: hdr.fourcc }; +} + +fn legacy_video_codec(codec_id: u8) -> VideoCodec { + match codec_id { + 7 => VideoCodec::H264, + 12 => VideoCodec::H265, + 13 => VideoCodec::Av1, + 14 => VideoCodec::Vp9, + _ => VideoCodec::H264, + } +} + +/// Returns true when `payload` is an AMF0 onMetaData (or @setDataFrame/onMetaData) message. +pub fn is_on_metadata_payload(payload: &[u8]) -> bool { + let mut buf = crate::buffer::Buffer::from_slice(payload); + let first_byte = match buf.peek().first().copied() { + Some(b) => b, + None => return false, + }; + if first_byte != crate::amf::amf0::Amf0Type::String as u8 + && first_byte != crate::amf::amf0::Amf0Type::LongString as u8 + { + return false; + } + + let mut name = [0u8; 64]; + let name_len = match read_data_event_name( + &mut buf, + first_byte == crate::amf::amf0::Amf0Type::String as u8, + &mut name, + ) { + Some(n) => n, + None => return false, + }; + let name_str = std::str::from_utf8(&name[..name_len]).unwrap_or(""); + + if name_str == "@setDataFrame" { + let next_byte = match buf.peek().first().copied() { + Some(b) => b, + None => return false, + }; + if next_byte != crate::amf::amf0::Amf0Type::String as u8 + && next_byte != crate::amf::amf0::Amf0Type::LongString as u8 + { + return false; + } + let mut inner = [0u8; 64]; + let inner_len = match read_data_event_name( + &mut buf, + next_byte == crate::amf::amf0::Amf0Type::String as u8, + &mut inner, + ) { + Some(n) => n, + None => return false, + }; + let inner_str = std::str::from_utf8(&inner[..inner_len]).unwrap_or(""); + inner_str == "onMetaData" + } else { + name_str == "onMetaData" + } +} + +fn read_data_event_name( + buf: &mut crate::buffer::Buffer, + is_string: bool, + out: &mut [u8; 64], +) -> Option { + match if is_string { + crate::amf::amf0::read_string(buf, out) + } else { + crate::amf::amf0::read_long_string(buf, out) + } { + Ok(n) => Some(n), + Err(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn multitrack_video_callback_metadata_uses_track_descriptor() { + let mut frame = Frame { + frame_type: FrameType::Video, + ..Default::default() + }; + populate_multitrack_frame(&mut frame, *b"hvc1", 1, 1); + assert_eq!(&frame.video_fourcc.cc[..4], b"hvc1"); + assert_eq!(frame.video_codec, VideoCodec::H265); + assert_eq!(frame.video_frame_type, 1); + } + + #[test] + fn multitrack_audio_callback_metadata_uses_track_descriptor() { + let mut frame = Frame { + frame_type: FrameType::Audio, + ..Default::default() + }; + populate_multitrack_frame(&mut frame, *b"Opus", 1, 0); + assert_eq!(&frame.audio_fourcc.cc[..4], b"Opus"); + assert_eq!(frame.audio_codec, AudioCodec::Opus); + } + #[test] + fn enhanced_hevc_sequence_start_is_cached() { + let payload = [0x90, b'h', b'v', b'c', b'1', 0x01, 0x02]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoSequenceHeader + ); + } + + #[test] + fn enhanced_av1_keyframe_is_cached() { + let payload = [0x91, b'a', b'v', b'0', b'1', 0xDE, 0xAD]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoKeyframe + ); + } + + #[test] + fn enhanced_coded_frames_x_keyframe_is_cached() { + let payload = [0x93, b'a', b'v', b'c', b'1', 0xDE, 0xAD]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoKeyframe + ); + } + + #[test] + fn enhanced_opus_sequence_start_is_cached() { + let payload = [0x90, b'O', b'p', b'u', b's']; + assert_eq!( + classify_cache_frame(FrameType::Audio, &payload), + CacheFrameKind::AudioSequenceHeader + ); + } + + #[test] + fn multitrack_video_sequence_start_is_cached() { + let payload = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC, + ]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoSequenceHeader + ); + } + + #[test] + fn multitrack_video_keyframe_is_cached_from_outer_header() { + let payload = vec![ + 0x96, 0x11, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x02, 0xDE, 0xAD, + ]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoKeyframe + ); + } + + #[test] + fn legacy_avc_header_still_cached() { + let payload = [0x17, 0x00, 0x01]; + assert_eq!( + classify_cache_frame(FrameType::Video, &payload), + CacheFrameKind::VideoSequenceHeader + ); + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..2488581 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,7 @@ +//! Media helpers shared by the session hot path and server relay cache. + +pub mod init_cache; +pub mod modex; + +pub use init_cache::*; +pub use modex::*; diff --git a/src/media/modex.rs b/src/media/modex.rs new file mode 100644 index 0000000..95cdc6a --- /dev/null +++ b/src/media/modex.rs @@ -0,0 +1,86 @@ +//! Helpers for parsing E-RTMP v2 ModEx wrappers without changing relay bytes. + +use std::borrow::Cow; + +use crate::types::CAPS_EX_MASK_MODEX; + +pub fn normalize_modex_payload<'a>(payload: &'a [u8], caps_ex_mask: u32) -> Cow<'a, [u8]> { + if payload.is_empty() + || (caps_ex_mask & CAPS_EX_MASK_MODEX) == 0 + || payload[0] & 0x80 == 0 + || payload[0] & 0x0F != 7 + { + return Cow::Borrowed(payload); + } + + let mut pos = 1usize; + let mut reconstructed_header = payload[0]; + loop { + if pos >= payload.len() { + return Cow::Borrowed(payload); + } + let size_minus_one = payload[pos]; + pos += 1; + let modex_size = if size_minus_one == u8::MAX { + if pos + 2 > payload.len() { + return Cow::Borrowed(payload); + } + let size = u16::from_be_bytes([payload[pos], payload[pos + 1]]) as usize + 1; + pos += 2; + size + } else { + size_minus_one as usize + 1 + }; + + if pos + modex_size + 1 > payload.len() { + return Cow::Borrowed(payload); + } + pos += modex_size; + let option_and_next_packet = payload[pos]; + pos += 1; + let next_packet_type = option_and_next_packet & 0x0F; + reconstructed_header = (reconstructed_header & 0xF0) | next_packet_type; + if next_packet_type != 7 { + break; + } + } + + let mut normalized = Vec::with_capacity(1 + payload.len().saturating_sub(pos)); + normalized.push(reconstructed_header); + normalized.extend_from_slice(&payload[pos..]); + Cow::Owned(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_timestamp_modex_video_wrapper() { + let payload = [ + 0x97, 0x02, 0x00, 0x01, 0x02, 0x01, b'a', b'v', b'c', b'1', 0, 0, 0, 0xAA, + ]; + assert_eq!( + normalize_modex_payload(&payload, CAPS_EX_MASK_MODEX).as_ref(), + &[0x91, b'a', b'v', b'c', b'1', 0, 0, 0, 0xAA] + ); + } + + #[test] + fn legacy_aac_is_unchanged() { + let payload = [0xAF, 0x00, 0x12, 0x10]; + assert!(matches!( + normalize_modex_payload(&payload, CAPS_EX_MASK_MODEX), + Cow::Borrowed(_) + )); + } + + #[test] + fn malformed_modex_is_left_opaque() { + let payload = [0x97, 0x05, 0x00]; + assert_eq!( + normalize_modex_payload(&payload, CAPS_EX_MASK_MODEX).as_ref(), + payload + ); + } +} diff --git a/src/message/command.rs b/src/message/command.rs index a31edc2..d5adad1 100644 --- a/src/message/command.rs +++ b/src/message/command.rs @@ -4,8 +4,11 @@ use crate::amf::amf0; use crate::buffer::Buffer; +use crate::ertmp::connect_amf; +use crate::types::CAPS_EX_MASK_MULTITRACK; use crate::types::ConnectInfo; use crate::types::ErrorCode; +use crate::types::NegotiatedCaps; use crate::types::Result; /// Maximum key/value pairs in a connect object @@ -23,6 +26,7 @@ pub fn build_connect( flash_ver: &str, audio_codecs: i32, video_codecs: i32, + caps: Option<&NegotiatedCaps>, ) -> Result<()> { macro_rules! chk { ($expr:expr) => { @@ -57,11 +61,35 @@ pub fn build_connect( chk!(amf0::write_number(buf, audio_codecs as f64)); chk!(amf0::write_object_key(buf, "videoCodecs")); chk!(amf0::write_number(buf, video_codecs as f64)); + if let Some(caps) = caps { + connect_amf::write_negotiated_caps(buf, caps)?; + } chk!(amf0::write_object_end(buf)); Ok(()) } +/// Build a NetConnection `_error` response. +pub fn build_error( + buf: &mut Buffer, + transaction_id: f64, + code: &str, + description: &str, +) -> Result<()> { + amf0::write_string(buf, "_error")?; + amf0::write_number(buf, transaction_id)?; + amf0::write_null(buf)?; + amf0::write_object_begin(buf)?; + amf0::write_object_key(buf, "level")?; + amf0::write_string(buf, "error")?; + amf0::write_object_key(buf, "code")?; + amf0::write_string(buf, code)?; + amf0::write_object_key(buf, "description")?; + amf0::write_string(buf, description)?; + amf0::write_object_end(buf)?; + Ok(()) +} + /// Build a "releaseStream" command. pub fn build_release_stream(buf: &mut Buffer, stream_name: &str) -> Result<()> { amf0::write_string(buf, "releaseStream")?; @@ -189,6 +217,55 @@ pub fn read_connect(buf: &mut Buffer, info: &mut ConnectInfo) -> Result<()> { let mut key = [0u8; 256]; let key_len = amf0::read_object_key(buf, &mut key)?; + let key_str = std::str::from_utf8(&key[..key_len]).unwrap_or(""); + + if key_str == "fourCcList" { + if connect_amf::read_four_cc_list_amf(buf, &mut info.four_cc_list).is_ok() { + info.has_four_cc_list = true; + } else { + let type_pos = buf.read_pos(); + let _ = amf0::read_type(buf); + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + continue; + } + if key_str == "capsEx" { + if connect_amf::read_caps_ex_amf(buf, &mut info.caps_ex, &mut info.caps_ex_mask).is_ok() + { + info.has_caps_ex = true; + } else { + let type_pos = buf.read_pos(); + let _ = amf0::read_type(buf); + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + continue; + } + if key_str == "videoFourCcInfoMap" { + if connect_amf::read_video_fourcc_info_map_amf(buf, &mut info.video_four_cc_info_map) + .is_ok() + { + info.has_video_four_cc_info_map = true; + } else { + let type_pos = buf.read_pos(); + let _ = amf0::read_type(buf); + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + continue; + } + if key_str == "reconnect" { + if connect_amf::read_reconnect_amf(buf, &mut info.reconnect).is_ok() { + info.has_reconnect = true; + } else { + let type_pos = buf.read_pos(); + let _ = amf0::read_type(buf); + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + continue; + } // Peek value type let type_pos = buf.read_pos(); @@ -196,22 +273,18 @@ pub fn read_connect(buf: &mut Buffer, info: &mut ConnectInfo) -> Result<()> { buf.set_read_pos(type_pos); // restore match value_type { - amf0::Amf0Type::String => { - let key_str = std::str::from_utf8(&key[..key_len]).unwrap_or(""); - match key_str { - "app" => read_string_checked(buf, &mut info.app)?, - "tcUrl" => read_string_checked(buf, &mut info.tc_url)?, - "pageUrl" => read_string_checked(buf, &mut info.page_url)?, - "swfUrl" => read_string_checked(buf, &mut info.swf_url)?, - "flashVer" => read_string_checked(buf, &mut info.flash_ver)?, - _ => { - amf0::skip_value(buf)?; - } + amf0::Amf0Type::String => match key_str { + "app" => read_string_checked(buf, &mut info.app)?, + "tcUrl" => read_string_checked(buf, &mut info.tc_url)?, + "pageUrl" => read_string_checked(buf, &mut info.page_url)?, + "swfUrl" => read_string_checked(buf, &mut info.swf_url)?, + "flashVer" => read_string_checked(buf, &mut info.flash_ver)?, + _ => { + amf0::skip_value(buf)?; } - } + }, amf0::Amf0Type::Number => { let value = read_number_value(buf)?; - let key_str = std::str::from_utf8(&key[..key_len]).unwrap_or(""); match key_str { "audioCodecs" => info.audio_codecs = value as i32, "videoCodecs" => info.video_codecs = value as i32, @@ -270,16 +343,162 @@ pub fn read_play(buf: &mut Buffer, stream_name: &mut [u8]) -> Result<()> { Ok(()) } +/// Read a pause command. Returns the pause flag (true = pause, false = unpause). +pub fn read_pause(buf: &mut Buffer) -> Result { + let mut name = [0u8; 64]; + amf0::read_string(buf, &mut name)?; + read_number_value(buf)?; + amf0::skip_value(buf)?; + read_bool_value(buf) +} + +/// Read a seek command. Returns the target time in milliseconds. +pub fn read_seek(buf: &mut Buffer) -> Result { + let mut name = [0u8; 64]; + amf0::read_string(buf, &mut name)?; + read_number_value(buf)?; + amf0::skip_value(buf)?; + read_number_value(buf) +} + +/// Read receiveAudio / receiveVideo: the boolean enable flag. +pub fn read_bool_command(buf: &mut Buffer) -> Result { + let mut name = [0u8; 64]; + amf0::read_string(buf, &mut name)?; + read_number_value(buf)?; + amf0::skip_value(buf)?; + read_bool_value(buf) +} + +/// Read a closeStream command. Returns `None` when the peer sends the usual +/// three-argument form (stream id comes from the RTMP message stream id). +pub fn read_close_stream(buf: &mut Buffer) -> Result> { + let mut name = [0u8; 64]; + amf0::read_string(buf, &mut name)?; + read_number_value(buf)?; + amf0::skip_value(buf)?; + if buf.available() == 0 { + return Ok(None); + } + let type_pos = buf.read_pos(); + let ty = amf0::read_type(buf)?; + if ty == amf0::Amf0Type::Number { + Ok(Some(amf0::read_number(buf)? as u32)) + } else { + buf.set_read_pos(type_pos); + Ok(None) + } +} + +/// Build a pause command. +pub fn build_pause(buf: &mut Buffer, pause: bool) -> Result<()> { + amf0::write_string(buf, "pause")?; + amf0::write_number(buf, 0.0)?; + amf0::write_null(buf)?; + amf0::write_boolean(buf, pause)?; + Ok(()) +} + +/// Build a seek command. +pub fn build_seek(buf: &mut Buffer, millis: f64) -> Result<()> { + amf0::write_string(buf, "seek")?; + amf0::write_number(buf, 0.0)?; + amf0::write_null(buf)?; + amf0::write_number(buf, millis)?; + Ok(()) +} + /// Read a connect _result response. pub fn read_connect_result(buf: &mut Buffer) -> Result { + read_connect_result_with_caps(buf, None) +} + +/// Read a connect `_result` and optionally capture negotiated E-RTMP caps. +pub fn read_connect_result_with_caps( + buf: &mut Buffer, + caps: Option<&mut NegotiatedCaps>, +) -> Result { let mut name = [0u8; 64]; amf0::read_string(buf, &mut name)?; let txn = read_number_value(buf)?; amf0::skip_value(buf)?; - amf0::skip_value(buf)?; + if let Some(caps) = caps { + parse_connect_result_caps(buf, caps)?; + } else { + amf0::skip_value(buf)?; + } Ok(txn) } +fn parse_connect_result_caps(buf: &mut Buffer, caps: &mut NegotiatedCaps) -> Result<()> { + let type_pos = buf.read_pos(); + let ty = amf0::read_type(buf)?; + if ty != amf0::Amf0Type::Object { + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + return Ok(()); + } + let mut keys = 0usize; + while !amf0::is_object_end(buf) { + keys += 1; + if keys > amf0::MAX_OBJECT_KEYS { + return Err(ErrorCode::Amf); + } + let mut key = [0u8; 256]; + let key_len = amf0::read_object_key(buf, &mut key)?; + let key_str = std::str::from_utf8(&key[..key_len]).unwrap_or(""); + let type_pos = buf.read_pos(); + match key_str { + "fourCcList" => { + if connect_amf::read_four_cc_list_amf(buf, &mut caps.four_cc_list).is_ok() { + caps.has_four_cc_list = true; + } else { + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + } + "capsEx" => { + if connect_amf::read_caps_ex_amf(buf, &mut caps.caps_ex, &mut caps.caps_ex_mask) + .is_ok() + { + caps.has_caps_ex = true; + caps.multitrack_enabled = (caps.caps_ex_mask & CAPS_EX_MASK_MULTITRACK) != 0; + } else { + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + } + "videoFourCcInfoMap" => { + if connect_amf::read_video_fourcc_info_map_amf( + buf, + &mut caps.video_four_cc_info_map, + ) + .is_ok() + { + caps.has_video_four_cc_info_map = true; + } else { + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + } + "reconnect" => { + if connect_amf::read_reconnect_amf(buf, &mut caps.reconnect).is_ok() { + caps.has_reconnect = true; + } else { + buf.set_read_pos(type_pos); + amf0::skip_value(buf)?; + } + } + _ => { + amf0::skip_value(buf)?; + } + } + } + let mut end = [0u8; 3]; + buf.read(&mut end).map_err(|_| ErrorCode::Amf)?; + Ok(()) +} + /// Read a createStream _result response. pub fn read_create_stream_result(buf: &mut Buffer) -> Result<(f64, f64)> { let mut name = [0u8; 64]; @@ -336,6 +555,15 @@ fn read_number_value(buf: &mut Buffer) -> Result { amf0::read_number(buf) } +fn read_bool_value(buf: &mut Buffer) -> Result { + let ty = amf0::read_type(buf)?; + match ty { + amf0::Amf0Type::Boolean => amf0::read_boolean(buf), + amf0::Amf0Type::Number => Ok(amf0::read_number(buf)? != 0.0), + _ => Err(ErrorCode::Amf), + } +} + fn read_string_checked(buf: &mut Buffer, out: &mut [u8]) -> Result<()> { let mut byte = [0u8; 1]; buf.read(&mut byte).map_err(|_| ErrorCode::Amf)?; @@ -378,7 +606,7 @@ mod tests { } #[test] - fn read_connect_skips_four_cc_list_strict_array() { + fn read_connect_parses_four_cc_list_strict_array() { let mut buf = Buffer::new(); amf0::write_string(&mut buf, "connect").unwrap(); amf0::write_number(&mut buf, 1.0).unwrap(); @@ -394,6 +622,9 @@ mod tests { read_connect(&mut buf, &mut info).unwrap(); let app_len = info.app.iter().position(|&b| b == 0).unwrap_or(0); assert_eq!(std::str::from_utf8(&info.app[..app_len]).unwrap(), "live"); + assert!(info.has_four_cc_list); + assert_eq!(info.four_cc_list.count, 1); + assert_eq!(&info.four_cc_list.entries[0].cc[..4], b"av01"); } #[test] @@ -472,13 +703,7 @@ mod tests { #[test] fn read_onstatus_accepts_status_level() { let mut buf = Buffer::new(); - build_onstatus( - &mut buf, - "status", - "NetStream.Publish.Start", - "Publishing", - ) - .unwrap(); + build_onstatus(&mut buf, "status", "NetStream.Publish.Start", "Publishing").unwrap(); assert!(read_onstatus(&mut buf).is_ok()); } @@ -501,4 +726,25 @@ mod tests { assert_eq!(read_onstatus(&mut buf), Err(ErrorCode::Amf)); } + + #[test] + fn read_close_stream_accepts_three_argument_form() { + let mut buf = Buffer::new(); + amf0::write_string(&mut buf, "closeStream").unwrap(); + amf0::write_number(&mut buf, 2.0).unwrap(); + amf0::write_null(&mut buf).unwrap(); + + assert_eq!(read_close_stream(&mut buf).unwrap(), None); + } + + #[test] + fn read_close_stream_accepts_explicit_stream_id() { + let mut buf = Buffer::new(); + amf0::write_string(&mut buf, "closeStream").unwrap(); + amf0::write_number(&mut buf, 2.0).unwrap(); + amf0::write_null(&mut buf).unwrap(); + amf0::write_number(&mut buf, 7.0).unwrap(); + + assert_eq!(read_close_stream(&mut buf).unwrap(), Some(7)); + } } diff --git a/src/message/message.rs b/src/message/message.rs index 7362fd8..dad3359 100644 --- a/src/message/message.rs +++ b/src/message/message.rs @@ -68,6 +68,7 @@ fn deliver_audio_frame(conn: &mut dyn Connection, timestamp: u32, payload: &[u8] video_fourcc: crate::types::FourCc { cc: [0; 5] }, video_frame_type: 0, is_metadata: 0, + track_id: u8::MAX, }; if !payload.is_empty() { @@ -111,6 +112,7 @@ fn deliver_video_frame(conn: &mut dyn Connection, timestamp: u32, payload: &[u8] video_fourcc: crate::types::FourCc { cc: [0; 5] }, video_frame_type: 0, is_metadata: 0, + track_id: u8::MAX, }; if !payload.is_empty() { diff --git a/src/server/mod.rs b/src/server/mod.rs index c3c3a77..41feeda 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -10,6 +10,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crate::chunk::state::DEFAULT_CHUNK_SIZE; +use crate::ertmp::multitrack_media::{foreach_track, is_multitrack_container}; +use crate::media::{CacheFrameKind, classify_cache_frame, normalize_modex_payload}; use crate::net; use crate::session::conn::Conn; use crate::session::publish_route::PublishRouteRegistry; @@ -60,11 +62,21 @@ const MAX_RECV_BYTES_PER_CONN_PER_POLL: usize = 256 * 1024; /// connections in this one. const MAX_BUDGET_DRAIN_PASSES_PER_CONN_PER_POLL: usize = 3; +/// Default cap on relay sends issued while fanning publisher media out to +/// players in one `process_connections` pass. Integrators can tune the active +/// value through [`Server::max_relay_sends_per_poll`]. +pub const DEFAULT_MAX_RELAY_SENDS_PER_POLL: usize = 4096; + /// Cached codec headers and last keyframe for a (app, stream_name) pair. /// Replayed to players that join after the publisher has already sent headers. struct StreamCache { avc_header: Option>, + /// Per-track video sequence headers when publishers send multitrack inits separately. + video_track_headers: HashMap>, aac_header: Option>, + audio_track_headers: HashMap>, + /// Last onMetaData AMF0 payload for late-joining players. + metadata: Option>, /// (timestamp, payload) of the most recent IDR keyframe. last_keyframe: Option<(u32, Vec)>, } @@ -96,6 +108,13 @@ struct PendingTlsConnection { pub struct Server { pub config: ServerConfig, pub resource_limits: ResourceLimits, + /// Maximum relay send operations allowed in one `process_connections` pass. + /// + /// The first eligible frame in a pass is always relayed even when its fan-out + /// exceeds this value, which guarantees forward progress instead of + /// perpetually re-queueing an oversized frame. Later frames are deferred once + /// the accumulated send count would exceed the configured budget. + pub max_relay_sends_per_poll: usize, pub running: bool, /// Identifies *one* bound listener (whichever was bound first) for /// diagnostics/backward compatibility. When more than one listener is @@ -162,6 +181,7 @@ impl Server { Ok(Self { config, resource_limits: ResourceLimits::default(), + max_relay_sends_per_poll: DEFAULT_MAX_RELAY_SENDS_PER_POLL, running: false, server_fd: -1, connections: Vec::new(), @@ -585,7 +605,7 @@ impl Server { // Collect all frames queued by publishers, then relay them to players // on the same (app, stream_name) pair. - let relay_frames: Vec<_> = self + let mut relay_frames: Vec<_> = self .connections .iter_mut() .flat_map(|c| c.pending_relay.drain(..)) @@ -606,19 +626,56 @@ impl Server { } conn.needs_init_frames = false; let key = (conn.app.clone(), conn.relay_route_key()); + let receive_audio = conn + .current_stream + .as_ref() + .map(|s| s.receive_audio) + .unwrap_or(true); + let receive_video = conn + .current_stream + .as_ref() + .map(|s| s.receive_video) + .unwrap_or(true); if let Some(cache) = self.stream_cache.get(&key) { let mut send_failed = false; - if let Some(ref hdr) = cache.avc_header.clone() { - send_failed |= conn.send_frame(FrameType::Video, 0, hdr).is_err(); + if let Some(ref md) = cache.metadata.clone() { + send_failed |= conn.send_data_message(0, md).is_err(); + } + if receive_video { + if let Some(ref hdr) = cache.avc_header.clone() { + if !Self::cached_payload_is_multitrack(FrameType::Video, hdr) + || conn.accepts_multitrack() + { + send_failed |= conn.send_frame(FrameType::Video, 0, hdr).is_err(); + } + } + for hdr in cache.video_track_headers.values() { + if conn.accepts_multitrack() { + send_failed |= conn.send_frame(FrameType::Video, 0, hdr).is_err(); + } + } } - if !send_failed { + if receive_audio && !send_failed { if let Some(ref hdr) = cache.aac_header.clone() { - send_failed |= conn.send_frame(FrameType::Audio, 0, hdr).is_err(); + if !Self::cached_payload_is_multitrack(FrameType::Audio, hdr) + || conn.accepts_multitrack() + { + send_failed |= conn.send_frame(FrameType::Audio, 0, hdr).is_err(); + } + } + for hdr in cache.audio_track_headers.values() { + if conn.accepts_multitrack() { + send_failed |= conn.send_frame(FrameType::Audio, 0, hdr).is_err(); + } } } - if !send_failed { + if receive_video && !send_failed { if let Some((ts, ref kf)) = cache.last_keyframe.clone() { - send_failed |= conn.send_frame(FrameType::Video, ts, kf).is_err(); + if !Self::cached_payload_is_multitrack(FrameType::Video, kf) + || conn.accepts_multitrack() + { + send_failed |= conn.send_frame(FrameType::Video, ts, kf).is_err(); + } } } if send_failed { @@ -634,7 +691,17 @@ impl Server { // Update per-stream cache and relay each frame in order so players // receive frames in the same sequence the publisher sent them. + let mut relay_sends = 0usize; + let mut relay_processed = 0usize; for frame in &relay_frames { + let player_count = self.count_relay_players(frame); + if player_count > 0 + && relay_sends > 0 + && relay_sends.saturating_add(player_count) > self.max_relay_sends_per_poll + { + break; + } + let abandon_key = ( frame.app.clone(), frame.stream_name.clone(), @@ -644,20 +711,16 @@ impl Server { self.cache_relay_frame(frame); } for (i, conn) in self.connections.iter_mut().enumerate() { - let is_player = conn.relay_enabled - && conn.transport.is_some() - && conn - .current_stream - .as_ref() - .map(|s| s.is_playing && conn.relay_route_key() == frame.stream_name) - .unwrap_or(false); - if !is_player || conn.app != frame.app { + if !Self::conn_will_receive_relay_frame(conn, frame) { continue; } - if conn - .send_frame(frame.frame_type, frame.timestamp, &frame.payload) - .is_err() - { + let send_result = match frame.frame_type { + FrameType::Script | FrameType::Metadata => { + conn.send_data_message(frame.timestamp, &frame.payload) + } + _ => conn.send_frame(frame.frame_type, frame.timestamp, &frame.payload), + }; + if send_result.is_err() { // Player stopped reading; outbound send_buffer is full. // Drop the connection immediately so later relay frames in // this poll batch skip it and no more socket work is done. @@ -667,6 +730,11 @@ impl Server { closed.push(i); } } + relay_sends += player_count; + relay_processed += 1; + } + for frame in relay_frames.drain(relay_processed..) { + self.requeue_relay_frame(frame); } // Flush all connections. @@ -715,10 +783,69 @@ impl Server { Ok(()) } + fn conn_will_receive_relay_frame( + conn: &Conn, + frame: &crate::session::conn::RelayFrame, + ) -> bool { + let Some(stream) = conn.current_stream.as_ref() else { + return false; + }; + if !conn.relay_enabled + || conn.transport.is_none() + || conn.app != frame.app + || !stream.is_playing + || conn.relay_route_key() != frame.stream_name + || stream.paused + { + return false; + } + if frame.frame_type == FrameType::Audio && !stream.receive_audio { + return false; + } + if frame.frame_type == FrameType::Video && !stream.receive_video { + return false; + } + if matches!(frame.frame_type, FrameType::Audio | FrameType::Video) + && is_multitrack_container(frame.frame_type, frame.cache_payload()) + && !conn.accepts_multitrack() + { + return false; + } + true + } + + fn count_relay_players(&self, frame: &crate::session::conn::RelayFrame) -> usize { + self.connections + .iter() + .filter(|conn| Self::conn_will_receive_relay_frame(conn, frame)) + .count() + } + + fn requeue_relay_frame(&mut self, frame: crate::session::conn::RelayFrame) { + if let Some(conn) = self + .connections + .iter_mut() + .find(|conn| conn.conn_id == frame.publisher_conn_id) + { + conn.pending_relay.push(frame); + } + } + /// Bytes retained by a single stream_cache entry. fn stream_cache_entry_bytes(cache: &StreamCache) -> usize { cache.avc_header.as_ref().map(|v| v.len()).unwrap_or(0) + + cache + .video_track_headers + .values() + .map(|v| v.len()) + .sum::() + cache.aac_header.as_ref().map(|v| v.len()).unwrap_or(0) + + cache + .audio_track_headers + .values() + .map(|v| v.len()) + .sum::() + + cache.metadata.as_ref().map(|v| v.len()).unwrap_or(0) + cache .last_keyframe .as_ref() @@ -741,19 +868,103 @@ impl Server { } } + fn stream_cache_is_empty(cache: &StreamCache) -> bool { + cache.avc_header.is_none() + && cache.video_track_headers.is_empty() + && cache.aac_header.is_none() + && cache.audio_track_headers.is_empty() + && cache.metadata.is_none() + && cache.last_keyframe.is_none() + } + + fn cached_payload_is_multitrack(frame_type: FrameType, payload: &[u8]) -> bool { + let normalized = normalize_modex_payload(payload, CAPS_EX_MASK_MODEX); + is_multitrack_container(frame_type, normalized.as_ref()) + } + + fn multitrack_sequence_track_ids(frame_type: FrameType, payload: &[u8]) -> Vec { + let mut ids = Vec::new(); + if is_multitrack_container(frame_type, payload) { + foreach_track(frame_type, payload, |track| { + if track.packet_type == 0 { + ids.push(track.track_id); + } + }); + } + ids + } + + fn reserve_stream_cache_storage( + &mut self, + key: &(String, String), + incoming_len: usize, + existing_field_len: usize, + ) -> bool { + if self.stream_cache.len() >= MAX_STREAM_CACHE_ENTRIES + && !self.stream_cache.contains_key(key) + { + if let Some(evict) = self.stream_cache.keys().find(|k| *k != key).cloned() { + self.evict_stream_cache_key(&evict); + } + } + + let mut projected_total = self.stream_cache_bytes() + incoming_len - existing_field_len; + let max_cache_bytes = self.resource_limits.max_stream_cache_bytes; + if projected_total > max_cache_bytes { + let victims: Vec<_> = self + .stream_cache + .keys() + .filter(|k| *k != key) + .cloned() + .collect(); + for victim in victims { + if projected_total <= max_cache_bytes { + break; + } + if let Some(cache) = self.stream_cache.get(&victim) { + projected_total -= Self::stream_cache_entry_bytes(cache); + } + self.evict_stream_cache_key(&victim); + } + } + + projected_total <= max_cache_bytes + } + fn cache_relay_frame(&mut self, frame: &crate::session::conn::RelayFrame) { - let is_avc_header = frame.frame_type == FrameType::Video - && frame.payload.len() >= 2 - && frame.payload[0] == 0x17 - && frame.payload[1] == 0x00; - let is_keyframe = frame.frame_type == FrameType::Video - && frame.payload.len() >= 2 - && frame.payload[0] == 0x17 - && frame.payload[1] == 0x01; - let is_aac_header = frame.frame_type == FrameType::Audio - && frame.payload.len() >= 2 - && (frame.payload[0] & 0xF0) == 0xA0 - && frame.payload[1] == 0x00; + if frame.frame_type == FrameType::Script || frame.frame_type == FrameType::Metadata { + if frame.payload.len() > MAX_CACHED_INIT_FRAME_BYTES { + return; + } + let key = (frame.app.clone(), frame.stream_name.clone()); + let publisher_keys = self + .publisher_cache_keys + .entry(frame.publisher_conn_id) + .or_default(); + if !publisher_keys.iter().any(|k| k == &key) { + publisher_keys.push(key.clone()); + } + let existing_field_len = self + .stream_cache + .get(&key) + .and_then(|cache| cache.metadata.as_ref()) + .map(|v| v.len()) + .unwrap_or(0); + if !self.reserve_stream_cache_storage(&key, frame.payload.len(), existing_field_len) { + return; + } + let cache = self + .stream_cache + .entry(key) + .or_insert_with(empty_stream_cache); + cache.metadata = Some(frame.payload.clone()); + return; + } + + let cache_kind = classify_cache_frame(frame.frame_type, frame.cache_payload()); + let is_avc_header = cache_kind == CacheFrameKind::VideoSequenceHeader; + let is_keyframe = cache_kind == CacheFrameKind::VideoKeyframe; + let is_aac_header = cache_kind == CacheFrameKind::AudioSequenceHeader; // Frames that are neither a codec header nor a keyframe are relayed // live but never cached; don't create an empty entry for them. @@ -774,16 +985,44 @@ impl Server { // it) -- drop it rather than keep replaying it to late joiners. if let Some(cache) = self.stream_cache.get_mut(&key) { if is_avc_header { - cache.avc_header = None; + let seq_tracks = Self::multitrack_sequence_track_ids( + FrameType::Video, + frame.cache_payload(), + ); + if seq_tracks.len() > 1 { + cache.avc_header = None; + cache.video_track_headers.clear(); + } else if let Some(track_id) = seq_tracks.first().copied() { + cache.avc_header = None; + cache.video_track_headers.remove(&track_id); + } else { + cache.avc_header = None; + cache.video_track_headers.clear(); + } } else if is_keyframe { cache.last_keyframe = None; } else { - cache.aac_header = None; + let seq_tracks = Self::multitrack_sequence_track_ids( + FrameType::Audio, + frame.cache_payload(), + ); + if seq_tracks.len() > 1 { + cache.aac_header = None; + cache.audio_track_headers.clear(); + } else if let Some(track_id) = seq_tracks.first().copied() { + cache.aac_header = None; + cache.audio_track_headers.remove(&track_id); + } else { + cache.aac_header = None; + cache.audio_track_headers.clear(); + } } } - if self.stream_cache.get(&key).is_some_and(|c| { - c.avc_header.is_none() && c.aac_header.is_none() && c.last_keyframe.is_none() - }) { + if self + .stream_cache + .get(&key) + .is_some_and(Self::stream_cache_is_empty) + { self.evict_stream_cache_key(&key); } return; @@ -796,20 +1035,27 @@ impl Server { if !publisher_keys.iter().any(|k| k == &key) { publisher_keys.push(key.clone()); } - if self.stream_cache.len() >= MAX_STREAM_CACHE_ENTRIES - && !self.stream_cache.contains_key(&key) - { - if let Some(evict) = self.stream_cache.keys().find(|k| *k != &key).cloned() { - self.evict_stream_cache_key(&evict); - } - } let existing_field_len = self .stream_cache .get(&key) .map(|cache| { if is_avc_header { - cache.avc_header.as_ref().map(|v| v.len()).unwrap_or(0) + let seq_tracks = Self::multitrack_sequence_track_ids( + FrameType::Video, + frame.cache_payload(), + ); + if seq_tracks.len() > 1 { + cache.avc_header.as_ref().map(|v| v.len()).unwrap_or(0) + } else if let Some(track_id) = seq_tracks.first() { + cache + .video_track_headers + .get(track_id) + .map(|v| v.len()) + .unwrap_or(0) + } else { + cache.avc_header.as_ref().map(|v| v.len()).unwrap_or(0) + } } else if is_keyframe { cache .last_keyframe @@ -817,52 +1063,64 @@ impl Server { .map(|(_, v)| v.len()) .unwrap_or(0) } else { - cache.aac_header.as_ref().map(|v| v.len()).unwrap_or(0) + let seq_tracks = Self::multitrack_sequence_track_ids( + FrameType::Audio, + frame.cache_payload(), + ); + if seq_tracks.len() > 1 { + cache.aac_header.as_ref().map(|v| v.len()).unwrap_or(0) + } else if let Some(track_id) = seq_tracks.first() { + cache + .audio_track_headers + .get(track_id) + .map(|v| v.len()) + .unwrap_or(0) + } else { + cache.aac_header.as_ref().map(|v| v.len()).unwrap_or(0) + } } }) .unwrap_or(0); - let incoming_len = frame.payload.len(); - // Track the running total locally instead of recomputing - // stream_cache_bytes() (an O(n) scan) on every eviction, which would - // make this O(n^2) under sustained cache churn. - let mut projected_total = self.stream_cache_bytes() + incoming_len - existing_field_len; - let max_cache_bytes = self.resource_limits.max_stream_cache_bytes; - if projected_total > max_cache_bytes { - let victims: Vec<_> = self - .stream_cache - .keys() - .filter(|k| *k != &key) - .cloned() - .collect(); - for victim in victims { - if projected_total <= max_cache_bytes { - break; - } - if let Some(cache) = self.stream_cache.get(&victim) { - projected_total -= Self::stream_cache_entry_bytes(cache); - } - self.evict_stream_cache_key(&victim); - } - } - - // Evicting every other entry still isn't enough when this single - // payload alone exceeds the budget -- don't cache it at all rather - // than let the server-wide total blow past the configured cache cap. - if projected_total > max_cache_bytes { + if !self.reserve_stream_cache_storage(&key, frame.payload.len(), existing_field_len) { return; } - let cache = self.stream_cache.entry(key).or_insert(StreamCache { - avc_header: None, - aac_header: None, - last_keyframe: None, - }); + let cache = self + .stream_cache + .entry(key) + .or_insert_with(empty_stream_cache); if is_avc_header { - cache.avc_header = Some(frame.payload.clone()); + let seq_tracks = + Self::multitrack_sequence_track_ids(FrameType::Video, frame.cache_payload()); + if seq_tracks.len() > 1 { + cache.video_track_headers.clear(); + cache.avc_header = Some(frame.payload.clone()); + } else if let Some(track_id) = seq_tracks.first().copied() { + cache.avc_header = None; + cache + .video_track_headers + .insert(track_id, frame.payload.clone()); + } else { + cache.video_track_headers.clear(); + cache.avc_header = Some(frame.payload.clone()); + } } else if is_keyframe { cache.last_keyframe = Some((frame.timestamp, frame.payload.clone())); } else if is_aac_header { - cache.aac_header = Some(frame.payload.clone()); + let seq_tracks = + Self::multitrack_sequence_track_ids(FrameType::Audio, frame.cache_payload()); + if seq_tracks.len() > 1 { + cache.audio_track_headers.clear(); + cache.aac_header = Some(frame.payload.clone()); + } else if let Some(track_id) = seq_tracks.first().copied() { + cache.aac_header = None; + cache + .audio_track_headers + .insert(track_id, frame.payload.clone()); + } else { + cache.audio_track_headers.clear(); + cache.aac_header = Some(frame.payload.clone()); + } } } @@ -921,6 +1179,17 @@ impl Server { } } +fn empty_stream_cache() -> StreamCache { + StreamCache { + avc_header: None, + video_track_headers: HashMap::new(), + aac_header: None, + audio_track_headers: HashMap::new(), + metadata: None, + last_keyframe: None, + } +} + impl Drop for Server { fn drop(&mut self) { self.running = false; @@ -941,6 +1210,79 @@ mod tests { assert!(MAX_RECV_BYTES_PER_CONN_PER_POLL <= 1024 * 1024); } + #[test] + fn relay_send_budget_limits_worst_case_player_fan_out() { + // One publisher can queue 1024 frames; with 256 connections that + // could be 261_120 sends per poll without a relay budget. + let worst_case = 1024 * 256; + let server = test_server(); + assert_eq!( + server.max_relay_sends_per_poll, + DEFAULT_MAX_RELAY_SENDS_PER_POLL + ); + assert!( + server.max_relay_sends_per_poll < worst_case / 10, + "relay budget should be well below unbounded fan-out" + ); + } + + #[test] + fn oversized_relay_fan_out_still_makes_progress_each_poll() { + use crate::session::stream::Stream; + use crate::transport::Transport; + use std::os::unix::io::IntoRawFd; + use std::os::unix::net::UnixStream; + + fn attached_conn(conn_id: u64, publishing: bool) -> (Conn, UnixStream) { + let (server_end, peer_end) = UnixStream::pair().unwrap(); + server_end.set_nonblocking(true).unwrap(); + peer_end.set_nonblocking(true).unwrap(); + + let mut conn = Conn::new(); + conn.conn_id = conn_id; + conn.app = "live".to_string(); + conn.relay_enabled = true; + conn.transport = Some(Transport::new_plain(server_end.into_raw_fd())); + conn.current_stream = Some(Box::new(Stream { + stream_id: 1, + name: "stream".to_string(), + is_publishing: publishing, + is_playing: !publishing, + paused: false, + receive_audio: true, + receive_video: true, + })); + (conn, peer_end) + } + + let mut server = test_server(); + server.max_relay_sends_per_poll = 1; + + let (mut publisher, _publisher_peer) = attached_conn(1, true); + publisher + .pending_relay + .push(relay_frame(FrameType::Video, vec![0x17, 0x01, 0xAA])); + publisher + .pending_relay + .push(relay_frame(FrameType::Video, vec![0x27, 0x01, 0xBB])); + let (player_a, _player_a_peer) = attached_conn(2, false); + let (player_b, _player_b_peer) = attached_conn(3, false); + server.connections = vec![publisher, player_a, player_b]; + + server.process_connections().unwrap(); + assert_eq!( + server.connections[0].pending_relay.len(), + 1, + "the first oversized fan-out frame must be relayed instead of re-queuing the whole batch" + ); + + server.process_connections().unwrap(); + assert!( + server.connections[0].pending_relay.is_empty(), + "the deferred frame must make progress on the next poll" + ); + } + fn test_server() -> Server { Server::new(ServerConfig { max_connections: 4, @@ -961,10 +1303,79 @@ mod tests { publisher_conn_id: 1, frame_type, timestamp: 0, + cache_payload: None, payload, } } + #[test] + fn modex_wrapped_multitrack_is_detected_for_player_gating() { + let payload = [ + 0x87, 0x02, 0x00, 0x01, 0x02, 0x06, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, + 0x01, 0xAA, + ]; + assert!(Server::cached_payload_is_multitrack( + FrameType::Video, + &payload + )); + } + + #[test] + fn multitrack_video_sequence_header_is_cached() { + let mut server = test_server(); + let payload = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x01, 0xAA, 0x01, 0x00, 0x00, + 0x01, 0xBB, + ]; + server.cache_relay_frame(&relay_frame(FrameType::Video, payload)); + + let key = ("live".to_string(), "stream".to_string()); + assert!(server.stream_cache.get(&key).unwrap().avc_header.is_some()); + } + + #[test] + fn multitrack_per_track_video_inits_are_retained() { + let mut server = test_server(); + let track0 = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x01, 0xAA, + ]; + let track1 = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x01, 0x00, 0x00, 0x01, 0xBB, + ]; + server.cache_relay_frame(&relay_frame(FrameType::Video, track0)); + server.cache_relay_frame(&relay_frame(FrameType::Video, track1)); + + let key = ("live".to_string(), "stream".to_string()); + let cache = server.stream_cache.get(&key).unwrap(); + assert!(cache.avc_header.is_none()); + assert_eq!(cache.video_track_headers.len(), 2); + assert!(cache.video_track_headers.contains_key(&0)); + assert!(cache.video_track_headers.contains_key(&1)); + } + + #[test] + fn enhanced_hevc_sequence_header_is_cached() { + let mut server = test_server(); + let payload = vec![0x90, b'h', b'v', b'c', b'1', 0x01, 0x02]; + server.cache_relay_frame(&relay_frame(FrameType::Video, payload)); + + let key = ("live".to_string(), "stream".to_string()); + assert!(server.stream_cache.get(&key).unwrap().avc_header.is_some()); + } + + #[test] + fn on_metadata_script_is_cached() { + let mut server = test_server(); + let mut payload = vec![0x02, 0x00, 0x0A]; + payload.extend_from_slice(b"onMetaData"); + payload.push(crate::amf::amf0::Amf0Type::Object as u8); + payload.extend_from_slice(&[0x00, 0x00, 0x09]); + + server.cache_relay_frame(&relay_frame(FrameType::Script, payload)); + let key = ("live".to_string(), "stream".to_string()); + assert!(server.stream_cache.get(&key).unwrap().metadata.is_some()); + } + #[test] fn oversized_codec_header_is_not_cached() { let mut server = test_server(); @@ -1122,6 +1533,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); first.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1135,6 +1549,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); second.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1171,6 +1588,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); first.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1184,6 +1604,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); second.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1220,6 +1643,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); third.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1249,6 +1675,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); first.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1281,6 +1710,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); second.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1304,8 +1736,8 @@ mod tests { use crate::chunk::writer::chunk_write; use crate::message::message::RTMP_MSG_AMF0_COMMAND; use crate::session::stream::Stream; - use crate::types::ConnState; use crate::transport::Transport; + use crate::types::ConnState; use std::os::unix::io::IntoRawFd; use std::os::unix::net::UnixStream; @@ -1322,6 +1754,9 @@ mod tests { name: "victim".to_string(), is_publishing: true, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); conn_a.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1350,6 +1785,9 @@ mod tests { name: String::new(), is_publishing: false, is_playing: false, + paused: false, + receive_audio: true, + receive_video: true, })); conn_b.publish_routes = Some(PublishRouteRegistry::new(Arc::clone( &server.active_publish_routes, @@ -1375,7 +1813,11 @@ mod tests { server.connections = vec![conn_a, conn_b]; server.process_connections().unwrap(); - assert_eq!(server.connections.len(), 1, "conn_a should have been removed"); + assert_eq!( + server.connections.len(), + 1, + "conn_a should have been removed" + ); assert!( server.connections[0].relay_enabled, "conn_b's publish must succeed in the same batch conn_a's route was freed in" diff --git a/src/session/conn.rs b/src/session/conn.rs index 0739b05..ebf007b 100644 --- a/src/session/conn.rs +++ b/src/session/conn.rs @@ -6,9 +6,17 @@ use crate::buffer::Buffer; use crate::chunk::reader::{ChunkMessage, chunk_read_owned}; use crate::chunk::state::{ChunkRegistry, DEFAULT_CHUNK_SIZE, DEFAULT_MAX_MSG_LENGTH}; use crate::chunk::writer::chunk_write; +use crate::ertmp::connect_amf::{negotiate_caps, write_negotiated_caps}; +use crate::ertmp::multitrack_media::{first_track_fourcc, foreach_track}; use crate::handshake::{self, Handshake, HandshakeState}; +use crate::media::{ + is_on_metadata_payload, normalize_modex_payload, populate_av_frame, populate_multitrack_frame, +}; use crate::message::command; -use crate::message::control::{self, UCTRL_PING_REQUEST, UCTRL_PING_RESPONSE}; +use crate::message::control::{ + self, UCTRL_PING_REQUEST, UCTRL_PING_RESPONSE, UCTRL_SET_BUFFER_LENGTH, UCTRL_STREAM_BEGIN, + UCTRL_STREAM_EOF, +}; use crate::message::message as msg_dispatch; use crate::session::publish_route::PublishRouteRegistry; use crate::session::state_machine; @@ -69,11 +77,29 @@ pub struct RelayFrame { pub frame_type: FrameType, pub timestamp: u32, pub payload: Vec, + /// ModEx-normalized bytes used only for codec parsing and cache classification. + /// `None` means the normalized bytes are identical to `payload`. + pub cache_payload: Option>, pub app: String, pub stream_name: String, pub publisher_conn_id: u64, } +impl RelayFrame { + /// Bytes used for codec parsing and cache classification. + pub fn cache_payload(&self) -> &[u8] { + self.cache_payload.as_deref().unwrap_or(&self.payload) + } + + fn retained_bytes(&self) -> usize { + self.payload.len().saturating_add( + self.cache_payload + .as_ref() + .map(|payload| payload.len()) + .unwrap_or(0), + ) + } +} pub struct Conn { pub state: ConnState, pub handshake: Handshake, @@ -153,6 +179,10 @@ pub struct Conn { /// When this inbound TCP session was accepted. Used to reap peers that /// never progress past the legacy handshake / AMF connect setup. session_setup_started: Instant, + /// E-RTMP caps agreed during connect (empty when legacy connect). + pub negotiated_caps: NegotiatedCaps, + /// Player-side buffer length from SetBufferLength user control (ms). + pub buffer_length_ms: u32, } impl Conn { @@ -211,6 +241,8 @@ impl Conn { frame_cb_scratch: Vec::new(), budget_exhausted: false, session_setup_started: Instant::now(), + negotiated_caps: NegotiatedCaps::default(), + buffer_length_ms: 3000, } } @@ -236,7 +268,10 @@ impl Conn { } fn pending_relay_bytes(&self) -> usize { - self.pending_relay.iter().map(|f| f.payload.len()).sum() + self.pending_relay + .iter() + .map(RelayFrame::retained_bytes) + .sum() } /// Key used to route relayed media between publishers and players. @@ -250,6 +285,10 @@ impl Conn { .unwrap_or_default() } + pub fn accepts_multitrack(&self) -> bool { + !self.negotiated_caps.has_caps_ex || self.negotiated_caps.multitrack_enabled + } + /// Queue eviction of the current publish route's cache key when /// `current_stream` is about to be repurposed or replaced while still /// marked as actively publishing (a fresh `createStream`, or switching @@ -298,11 +337,40 @@ impl Conn { msg_stream_id == expected_stream_id && expected_stream_id != 0 } - fn handle_publisher_data_message(&mut self, msg_stream_id: u32, payload: &[u8]) -> Result<()> { + fn handle_publisher_data_message( + &mut self, + msg_stream_id: u32, + timestamp: u32, + payload: &[u8], + ) -> Result<()> { if !self.publishing_metadata_allowed(msg_stream_id) { return Ok(()); } - self.handle_data_message(payload) + let relay_metadata = is_on_metadata_payload(payload) + && self.relay_enabled + && self + .current_stream + .as_ref() + .map(|s| s.is_publishing) + .unwrap_or(false); + self.handle_data_message(payload)?; + if relay_metadata { + if let Some(cb) = self.on_frame_cb { + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let frame = Frame { + frame_type: FrameType::Script, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + is_metadata: 1, + ..Default::default() + }; + cb(&frame); + } + self.queue_relay_frame(FrameType::Script, timestamp, payload, payload)?; + } + Ok(()) } fn queue_relay_frame( @@ -310,9 +378,24 @@ impl Conn { frame_type: FrameType, timestamp: u32, payload: &[u8], + cache_payload: &[u8], ) -> Result<()> { + let cache_payload = if cache_payload.len() == payload.len() + && std::ptr::eq(cache_payload.as_ptr(), payload.as_ptr()) + { + None + } else { + Some(cache_payload.to_vec()) + }; + let retained_bytes = payload.len().saturating_add( + cache_payload + .as_ref() + .map(|payload| payload.len()) + .unwrap_or(0), + ); if self.pending_relay.len() >= MAX_PENDING_RELAY_FRAMES - || self.pending_relay_bytes() + payload.len() > self.max_pending_relay_bytes + || self.pending_relay_bytes().saturating_add(retained_bytes) + > self.max_pending_relay_bytes { return Err(ErrorCode::Internal); } @@ -320,6 +403,7 @@ impl Conn { frame_type, timestamp, payload: payload.to_vec(), + cache_payload, app: self.app.clone(), stream_name: self.relay_route_key(), publisher_conn_id: self.conn_id, @@ -365,12 +449,16 @@ impl Conn { return Ok(()); } + let normalized_payload = + normalize_modex_payload(payload, self.negotiated_caps.caps_ex_mask); + let parse_payload = normalized_payload.as_ref(); + match frame_type { FrameType::Video if self.detected_video_codec.is_none() => { - self.detected_video_codec = detect_video_codec(payload); + self.detected_video_codec = detect_video_codec(parse_payload); } FrameType::Audio if self.detected_audio_codec.is_none() => { - self.detected_audio_codec = detect_audio_codec(payload); + self.detected_audio_codec = detect_audio_codec(parse_payload); } _ => {} } @@ -384,20 +472,25 @@ impl Conn { .saturating_add(payload.len() as u64); if let Some(cb) = self.on_frame_cb { - self.frame_cb_scratch.clear(); - self.frame_cb_scratch.extend_from_slice(payload); - let frame = Frame { - frame_type, - timestamp, - size: self.frame_cb_scratch.len() as u32, - data: self.frame_cb_scratch.as_ptr(), - ..Default::default() - }; - cb(&frame); + let had_multitrack = foreach_track(frame_type, parse_payload, |track| { + self.invoke_multitrack_on_frame_cb( + cb, + frame_type, + timestamp, + track.track_id, + track.fourcc, + track.packet_type, + track.video_frame_type, + track.payload, + ); + }); + if !had_multitrack { + self.invoke_on_frame_cb(cb, frame_type, timestamp, u8::MAX, parse_payload); + } } if self - .queue_relay_frame(frame_type, timestamp, payload) + .queue_relay_frame(frame_type, timestamp, payload, parse_payload) .is_err() { return Err(ErrorCode::Internal); @@ -463,7 +556,7 @@ impl Conn { self.handle_media_frame(msg_stream_id, FrameType::Video, out_ts, tag_payload)?; } msg_dispatch::RTMP_MSG_AMF0_DATA => { - self.handle_publisher_data_message(msg_stream_id, tag_payload)?; + self.handle_publisher_data_message(msg_stream_id, out_ts, tag_payload)?; } _ => {} } @@ -672,19 +765,36 @@ impl Conn { self.handle_aggregate(msg.msg_stream_id, msg.timestamp, payload) } msg_dispatch::RTMP_MSG_AMF0_DATA => { - self.handle_publisher_data_message(msg.msg_stream_id, payload) + self.handle_publisher_data_message(msg.msg_stream_id, msg.timestamp, payload) } msg_dispatch::RTMP_MSG_AMF3_DATA => { if !payload.is_empty() && payload[0] == 0x00 { - self.handle_publisher_data_message(msg.msg_stream_id, &payload[1..]) + self.handle_publisher_data_message( + msg.msg_stream_id, + msg.timestamp, + &payload[1..], + ) } else { - self.handle_publisher_data_message(msg.msg_stream_id, payload) + self.handle_publisher_data_message(msg.msg_stream_id, msg.timestamp, payload) } } + msg_dispatch::RTMP_MSG_AMF3_SHARED_OBJECT => { + let data = if !payload.is_empty() && payload[0] == 0x00 { + &payload[1..] + } else { + payload + }; + self.handle_amf3_shared_object(data) + } _ => Ok(()), } } + fn handle_amf3_shared_object(&mut self, _payload: &[u8]) -> Result<()> { + // AMF3 shared objects are accepted and ignored until a relay use-case exists. + Ok(()) + } + fn handle_data_message(&mut self, payload: &[u8]) -> Result<()> { let mut buf = Buffer::from_slice(payload); let first_byte = match buf.peek().first().copied() { @@ -918,7 +1028,13 @@ impl Conn { if payload.len() < 6 { return Ok(()); } - let (event_type, param1, _) = control::read_user_control(payload, false)?; + let event_type = ((payload[0] as u16) << 8) | (payload[1] as u16); + let (event_type, param1, param2) = if event_type == UCTRL_SET_BUFFER_LENGTH { + control::read_user_control(payload, true)? + } else { + let (ty, p1, _) = control::read_user_control(payload, false)?; + (ty, p1, None) + }; match event_type { UCTRL_PING_RESPONSE => { if let Some(sent_at) = self.pending_pings.remove(¶m1) { @@ -941,6 +1057,17 @@ impl Conn { self.inbound_ping_responses += 1; self.send_user_control_ping_response(param1)?; } + UCTRL_STREAM_BEGIN => { + let _ = param1; + } + UCTRL_STREAM_EOF => { + let _ = param1; + } + UCTRL_SET_BUFFER_LENGTH => { + if let Some(ms) = param2 { + self.buffer_length_ms = ms; + } + } _ => {} } Ok(()) @@ -1010,13 +1137,35 @@ impl Conn { return Ok(()); } let mut info = ConnectInfo::default(); - command::read_connect(&mut buf, &mut info)?; + if command::read_connect(&mut buf, &mut info).is_err() { + self.send_command_error( + info.transaction_id, + "NetConnection.Connect.Rejected", + "Invalid connect command or capability negotiation.", + )?; + return Ok(()); + } let app_len = info.app.iter().position(|&b| b == 0).unwrap_or(0); self.app = std::str::from_utf8(&info.app[..app_len]) .unwrap_or("") .to_string(); + let needs_caps = info.has_four_cc_list + || info.has_caps_ex + || info.has_video_four_cc_info_map + || info.has_reconnect; + if needs_caps { + let _ = + state_machine::conn_transition(&mut self.state, ConnState::CapsNegotiated); + } + let negotiated = if needs_caps { + let caps = negotiate_caps(&info); + self.negotiated_caps = caps.clone(); + Some(caps) + } else { + None + }; let _ = state_machine::conn_transition(&mut self.state, ConnState::AppConnected); - self.send_connect_response(info.transaction_id)?; + self.send_connect_response(info.transaction_id, negotiated.as_ref())?; if !self.connect_cb_fired { self.connect_cb_fired = true; if let Some(cb) = self.on_connect_cb { @@ -1180,6 +1329,7 @@ impl Conn { .map(|s| s.stream_id) .unwrap_or(0); self.send_onstatus(sid, "status", "NetStream.Play.Start", "Playing")?; + self.send_stream_lifecycle_begin(sid)?; } } "FCUnpublish" | "deleteStream" => { @@ -1199,14 +1349,95 @@ impl Conn { // `true` here would let a new publish on this connection // relay before it's actually re-authorized. self.relay_enabled = false; + if let Some(sid) = self.current_stream.as_ref().map(|s| s.stream_id) { + let _ = self.send_stream_lifecycle_eof(sid); + } } "FCPublish" | "releaseStream" => {} + "pause" => { + if let Ok(pause_flag) = command::read_pause(&mut buf) { + if let Some(ref mut stream) = self.current_stream { + stream.paused = pause_flag; + } + let sid = self + .current_stream + .as_ref() + .map(|s| s.stream_id) + .unwrap_or(0); + let (code, desc) = if pause_flag { + ("NetStream.Pause.Notify", "Paused") + } else { + ("NetStream.Unpause.Notify", "Unpaused") + }; + self.send_onstatus(sid, "status", code, desc)?; + } + } + "seek" => { + let _millis = command::read_seek(&mut buf).unwrap_or(0.0); + let sid = self + .current_stream + .as_ref() + .map(|s| s.stream_id) + .unwrap_or(0); + self.send_onstatus(sid, "status", "NetStream.Seek.Notify", "Seeking")?; + } + "receiveAudio" => { + if let Ok(flag) = command::read_bool_command(&mut buf) { + let was_enabled = self + .current_stream + .as_ref() + .map(|stream| stream.receive_audio) + .unwrap_or(true); + if let Some(ref mut stream) = self.current_stream { + stream.receive_audio = flag; + } + if flag && !was_enabled { + self.needs_init_frames = true; + } + } + } + "receiveVideo" => { + if let Ok(flag) = command::read_bool_command(&mut buf) { + let was_enabled = self + .current_stream + .as_ref() + .map(|stream| stream.receive_video) + .unwrap_or(true); + if let Some(ref mut stream) = self.current_stream { + stream.receive_video = flag; + } + if flag && !was_enabled { + self.needs_init_frames = true; + } + } + } + "closeStream" => { + let target_id = command::read_close_stream(&mut buf) + .ok() + .flatten() + .or_else(|| self.current_stream.as_ref().map(|s| s.stream_id)) + .unwrap_or(0); + if self.current_stream.as_ref().map(|s| s.stream_id) == Some(target_id) { + self.evict_active_publish_route(); + if let Some(ref mut stream) = self.current_stream { + stream.is_playing = false; + stream.is_publishing = false; + stream.paused = false; + } + self.relay_enabled = false; + let _ = self.send_stream_lifecycle_eof(target_id); + } + } _ => {} } Ok(()) } - pub fn send_connect_response(&mut self, transaction_id: f64) -> Result<()> { + pub fn send_connect_response( + &mut self, + transaction_id: f64, + caps: Option<&NegotiatedCaps>, + ) -> Result<()> { let win = SERVER_WINDOW_ACK_SIZE.to_be_bytes(); self.send_control(0x05, &win)?; let mut bw = [0u8; 5]; @@ -1230,10 +1461,24 @@ impl Conn { crate::amf::amf0::write_string(&mut amf_buf, "NetConnection.Connect.Success")?; crate::amf::amf0::write_object_key(&mut amf_buf, "description")?; crate::amf::amf0::write_string(&mut amf_buf, "Connection succeeded.")?; + if let Some(caps) = caps { + write_negotiated_caps(&mut amf_buf, caps)?; + } crate::amf::amf0::write_object_end(&mut amf_buf)?; self.send_command(0, amf_buf.as_slice()) } + pub fn send_command_error( + &mut self, + transaction_id: f64, + code: &str, + description: &str, + ) -> Result<()> { + let mut amf_buf = Buffer::with_capacity(256); + command::build_error(&mut amf_buf, transaction_id, code, description)?; + self.send_command(0, amf_buf.as_slice()) + } + pub fn send_create_stream_response( &mut self, transaction_id: f64, @@ -1333,6 +1578,31 @@ impl Conn { Ok(()) } + /// Send an AMF0 data message (e.g. onMetaData) on the current stream. + pub fn send_data_message(&mut self, timestamp: u32, payload: &[u8]) -> Result<()> { + let stream_id = self + .current_stream + .as_ref() + .map(|s| s.stream_id) + .unwrap_or(1); + let mut cmsg = ChunkMessage::default(); + cmsg.timestamp = timestamp; + cmsg.msg_length = payload.len() as u32; + cmsg.msg_stream_id = stream_id; + cmsg.fmt = 0; + cmsg.csid = 5; + cmsg.msg_type_id = msg_dispatch::RTMP_MSG_AMF0_DATA; + chunk_write( + &mut self.send_buffer, + &cmsg, + payload, + payload.len(), + self.active_chunk_size as usize, + )?; + self.media_bytes_sent = self.media_bytes_sent.saturating_add(payload.len() as u64); + Ok(()) + } + fn send_control(&mut self, ty: u8, data: &[u8]) -> Result<()> { let mut msg = ChunkMessage::default(); msg.csid = 2; @@ -1381,6 +1651,67 @@ impl Conn { control::write_user_control_ping_response(&mut buf, timestamp)?; self.send_control(msg_dispatch::RTMP_MSG_USER_CONTROL, buf.as_slice()) } + + fn send_stream_lifecycle_begin(&mut self, stream_id: u32) -> Result<()> { + let mut buf = Buffer::with_capacity(14); + control::write_user_control_stream_begin(&mut buf, stream_id)?; + self.send_control(msg_dispatch::RTMP_MSG_USER_CONTROL, buf.as_slice())?; + buf.reset(); + control::write_user_control_set_buffer_length(&mut buf, stream_id, self.buffer_length_ms)?; + self.send_control(msg_dispatch::RTMP_MSG_USER_CONTROL, buf.as_slice()) + } + + fn send_stream_lifecycle_eof(&mut self, stream_id: u32) -> Result<()> { + let mut buf = Buffer::with_capacity(6); + control::write_user_control_stream_eof(&mut buf, stream_id)?; + self.send_control(msg_dispatch::RTMP_MSG_USER_CONTROL, buf.as_slice()) + } + + fn invoke_multitrack_on_frame_cb( + &mut self, + cb: fn(&Frame), + frame_type: FrameType, + timestamp: u32, + track_id: u8, + fourcc: [u8; 4], + packet_type: u8, + video_frame_type: u8, + payload: &[u8], + ) { + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let mut frame = Frame { + frame_type, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + track_id, + ..Default::default() + }; + populate_multitrack_frame(&mut frame, fourcc, packet_type, video_frame_type); + cb(&frame); + } + fn invoke_on_frame_cb( + &mut self, + cb: fn(&Frame), + frame_type: FrameType, + timestamp: u32, + track_id: u8, + payload: &[u8], + ) { + self.frame_cb_scratch.clear(); + self.frame_cb_scratch.extend_from_slice(payload); + let mut frame = Frame { + frame_type, + timestamp, + size: self.frame_cb_scratch.len() as u32, + data: self.frame_cb_scratch.as_ptr(), + track_id, + ..Default::default() + }; + populate_av_frame(&mut frame, &self.frame_cb_scratch); + cb(&frame); + } } impl Default for Conn { @@ -1412,47 +1743,78 @@ fn read_data_event_name(buf: &mut Buffer, is_string: bool, out: &mut [u8; 64]) - } fn detect_video_codec(payload: &[u8]) -> Option { - if payload.is_empty() { + if let Some(cc) = first_track_fourcc(FrameType::Video, payload) { + return std::str::from_utf8(&cc).ok().map(str::to_owned); + } + let mut hdr = VideoHeader::default(); + if crate::ertmp::exvideo::exvideo_parse(payload, &mut hdr).is_err() { return None; } - if payload[0] & 0x80 != 0 { - if payload.len() >= 5 { - if let Ok(s) = std::str::from_utf8(&payload[1..5]) { - return Some(s.to_string()); - } + if hdr.is_ex_header != 0 { + std::str::from_utf8(&hdr.fourcc[..4]) + .ok() + .map(|s| s.to_string()) + } else { + match payload[0] & 0x0F { + 7 => Some("avc1".to_string()), + 12 => Some("hvc1".to_string()), + 13 => Some("av01".to_string()), + _ => None, } - return None; } - Some(match payload[0] & 0x0F { - 7 => "avc1".to_string(), - 12 => "hvc1".to_string(), - 13 => "av01".to_string(), - _ => return None, - }) } fn detect_audio_codec(payload: &[u8]) -> Option { - if payload.is_empty() { + if let Some(cc) = first_track_fourcc(FrameType::Audio, payload) { + return std::str::from_utf8(&cc).ok().map(str::to_owned); + } + let mut hdr = AudioHeader::default(); + if crate::ertmp::exaudio::exaudio_parse(payload, &mut hdr).is_err() { return None; } - if (payload[0] & 0xF0) == 0x90 && payload.len() >= 5 { - if let Ok(s) = std::str::from_utf8(&payload[1..5]) { - return Some(s.to_string()); + if hdr.is_ex_header != 0 { + std::str::from_utf8(&hdr.fourcc[..4]) + .ok() + .map(|s| s.to_string()) + } else { + match hdr.audio_codec { + AudioCodec::Aac => Some("mp4a".to_string()), + AudioCodec::Mp3 => Some("mp3".to_string()), + AudioCodec::Opus => Some("Opus".to_string()), + _ => None, } } - Some(match (payload[0] >> 4) & 0x0F { - 10 => "mp4a".to_string(), - 2 => "mp3".to_string(), - 14 => "Opus".to_string(), - _ => return None, - }) } #[cfg(test)] mod tests { use super::*; + use crate::amf::amf0; use crate::session::stream::Stream; + #[test] + fn relay_budget_counts_actual_retained_bytes() { + let mut conn = Conn::new(); + conn.max_pending_relay_bytes = 6; + + assert!( + conn.queue_relay_frame(FrameType::Video, 0, b"data", b"data") + .is_ok() + ); + assert_eq!(conn.pending_relay_bytes(), 4); + assert!(conn.pending_relay[0].cache_payload.is_none()); + assert_eq!(conn.pending_relay[0].cache_payload(), b"data"); + + assert!( + conn.queue_relay_frame(FrameType::Video, 0, b"x", b"y") + .is_ok() + ); + assert_eq!(conn.pending_relay_bytes(), 6); + assert!( + conn.queue_relay_frame(FrameType::Video, 0, b"z", b"z") + .is_err() + ); + } #[test] fn relay_route_key_prefers_relay_key_over_rtmp_name() { let mut conn = Conn::new(); @@ -1475,7 +1837,51 @@ mod tests { } #[test] - fn connect_rejects_app_names_longer_than_routing_buffer() { + fn connect_with_caps_sets_negotiated_state_and_caps() { + let mut conn = Conn::new(); + conn.state = ConnState::Connected; + + let mut buf = Buffer::new(); + amf0::write_string(&mut buf, "connect").unwrap(); + amf0::write_number(&mut buf, 1.0).unwrap(); + amf0::write_object_begin(&mut buf).unwrap(); + amf0::write_object_key(&mut buf, "app").unwrap(); + amf0::write_string(&mut buf, "live").unwrap(); + amf0::write_object_key(&mut buf, "fourCcList").unwrap(); + buf.write(&[0x0A, 0x00, 0x00, 0x00, 0x02]).unwrap(); + amf0::write_string(&mut buf, "av01").unwrap(); + amf0::write_string(&mut buf, "hvc1").unwrap(); + amf0::write_object_end(&mut buf).unwrap(); + + conn.handle_command(buf.as_slice()).unwrap(); + assert_eq!(conn.state, ConnState::AppConnected); + assert!(conn.negotiated_caps.has_four_cc_list); + assert_eq!(conn.negotiated_caps.four_cc_list.count, 2); + } + + #[test] + fn connect_with_caps_ex_enables_multitrack() { + let mut conn = Conn::new(); + conn.state = ConnState::Connected; + + let mut buf = Buffer::new(); + amf0::write_string(&mut buf, "connect").unwrap(); + amf0::write_number(&mut buf, 1.0).unwrap(); + amf0::write_object_begin(&mut buf).unwrap(); + amf0::write_object_key(&mut buf, "app").unwrap(); + amf0::write_string(&mut buf, "live").unwrap(); + amf0::write_object_key(&mut buf, "capsEx").unwrap(); + amf0::write_number(&mut buf, CAPS_EX_MASK_MULTITRACK as f64).unwrap(); + amf0::write_object_end(&mut buf).unwrap(); + + conn.handle_command(buf.as_slice()).unwrap(); + assert_eq!(conn.state, ConnState::AppConnected); + assert!(conn.negotiated_caps.has_caps_ex); + assert!(conn.negotiated_caps.multitrack_enabled); + } + + #[test] + fn connect_parse_failure_sends_error_response() { let mut conn = Conn::new(); let mut buf = Buffer::with_capacity(512); let long_app = "a".repeat(256); @@ -1488,10 +1894,18 @@ mod tests { "FMLE/3.0", 0, 0, + None, ) .unwrap(); - assert_eq!(conn.handle_command(buf.as_slice()), Err(ErrorCode::Amf)); + assert_eq!(conn.handle_command(buf.as_slice()), Ok(())); assert!(conn.app.is_empty()); + assert_ne!(conn.state, ConnState::AppConnected); + assert!( + conn.send_buffer + .peek() + .windows(b"_error".len()) + .any(|window| window == b"_error") + ); } #[test] @@ -1508,6 +1922,7 @@ mod tests { "FMLE/3.0", 0, 0, + None, ) .unwrap(); conn.handle_command(buf.as_slice()).unwrap(); @@ -1528,6 +1943,7 @@ mod tests { "FMLE/3.0", 0, 0, + None, ) .unwrap(); conn.handle_command(buf2.as_slice()).unwrap(); @@ -1946,6 +2362,105 @@ mod tests { assert!(matches!(conn.recv(&[1]), Err(ErrorCode::Protocol))); } + #[test] + fn multitrack_invokes_on_frame_cb_per_track() { + use std::sync::{LazyLock, Mutex}; + + static SEEN_TRACK_IDS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + + fn record_track_id(frame: &Frame) { + SEEN_TRACK_IDS.lock().unwrap().push(frame.track_id); + } + + SEEN_TRACK_IDS.lock().unwrap().clear(); + let mut conn = Conn::new(); + conn.relay_enabled = true; + conn.current_stream = Some(Box::new(Stream::new(1))); + if let Some(s) = conn.current_stream.as_mut() { + s.is_publishing = true; + } + conn.on_frame_cb = Some(record_track_id); + + let payload = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC, 0x01, + 0x00, 0x00, 0x02, 0xDD, 0xEE, + ]; + conn.handle_media_frame(1, FrameType::Video, 0, &payload) + .unwrap(); + + assert_eq!(*SEEN_TRACK_IDS.lock().unwrap(), vec![0, 1]); + assert_eq!(conn.pending_relay.len(), 1); + assert_eq!(conn.pending_relay[0].payload, payload); + } + + #[test] + fn multitrack_codec_is_detected_before_authorization() { + use std::sync::{LazyLock, Mutex}; + + static SEEN_CODEC: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + fn allow_media(_: u64, _: FrameType, codec: Option<&str>) -> bool { + *SEEN_CODEC.lock().unwrap() = codec.map(str::to_owned); + true + } + + let mut conn = Conn::new(); + conn.relay_enabled = true; + conn.current_stream = Some(Box::new(Stream::new(1))); + conn.current_stream.as_mut().unwrap().is_publishing = true; + conn.on_media_cb = Some(allow_media); + let payload = vec![0x86, 0x10, b'a', b'v', b'c', b'1', 0, 0, 0, 1, 0xAA]; + conn.handle_media_frame(1, FrameType::Video, 0, &payload) + .unwrap(); + assert_eq!(SEEN_CODEC.lock().unwrap().as_deref(), Some("avc1")); + } + + #[test] + fn multitrack_on_frame_cb_scratch_retains_last_track_payload() { + let mut conn = Conn::new(); + conn.relay_enabled = true; + conn.current_stream = Some(Box::new(Stream::new(1))); + if let Some(s) = conn.current_stream.as_mut() { + s.is_publishing = true; + } + conn.on_frame_cb = Some(|_| {}); + + let payload = vec![ + 0x86, 0x10, b'a', b'v', b'c', b'1', 0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC, 0x01, + 0x00, 0x00, 0x02, 0xDD, 0xEE, + ]; + conn.handle_media_frame(1, FrameType::Video, 0, &payload) + .unwrap(); + + assert_eq!(conn.frame_cb_scratch.as_slice(), &[0xDD, 0xEE]); + } + + #[test] + fn modex_is_normalized_for_callbacks_and_cache_but_relayed_opaque() { + let mut conn = Conn::new(); + conn.relay_enabled = true; + conn.negotiated_caps.has_caps_ex = true; + conn.negotiated_caps.caps_ex_mask = CAPS_EX_MASK_MODEX; + conn.current_stream = Some(Box::new(Stream::new(1))); + conn.current_stream.as_mut().unwrap().is_publishing = true; + conn.on_frame_cb = Some(|_| {}); + + let payload = vec![ + 0x97, 0x02, 0, 1, 2, 0x01, b'a', b'v', b'c', b'1', 0, 0, 0, 0xAA, + ]; + conn.handle_media_frame(1, FrameType::Video, 0, &payload) + .unwrap(); + + assert_eq!(conn.pending_relay[0].payload, payload); + assert_eq!( + conn.pending_relay[0].cache_payload(), + &[0x91, b'a', b'v', b'c', b'1', 0, 0, 0, 0xAA] + ); + assert_eq!( + conn.frame_cb_scratch, + vec![0x91, b'a', b'v', b'c', b'1', 0, 0, 0, 0xAA] + ); + } + #[test] fn on_frame_cb_scratch_retains_payload_after_delivery() { let mut conn = Conn::new(); @@ -2103,6 +2618,64 @@ mod tests { payload } + #[test] + fn on_metadata_is_queued_for_relay_while_publishing() { + let mut conn = Conn::new(); + conn.app = "live".to_string(); + conn.relay_enabled = true; + conn.current_stream = Some(Box::new(Stream::new(1))); + if let Some(ref mut stream) = conn.current_stream { + stream.is_publishing = true; + stream.name = "stream".to_string(); + } + let payload = build_on_metadata_payload(false, &[("width", 1920.0)], &[]); + conn.handle_publisher_data_message(1, 9000, &payload) + .unwrap(); + assert_eq!(conn.pending_relay.len(), 1); + assert_eq!(conn.pending_relay[0].frame_type, FrameType::Script); + assert_eq!(conn.pending_relay[0].timestamp, 9000); + assert_eq!(conn.pending_relay[0].payload, payload); + } + + #[test] + fn receive_video_reenable_requests_cached_replay() { + let mut conn = Conn::new(); + conn.current_stream = Some(Box::new(Stream::new(1))); + { + let stream = conn.current_stream.as_mut().unwrap(); + stream.is_playing = true; + stream.receive_video = false; + } + conn.needs_init_frames = false; + + let mut buf = Buffer::new(); + crate::amf::amf0::write_string(&mut buf, "receiveVideo").unwrap(); + crate::amf::amf0::write_number(&mut buf, 1.0).unwrap(); + crate::amf::amf0::write_null(&mut buf).unwrap(); + crate::amf::amf0::write_boolean(&mut buf, true).unwrap(); + conn.handle_command(buf.as_slice()).unwrap(); + + assert!(conn.current_stream.as_ref().unwrap().receive_video); + assert!(conn.needs_init_frames); + } + + #[test] + fn malformed_pause_command_leaves_stream_unpaused() { + let mut conn = Conn::new(); + conn.current_stream = Some(Box::new(Stream::new(1))); + if let Some(ref mut stream) = conn.current_stream { + stream.is_playing = true; + stream.paused = false; + } + + let mut buf = Buffer::new(); + crate::amf::amf0::write_string(&mut buf, "pause").unwrap(); + crate::amf::amf0::write_number(&mut buf, 1.0).unwrap(); + // Missing null + boolean: parse must fail without forcing paused=true. + assert!(conn.handle_command(buf.as_slice()).is_ok()); + assert!(!conn.current_stream.as_ref().unwrap().paused); + } + #[test] fn on_metadata_populates_conn_fields() { let mut conn = Conn::new(); diff --git a/src/session/stream.rs b/src/session/stream.rs index c623fac..9ffac19 100644 --- a/src/session/stream.rs +++ b/src/session/stream.rs @@ -12,6 +12,11 @@ pub struct Stream { pub is_playing: bool, /// Stream name from the `publish` or `play` command. pub name: String, + /// Set by the RTMP `pause` command. + pub paused: bool, + /// Honored by server relay when forwarding to this player. + pub receive_audio: bool, + pub receive_video: bool, } impl Stream { @@ -22,6 +27,9 @@ impl Stream { is_publishing: false, is_playing: false, name: String::new(), + paused: false, + receive_audio: true, + receive_video: true, } } } diff --git a/src/transport.rs b/src/transport.rs index a23bfa3..df54702 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -172,9 +172,9 @@ impl Transport { insecure: bool, ) -> Result { use openssl::ssl::{Ssl, SslContextBuilder, SslMode}; + use openssl::x509::X509; use openssl::x509::store::X509StoreBuilder; use openssl::x509::verify::X509CheckFlags; - use openssl::x509::X509; stream .set_read_timeout(Some(Duration::from_secs(TLS_ACCEPT_TIMEOUT_SECS))) @@ -184,8 +184,10 @@ impl Transport { .map_err(|_| ErrorCode::Io)?; let mut ctx = SslContextBuilder::new(SslMethod::tls()).map_err(|_| ErrorCode::Internal)?; - ctx.set_cipher_list("DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK") - .map_err(|_| ErrorCode::Internal)?; + ctx.set_cipher_list( + "DEFAULT:!aNULL:!eNULL:!MD5:!3DES:!DES:!RC4:!IDEA:!SEED:!aDSS:!SRP:!PSK", + ) + .map_err(|_| ErrorCode::Internal)?; // The non-blocking publish path (Client::try_flush_send_buffer) may // retry a WANT_READ/WANT_WRITE SSL_write() with a send_buffer that // has grown (a new frame appended after the pending one) since the @@ -216,7 +218,8 @@ impl Transport { ctx.set_cert_store(store.build()); } None => { - ctx.set_default_verify_paths().map_err(|_| ErrorCode::Internal)?; + ctx.set_default_verify_paths() + .map_err(|_| ErrorCode::Internal)?; } } } @@ -579,7 +582,7 @@ mod client_tls_tests { use openssl::pkey::PKey; use openssl::rsa::Rsa; use openssl::x509::extension::{BasicConstraints, SubjectAlternativeName}; - use openssl::x509::{X509NameBuilder, X509}; + use openssl::x509::{X509, X509NameBuilder}; use std::net::TcpListener; use std::os::unix::io::IntoRawFd; use std::sync::atomic::{AtomicU32, Ordering}; @@ -625,12 +628,8 @@ mod client_tls_tests { static COUNTER: AtomicU32 = AtomicU32::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let base = std::env::temp_dir().join(format!( - "librtmp2-test-{}-{}-{}", - std::process::id(), - n, - cn - )); + let base = + std::env::temp_dir().join(format!("librtmp2-test-{}-{}-{}", std::process::id(), n, cn)); let cert_path = base.with_extension("cert.pem"); let key_path = base.with_extension("key.pem"); std::fs::write(&cert_path, cert.to_pem().unwrap()).unwrap(); @@ -665,7 +664,11 @@ mod client_tls_tests { let stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); let result = Transport::connect_tls(stream, "insecure.test", None, true); - assert!(result.is_ok(), "insecure connect should succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "insecure connect should succeed: {:?}", + result.err() + ); let _ = std::fs::remove_file(cert_path); let _ = std::fs::remove_file(key_path); diff --git a/src/types.rs b/src/types.rs index b92e84b..6d756fd 100644 --- a/src/types.rs +++ b/src/types.rs @@ -204,6 +204,8 @@ pub struct Frame { pub video_frame_type: u8, /// Script/metadata flag pub is_metadata: u8, + /// E-RTMP multitrack id (0 = default track; 255 = not set / single-track). + pub track_id: u8, } impl Default for Frame { @@ -223,6 +225,7 @@ impl Default for Frame { video_fourcc: FourCc::default(), video_frame_type: 0, is_metadata: 0, + track_id: u8::MAX, } } } @@ -349,11 +352,22 @@ pub const ERTMP_PACKET_TYPE_CODED_FRAMES_X: u8 = 3; pub const ERTMP_PACKET_TYPE_METADATA: u8 = 4; pub const ERTMP_PACKET_TYPE_MPEG2TS_SEQUENCE_START: u8 = 5; -/// Enhanced RTMP v1 AudioPacketType +/// Enhanced RTMP v1/v2 AudioPacketType pub const ERTMP_AUDIO_PACKET_TYPE_SEQUENCE_START: u8 = 0; pub const ERTMP_AUDIO_PACKET_TYPE_CODED_FRAMES: u8 = 1; -pub const ERTMP_AUDIO_PACKET_TYPE_MULTICHANNEL: u8 = 2; +pub const ERTMP_AUDIO_PACKET_TYPE_SEQUENCE_END: u8 = 2; pub const ERTMP_AUDIO_PACKET_TYPE_METADATA: u8 = 3; +pub const ERTMP_AUDIO_PACKET_TYPE_MULTICHANNEL: u8 = 4; +pub const ERTMP_AUDIO_PACKET_TYPE_MULTITRACK: u8 = 5; +pub const ERTMP_VIDEO_PACKET_TYPE_MULTITRACK: u8 = 6; + +/// E-RTMP v2 `CapsExMask` bits carried in the connect `capsEx` AMF number. +pub const CAPS_EX_MASK_RECONNECT: u32 = 0x01; +pub const CAPS_EX_MASK_MULTITRACK: u32 = 0x02; +pub const CAPS_EX_MASK_MODEX: u32 = 0x04; +pub const CAPS_EX_MASK_TIMESTAMP_NANO: u32 = 0x08; +pub const CAPS_EX_MASK_SERVER_DEFAULT: u32 = + CAPS_EX_MASK_MULTITRACK | CAPS_EX_MASK_MODEX | CAPS_EX_MASK_TIMESTAMP_NANO; /// Enhanced RTMP v1 AudioSampleRate (legacy) pub const ERTMP_AUDIO_RATE_5500: u8 = 0; @@ -422,11 +436,19 @@ pub struct FourCcList { /* ── E-RTMP v2 capability negotiation ── */ -/// Video FourCC info map. +/// E-RTMP v2 per-codec capability flags. +pub const FOUR_CC_INFO_CAN_DECODE: u32 = 0x01; +pub const FOUR_CC_INFO_CAN_ENCODE: u32 = 0x02; +pub const FOUR_CC_INFO_CAN_FORWARD: u32 = 0x04; +pub const FOUR_CC_INFO_ALL: u32 = + FOUR_CC_INFO_CAN_DECODE | FOUR_CC_INFO_CAN_ENCODE | FOUR_CC_INFO_CAN_FORWARD; + +/// Video FourCC info map. Each entry's mask uses `FOUR_CC_INFO_*`. #[derive(Debug, Clone, Default)] #[repr(C)] pub struct VideoFourCcInfoMap { pub entries: [FourCc; MAX_FOURCCS], + pub masks: [u32; MAX_FOURCCS], pub count: usize, } @@ -582,6 +604,21 @@ impl Default for ScriptTag { /* ── Connect info ── */ +/// Negotiated E-RTMP capability set returned in connect `_result`. +#[derive(Debug, Clone, Default)] +pub struct NegotiatedCaps { + pub four_cc_list: FourCcList, + pub caps_ex: CapsExit, + pub video_four_cc_info_map: VideoFourCcInfoMap, + pub reconnect: Reconnect, + pub caps_ex_mask: u32, + pub has_four_cc_list: bool, + pub has_caps_ex: bool, + pub has_video_four_cc_info_map: bool, + pub has_reconnect: bool, + pub multitrack_enabled: bool, +} + /// Connect command information. #[derive(Debug, Clone)] #[repr(C)] @@ -595,6 +632,15 @@ pub struct ConnectInfo { pub flash_ver: [u8; 64], pub audio_codecs: i32, pub video_codecs: i32, + pub four_cc_list: FourCcList, + pub caps_ex: CapsExit, + pub video_four_cc_info_map: VideoFourCcInfoMap, + pub reconnect: Reconnect, + pub caps_ex_mask: u32, + pub has_four_cc_list: bool, + pub has_caps_ex: bool, + pub has_video_four_cc_info_map: bool, + pub has_reconnect: bool, } impl Default for ConnectInfo { @@ -609,6 +655,15 @@ impl Default for ConnectInfo { flash_ver: [0; 64], audio_codecs: 0, video_codecs: 0, + four_cc_list: FourCcList::default(), + caps_ex: CapsExit::default(), + video_four_cc_info_map: VideoFourCcInfoMap::default(), + reconnect: Reconnect::default(), + caps_ex_mask: 0, + has_four_cc_list: false, + has_caps_ex: false, + has_video_four_cc_info_map: false, + has_reconnect: false, } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5030884..c83b58f 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -65,12 +65,16 @@ pub fn make_video_frame(timestamp: u32, byte: u8) -> ([u8; SENT_FRAME_LEN], Fram video_fourcc: FourCc::default(), video_frame_type: 1, is_metadata: 0, + track_id: u8::MAX, }; (data, frame) } pub fn make_audio_frame(timestamp: u32) -> ([u8; 16], Frame) { - let data = [0xAF, 0x00, 0x12, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let data = [ + 0xAF, 0x00, 0x12, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + ]; let frame = Frame { frame_type: FrameType::Audio, timestamp, @@ -86,6 +90,7 @@ pub fn make_audio_frame(timestamp: u32) -> ([u8; 16], Frame) { video_fourcc: FourCc::default(), video_frame_type: 0, is_metadata: 0, + track_id: u8::MAX, }; (data, frame) } diff --git a/tests/e2e_multiple_frames.rs b/tests/e2e_multiple_frames.rs index f2f5819..d69253a 100644 --- a/tests/e2e_multiple_frames.rs +++ b/tests/e2e_multiple_frames.rs @@ -8,8 +8,8 @@ use librtmp2::client::Client; use librtmp2::server::Server; use common::{ - allow_publish, make_video_frame, on_video_frame, plain_config, poll_until, run_client_with_server, - FRAMES_RECEIVED, + FRAMES_RECEIVED, allow_publish, make_video_frame, on_video_frame, plain_config, poll_until, + run_client_with_server, }; #[test] @@ -26,9 +26,13 @@ fn server_observes_multiple_published_video_frames() { client.connect("rtmp://127.0.0.1:19667/live/multiframe")?; client.publish()?; + let mut frames = Vec::with_capacity(5); for i in 0..5u8 { - let (_data, frame) = make_video_frame(u32::from(i) * 40, i); - client.send_frame(&frame)?; + frames.push(make_video_frame(u32::from(i) * 40, i)); + } + for (data, frame) in &frames { + let _ = data; + client.send_frame(frame)?; } Ok(()) }); diff --git a/tests/e2e_publish_play_same_listener.rs b/tests/e2e_publish_play_same_listener.rs index daa4bb8..65fd316 100644 --- a/tests/e2e_publish_play_same_listener.rs +++ b/tests/e2e_publish_play_same_listener.rs @@ -11,8 +11,8 @@ use librtmp2::server::Server; use librtmp2::types::ErrorCode; use common::{ - allow_play, allow_publish, make_video_frame, on_video_frame, plain_config, poll_until, - run_client_with_server, FRAMES_RECEIVED, SENT_FRAME_BYTE, + FRAMES_RECEIVED, SENT_FRAME_BYTE, allow_play, allow_publish, make_video_frame, on_video_frame, + plain_config, poll_until, run_client_with_server, }; #[test] diff --git a/tests/e2e_publish_rejection.rs b/tests/e2e_publish_rejection.rs index a089e25..2831d9f 100644 --- a/tests/e2e_publish_rejection.rs +++ b/tests/e2e_publish_rejection.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use librtmp2::client::Client; use librtmp2::server::Server; -use common::{deny_publish, plain_config, poll_until, SENT_FRAME_LEN}; +use common::{SENT_FRAME_LEN, deny_publish, plain_config, poll_until}; static SERVER_FRAMES: AtomicUsize = AtomicUsize::new(0); @@ -41,11 +41,9 @@ fn publish_rejected_by_callback_does_not_deliver_frames() { "publish should fail when on_publish_cb returns false" ); - poll_until( - &mut server, - Instant::now() + Duration::from_secs(2), - || false, - ); + poll_until(&mut server, Instant::now() + Duration::from_secs(2), || { + false + }); assert_eq!( SERVER_FRAMES.load(Ordering::SeqCst), diff --git a/tests/ertmp_v2_review_regressions.rs b/tests/ertmp_v2_review_regressions.rs new file mode 100644 index 0000000..5d11efc --- /dev/null +++ b/tests/ertmp_v2_review_regressions.rs @@ -0,0 +1,72 @@ +use librtmp2::buffer::Buffer; +use librtmp2::ertmp::connect_amf::{ + read_four_cc_list_amf, read_video_fourcc_info_map_amf, write_four_cc_list_amf, + write_video_fourcc_info_map_amf, +}; +use librtmp2::ertmp::multitrack_media::foreach_track; +use librtmp2::types::{FOUR_CC_INFO_CAN_FORWARD, FourCcList, FrameType, VideoFourCcInfoMap}; + +#[test] +fn video_fourcc_info_map_round_trips_as_object_with_flags() { + let mut offered = VideoFourCcInfoMap::default(); + offered.entries[0].cc[..4].copy_from_slice(b"vp09"); + offered.masks[0] = FOUR_CC_INFO_CAN_FORWARD; + offered.count = 1; + + let mut wire = Buffer::new(); + write_video_fourcc_info_map_amf(&mut wire, &offered).unwrap(); + + let mut parsed = VideoFourCcInfoMap::default(); + read_video_fourcc_info_map_amf(&mut wire, &mut parsed).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(&parsed.entries[0].cc[..4], b"vp09"); + assert_eq!(parsed.masks[0], FOUR_CC_INFO_CAN_FORWARD); +} + +#[test] +fn video_fourcc_info_map_preserves_wildcard_flags() { + let mut offered = VideoFourCcInfoMap::default(); + offered.entries[0].cc[0] = b'*'; + offered.masks[0] = FOUR_CC_INFO_CAN_FORWARD; + offered.count = 1; + + let mut wire = Buffer::new(); + write_video_fourcc_info_map_amf(&mut wire, &offered).unwrap(); + + let mut parsed = VideoFourCcInfoMap::default(); + read_video_fourcc_info_map_amf(&mut wire, &mut parsed).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(parsed.entries[0].cc[0], b'*'); + assert_eq!(parsed.masks[0], FOUR_CC_INFO_CAN_FORWARD); +} + +#[test] +fn fourcc_wildcard_survives_amf_round_trip() { + let mut offered = FourCcList::default(); + offered.entries[0].cc[0] = b'*'; + offered.count = 1; + + let mut wire = Buffer::new(); + write_four_cc_list_amf(&mut wire, &offered).unwrap(); + + let mut parsed = FourCcList::default(); + read_four_cc_list_amf(&mut wire, &mut parsed).unwrap(); + assert_eq!(parsed.count, 1); + assert_eq!(parsed.entries[0].cc[0], b'*'); +} + +#[test] +fn many_tracks_many_codecs_reports_each_track_codec() { + let payload = [ + 0x86, 0x20, b'a', b'v', b'c', b'1', 0, 0, 0, 1, 0xAA, b'h', b'v', b'c', b'1', 1, 0, 0, 1, + 0xBB, + ]; + let mut seen = Vec::new(); + assert!(foreach_track(FrameType::Video, &payload, |track| { + seen.push((track.track_id, track.fourcc, track.payload.to_vec())); + })); + assert_eq!( + seen, + vec![(0, *b"avc1", vec![0xAA]), (1, *b"hvc1", vec![0xBB])] + ); +} diff --git a/tests/server_client_loopback.rs b/tests/server_client_loopback.rs index 0883b5b..1d3bff8 100644 --- a/tests/server_client_loopback.rs +++ b/tests/server_client_loopback.rs @@ -70,6 +70,7 @@ fn server_client_publish_over_real_sockets() { video_fourcc: FourCc::default(), video_frame_type: 1, is_metadata: 0, + track_id: u8::MAX, }; client.send_frame(&frame)?; Ok(()) @@ -238,6 +239,7 @@ fn publisher_and_player_relay_across_different_listeners() { video_fourcc: FourCc::default(), video_frame_type: 1, is_metadata: 0, + track_id: u8::MAX, }; publisher.send_frame(&frame)?;