oxideav-rtmp
Pure-Rust RTMP for the
oxideav framework — accept an
incoming publisher (server / source) or push your own stream to a
remote RTMP server (client / sink). Zero external dependencies,
blocking-thread-per-connection.
Server (accept a publisher)
use ;
let server = bind?;
let req = server.accept?; // blocks until one publisher connects
if req.stream_name != "my-secret-key"
let mut session = req.accept?; // sends NetStream.Publish.Start
while let Some = session.next_packet?
Multi-client variant — one thread per connection:
server.serve?;
Client (push to a remote RTMP server)
use RtmpClient;
let mut client = connect?;
client.send_video_sequence_header?; // AVCDecoderConfigurationRecord
client.send_audio_sequence_header?; // 2-byte AudioSpecificConfig
loop
client.close?;
Scope
-
RTMP (
rtmp://, plain TCP port 1935). No RTMPS yet — wrap ourRead + Writewith rustls if you need it, or request anrtmpsfeature. -
Publish direction only. The server accepts incoming publishers; the client pushes to remote servers. RTMP play (subscribe / pull) is a follow-up.
-
AMF0 command flow is what every commodity ingest endpoint negotiates by default. The decoder handles every marker real RTMP traffic uses, including object references (marker 0x07, FLV v10.1 §E.4.4.2
SCRIPTDATAVALUE.Type == 7, aUI16index into the serialized complex-object table): a reference is dereferenced transparently to a clone of the value it points at, so callers never see aReferencevariant and a reference-deduplicatedonMetaDatadecodes correctly instead of being refused. An out-of-range or truncated reference index is a cleanInvalidAmf0error. The [amf3] module ships a complete AMF3 wire-format encoder + decoder — all thirteen markers plus the three reference tables — and AMF3 data / command messages are now routed end-to-end: the server decodesonMetaDatacarried as a type-15 AMF3 data message (per AMF 3 spec §4.1 / AMF 0 spec §3.1, an AMF0 frame switching to AMF3 via theavmplus-object-marker0x11), bridges the AMF3 value graph ontoAmf0Value, and surfaces it through the sameStreamPacket::Metadatapath as AMF0; type-17 AMF3 commands feed the same stream-teardown detection.RtmpClient::send_metadata_amf3emits the AMF3-encoded form for peers on an AMF3 channel. Externalizable objects (§3.12U29O-traits-ext, whose*(U8)body framing is a private class agreement the spec leaves "indeterminable") are decodable viaDecoder::register_externalizable, where a caller supplies a body-length resolver for a known class; an unregistered class is refused rather than guessed. Shared objects, RTMFP, and the Adobe digest-verified handshake remain unimplemented. -
H.264 + AAC are the canonical legacy payloads, plus Enhanced RTMP v1 (Veovera 2023) FourCC video codecs —
hvc1(HEVC / H.265),av01(AV1),vp09(VP9) — and the Enhanced RTMP v2 (Veovera 2026) additions:vp08(VP8),avc1(FourCC-mode AVC / H.264),vvc1(VVC / H.266). Sequence-start (HEVC / VVC / AVCDecoderConfigurationRecord,AV1CodecConfigurationRecord,VPCodecConfigurationRecordfor VP8 + VP9),CodedFrames,CodedFramesX(CTS=0 omitted),SequenceEnd, and the HDRPacketTypeMetadata(colorInfo) frames all round-trip viaflv::parse_video/flv::build_video. The HDRcolorInfoobject is also lifted into a strongly-typed [ColorInfo] view (perenhanced-rtmp-v2.pdf§"Metadata Frame"):VideoTag::color_info()decodes the["colorInfo", Object]pair intocolorConfig(bitDepth+ the ITU-T H.273colorPrimaries/transferCharacteristics/matrixCoefficientsenumeration indices),hdrCll(maxFall/maxCLLcontent light level) andhdrMdcv(SMPTE ST 2086:2018 mastering-display chromaticities + min/max luminance), andVideoTag::color_info_tag(fourcc, &ColorInfo)rebuilds the outbound metadata tag. Every property isOption<f64>(AMF's native double, byte-exact) and each sub-object isOption, so a partialcolorInforound-trips; the spec's "reset to original color state" signal —Undefined(RECOMMENDED) or an empty{}— surfaces as [ColorInfo::is_reset] and re-encodes asUndefined. SI24compositionTimeOffsetis emitted on the wire for the three NALU-based FourCCs (hvc1/avc1/vvc1) withCodedFramesand stripped forCodedFramesXper the v2 spec. The crate passes through FLV tag bytes verbatim, so additional codecs (MP3, H.263, Speex, …) keep working too. -
Enhanced RTMP v2 (Veovera 2026) FourCC audio codecs:
Opus,fLaC(FLAC),ac-3(AC-3),ec-3(E-AC-3),.mp3,mp4a(AAC, added FourCC signalling).SequenceStart,CodedFrames, andSequenceEndAudioPacketTypes round-trip viaflv::parse_audio/flv::build_audio; bodies are the per-codec shapes called out inenhanced-rtmp-v2.pdf§"ExAudioTagBody" (OpusHeadID-header per RFC 7845 §5.1 for Opus SequenceStart,fLaC + STREAMINFOper Xiph FLAC §7 for FLAC SequenceStart, ATSC sync frames for AC-3 / E-AC-3 CodedFrames, MPEG Layer III frames for MP3, ISO/IEC 14496-3AudioSpecificConfigfor FourCC-AAC SequenceStart). TheMultichannelConfigAudioPacketType is also decoded end-to-end:AudioTag::multichannel_config()lifts the body into the strongly-typedMultichannelConfigview (perenhanced-rtmp-v2.pdf§"ExAudioTagBody" —audioChannelOrder(UI8) | channelCount(UI8) | (audioChannelMapping[UI8] | audioChannelFlags(UI32))), andAudioTag::multichannel_config_tagrebuilds the matching outbound tag.audio_channel/audio_channel_maskpublic submodules name all 24 spec-defined positions (including the 22.2 surround extras from SMPTE ST 2036-2-2008). TheMultitrackAudioPacketType / VideoPacketType is also wired end-to-end: themultitrackType (UB[4]) | realPacketType (UB[4])byte plus the optional shared FourCC (per spec, omitted inManyTracksManyCodecsmode) are consumed inline byparse_audio/parse_video, and the per-track list ((trackFourCc if ManyTracksManyCodecs) | trackId(UI8) | (sizeOfTrack(UI24) if not OneTrack) | body) lifts to a typed [Multitrack { multitrack_type, tracks: Vec<MultitrackTrack> }] onVideoTag::multitrack/AudioTag::multitrack.OneTrack/ManyTracks/ManyTracksManyCodecsall round-trip — including the inner-PacketType-MUST-NOT-be-Multitrack invariant from the spec — via the newVideoTag::multitrack_tag/AudioTag::multitrack_tagbuilders. ReservedmultitrackTypevalues (3..=15) pass through verbatim so a forwarding ingest preserves future modes. -
Graceful session close.
RtmpSession::closeemits aUserControl StreamEOF(stream_id)(RTMP 1.0 §7.1.7) beforeonStatus("NetStream.Unpublish.Success"), flushes the chunk writer, and half-closes the write side. The peer drains every buffered frame plus the EOF + status reply before observing FIN, matching the client-side teardown behaviour. Symmetrically,RtmpClient::poll_eventsurfaces every server-originated User Control Message defined in RTMP 1.0 §3.7 —StreamBegin,StreamEOF,StreamDry,StreamIsRecorded,PingResponse— as typedClientEventvariants alongsideonStatus(...),_result, and_errorreplies, so a publisher can distinguish a clean server-initiated end-of-stream from a transient "no data" probe, an on-demand-recorded advertisement, or the round-trip echo of its ownRtmpClient::send_ping_request. Server-originatedPingRequestis auto-replied internally (per spec §3.7 — it's a liveness probe, not an application event), and the matchingRtmpSession::send_stream_dry/send_stream_is_recorded/send_ping_requesthelpers let an ingest broadcast the same UCM events to its publishers.SetBufferLength(the only 8-byte UCM payload — 4-byte stream id + 4-byte buffer length in ms) is validated on the wire and surfaces aProtocolViolationif truncated. The rest of the protocol-control plumbing (Set Chunk Size, Window Ack Size, Set Peer Bandwidth) is handled transparently insidepoll_event. -
Acknowledgement window (RTMP 1.0 §5.3 / §5.5 / §5.6). The chunk reader now counts every byte it consumes off the wire (basic header, message header, extended timestamp, payload) as the §5.3 sequence number, and both peers honour the §5.3 obligation to "send the acknowledgment to the peer after receiving bytes equal to the window size." A peer's §5.5 Window Acknowledgement Size — or the §5.6 Set Peer Bandwidth output-bandwidth value, which the spec defines as equal to the window size — is captured during setup and steady state (
ChunkReader::set_window_ack_size), andRtmpSession::next_packet(server) /RtmpClient::poll_event(client) emit abuild_ack(seq)carrying the running received-byte count the first time the count crosses each window, re-arming only after another full window so a steady stream never spams acks.ChunkReader::ack_dueis the public hook (returnsSome(seq)when an Acknowledgement is owed, advancing the internal mark), withChunkReader::received_bytes/window_ack_sizeaccessors; resetting the window re-bases the byte accounting so a freshly-shrunk window doesn't make an already-counted byte instantly owe an ack. With no window negotiated (the historical default) the obligation stays dormant, byte-identical to the pre-§5.3 behaviour. -
Injection-robust parser surface. Every public decode entry point — AMF0 (
decode/decode_all), AMF3 (decode/decode_all/decode_data_message), FLV (parse_video/parse_audio), the chunk-stream reader (ChunkReader::read_message), both handshake directions — is fuzzed against deterministic random byte streams (tests/injection_robustness.rs) and against structurally-corrupted RTMP frames (oversize lengths, forged fmt-1 chunks without prior fmt-0 state, truncated handshakes, wrong RTMP version bytes,u32::MAXstrict-array counts). Every call returnsResult, never panics, never spins, and never over-allocates. Stack-overflow protection:amf::MAX_DECODE_DEPTHandamf3::MAX_DECODE_DEPTH(both 64) cap nested-container recursion in both AMF dialects before the call stack runs out, so a forged onMetaData with 2_000 nested Objects surfaces a clean error rather than crashing. -
Enhanced RTMP v1+v2 NetConnection
connectcapability negotiation (Veovera 2023+2026).RtmpClient::connect_with_capabilitiesadvertises a [ConnectCapabilities] block in the publisher'sconnectCommand Object perenhanced-rtmp-v2.pdf§"Enhancing NetConnection connect Command": the v1fourCcListstrict-array, the v2 object mapsvideoFourCcInfoMap/audioFourCcInfoMapwith per-codec bitmask values (FourCcInfoMask.CanDecode = 0x01/CanEncode = 0x02/CanForward = 0x04, with the"*"wildcard honoured), and the v2capsExu32 bitfield (Reconnect = 0x01/Multitrack = 0x02/ModEx = 0x04/TimestampNanoOffset = 0x08). The block is appended after the historicalvideoFunctionfield so legacy peers keep parsing the message correctly.RtmpServer::set_capabilitiessymmetrically lets a server stamp its OWN capability block into the_result(connect)info object alongside theNetConnection.Connect.Successstatus; the publisher's advertised block surfaces onPublishRequest::capabilitiesand the server's advertised block surfaces onRtmpClient::server_capabilities(). The empty / default block produces byte-identical output to the legacy pre-2023 connect command. Resolves the previous "high-level publish helper opts in once the configurable codec-list follow-up lands" note for both audio and video FourCC advertisements. -
Enhanced RTMP v2 Reconnect Request (Veovera 2026). The
NetConnection.Connect.ReconnectRequeststatus event (enhanced-rtmp-v2.pdf§"Reconnect Request") is wired end-to-end:RtmpSession::send_reconnect_request(tc_url, description)emits the spec's NetConnection-level onStatus command (message stream 0, transaction id 0, null Command Object;code/levelmandatory,tcUrl/descriptionomitted from the wire whenNone) so an ingest can ask its publisher to move ahead of a server update or a load-balancing remap, andRtmpClient::poll_eventsurfaces it as the typedClientEvent::ReconnectRequest { tc_url, description }(the spec's level-MUST-be-statusrule is enforced — a mismatched level stays a plainOnStatus).RtmpClient::resolve_reconnect_url/ the freeresolve_tc_url(base, reference)apply the spec's resolution rule for all four documentedtcUrlshapes (absolute,//host/app,/app,app;Nonefalls back to the current connection's tcUrl, exposed viaRtmpClient::tc_url()). Per spec, neither side tears the session down on the event — the old server keeps processing publisher messages until the client disconnects at its next media boundary (tests/reconnect_request.rsproves both directions over loopback). -
Enhanced RTMP v2
ModExprelude (Veovera 2026). TheModExpacket-type signal (enhanced-rtmp-v2.pdf§"ExVideoTagHeader" / §"ExAudioTagHeader") is decoded for both audio and video: a chain of size-prefixedmodExDataentries (UI8 + 1length, escaping to0xFF+UI16 + 1for 256..=65536-byte payloads) preceding the FourCC, each terminated by amodExType | next-PacketTypenibble byte. The chain round-trips throughflv::parse_*/flv::build_*via the newVideoTag::mod_ex/AudioTag::mod_ex(Vec<flv::ModEx>) fields, and the real PacketType is recovered from the chain so the packet adapters route a ModEx-prefixed tag transparently. The only subtype defined today,TimestampOffsetNano(abytesToUI24sub-millisecond presentation offset, 0..=999_999 ns), is exposed via{Video,Audio}Tag::timestamp_offset_nano. The [SourceRegistry]/[audio_to_packet]/[video_to_packet] adapter now folds that nanosecond offset onto thePackettimeline at source: the timeline switched toRTMP_TIME_BASE = 1/1_000_000_000(nanoseconds), soptsanddtsare emitted asms * RTMP_MS_TO_NSand the per-messageTimestampOffsetNanosum is added to the presentation time per spec — for audio bothpts == dtsreceive the offset; for video onlypts(decode timestamp stays unmodified, matching the §"ExVideoTagHeader" contract "without altering the core RTMP timestamp"). The exposedRTMP_MS_TO_NS(= 1_000_000) constant lets a consumer recover the wire ms value when needed. Multi-entry chains and out-of-band ModEx subtypes are summed via the typed accessor so future subtypes never feed the timestamp sum by accident.
Pipeline integration (SourceRegistry)
Wire rtmp:// URIs into the workspace's
[oxideav_core::SourceRegistry] so the pipeline executor reads
RTMP streams via the same dispatch as file:// and http(s)://:
use SourceRegistry;
let mut reg = new;
register;
// `rtmp://host:port/app/stream-name` opens a one-shot listener
// that accepts a single publisher and surfaces it as a
// `PacketSource` (audio = stream 0, video = stream 1, both with
// time_base 1/1_000_000_000 — nanoseconds, so Enhanced-RTMP-v2
// `TimestampOffsetNano` ModEx entries fold onto the same uniform
// `Packet` timeline). Codec ids are auto-detected from the
// publisher's first audio + video tags (h264, hevc, av1, vp9 in
// Enhanced-RTMP-v1 FourCC mode; vp8, h264 (avc1), vvc added in
// Enhanced-RTMP-v2 FourCC mode; h264, h263, vp6f / vp6a, flashsv /
// flashsv2 for legacy single-byte codec ids; opus, flac, ac3,
// eac3, mp3, aac in Enhanced-RTMP-v2 audio FourCC mode; aac, mp3,
// pcm_*, speex, nellymoser for legacy single-byte audio
// sound-format).
let _src = reg.open?;
The opener is listen-style: each open() binds the URL's
host:port, accepts one publisher, validates the announced
app + stream_name against the URL path (rejects on
mismatch), then hands packets to the registry. For multi-client
service, keep using [RtmpServer::serve] directly.
Reusable building blocks
The lower-level modules are public so callers can compose something non-standard:
amf::{encode, decode, encode_command, Amf0Value}amf3::{encode, decode, decode_all, decode_data_message, encode_all, Amf3Value, Decoder}plusAmf3Value::to_amf0()andamf3::AVMPLUS_OBJECT_MARKERcaps::{ConnectCapabilities, FourCcInfoMap}plus the spec-mirroringFOURCC_INFO_CAN_DECODE/CAPS_EX_*/OBJECT_ENCODING_*/FOURCC_WILDCARDconstants — Enhanced RTMP v1+v2 NetConnectionconnectcapability negotiation perenhanced-rtmp-v2.pdf§"Enhancing NetConnection connect Command". Compose withRtmpClient::connect_with_capabilities(publisher) andRtmpServer::set_capabilities(ingest) for high-level use, or callConnectCapabilities::encode_into/from_amf0directly to bridge to a custom command pipeline.chunk::{ChunkReader, ChunkWriter, Message, MessageStreamKind}—Message::stream_kind()lifts the rawmsg_stream_id: u32field into a typedControl/NetStream(id)/Reserved(raw)view per RTMP Message Formats spec §4.1 + §5, andMessage::validate_protocol_control_invariants()returnsErr(ProtocolViolation)whenever a protocol-control message (msg_type_id1..=6) carries a non-zeromsg_stream_id— the spec §5 mandate "Protocol control messages MUST have message stream ID 0 (called as control stream)" — or whenever the §4.1 reserved high-byte rule is violated.ChunkReader::abort_partial(csid)applies an inbound Abort Message (RTMP 1.0 §5.2, protocol-control type 2): it discards the half-filled reassembly buffer for the named chunk stream id so abandoned bytes never splice onto the next message on that csid (no-op when nothing is in flight);message::build_abortis the matching outbound 4-byte-BE-csid builder.aggregate::{parse_aggregate, build_aggregate}— Aggregate Message (RTMP 1.0 §7.1.6 type 22) parser + builder. Splits a singleMessageof type id 22 into its component FLV-shaped sub-messages (audio / video / data / command), applying the §7.1.6 timestamp re-normalisation rule (t_i + (aggregate.timestamp - t_0)) and honouring the spec's "stream id of the aggregate overrides the stream ids of the sub-messages" override. Symmetrically packs a&[Message]slice into an aggregate carrying the correct §E.3PreviousTagSizeback-pointers — useful when a publisher wants to cut chunk-header overhead by bundling several frames into one message before they hitChunkWriter::write_message. Routed end-to-end:RtmpSession::next_packetdecomposes incoming aggregates into a per-session queue and surfaces each sub as the sameStreamPacket::{Audio,Video,Metadata}the publisher would have produced individually, including the spec's teardown-command detection on aggregatedcloseStream/deleteStream/FCUnpublishsubs.RtmpClient::poll_eventdoes the symmetric decomposition for server-pushed aggregates, andRtmpClient::send_aggregate(&[Message])is the outbound helper — every sub'smsg_stream_idis overridden to the active publish stream id per §7.1.6, the aggregate is framed onCSID_DATA, and an empty slice is a no-op.handshake::{client_handshake, server_handshake}flv::{parse_video, build_video, parse_audio, build_audio, ModEx}flv_file::{FlvWriter, FlvReader, FlvTag, FlvHeaderFlags, build_flv_header, build_flv_tag, DEFAULT_MAX_TAG_SIZE}— FLV file / byte-stream serializer + parser (Annex E offlv_v10_1.pdf).FlvWriter<W>framesVideoTag/AudioTagplus AMF0 script-data tags into the on-disk.flvlayout (9-byte file header + alternatingPreviousTagSize/FLVTAGbody).FlvReader<R>is the inverse: walks the §E.2 header, the §E.3 alternating back-pointers, and each §E.4.1FLVTAG, surfacing each tag as a strongly-typed [FlvTag] (Audio/Video/Script/ opaqueUnknownfor reservedTagTypevalues). Verifies the §E.3PreviousTagSize == 11 + DataSizeinvariant on every tag and refuses to advance past a mismatch; bounds the per-tagDataSizeby [DEFAULT_MAX_TAG_SIZE] (UI24 ceiling = 16 MiB) or a caller-supplied cap via [FlvReader::with_max_tag_size]; surfaces a cleanError::UnexpectedEofon a truncated header / payload andError::Otheron a forward-incompatibleFilter = 1(Annex F encrypted body). Useful as a recorder for anRtmpSession(write eachStreamPacketto anio::Writesink) and as the foundation for an HTTP-FLV bridge — the body of an HTTP-FLV response is exactly this byte stream, served withContent-Type: video/x-flv, and a downstream consumer can re-decode it withFlvReaderwithout re-implementing the §E.3 walk.message::build_*— builders for every protocol-control / command message we emitmessage::UserControlEvent— typed view of a User Control Message body per RTMP 1.0 §3.7 / §7.1.7.UserControlEvent::parse(payload)classifies the 2-byte BE event type + variable event data into one of the seven spec-defined variants —StreamBegin/StreamEof/StreamDry/SetBufferLength { stream_id, buffer_ms }/StreamIsRecorded/PingRequest { timestamp_ms }/PingResponse { timestamp_ms }— or [UserControlEvent::Unknown { event_type, data }] for the spec-reserved type 5 and any future event type ≥ 8.UserControlEvent::to_message()is the inverse: every spec-defined variant rebuilds the byte-for-byte payload the matchingbuild_user_control_*builder emits, and theUnknownvariant concatenatesevent_type:U16BE | dataverbatim so a forwarding ingest preserves forward-compatible UCMs without re-encoding. Spec-defined variants validate their fixed event-data size (4 bytes for the stream-id-carrying variants and ping, 8 bytes forSetBufferLength) on parse and surfaceError::ProtocolViolationon truncation.