oxideav-rtmp 0.0.6

Pure-Rust RTMP (ingest + push) for oxideav — server accepts publishers, client pushes to remote servers, with a pluggable key-verification hook
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# oxideav-rtmp

Pure-Rust **RTMP** for the
[`oxideav`](https://github.com/OxideAV/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)

```rust
use oxideav_rtmp::{RtmpServer, StreamPacket};

let server = RtmpServer::bind("0.0.0.0:1935")?;
let req = server.accept()?;                         // blocks until one publisher connects

if req.stream_name != "my-secret-key" {
    req.reject("unauthorized")?;
    return Ok(());
}

let mut session = req.accept()?;                    // sends NetStream.Publish.Start
while let Some(pkt) = session.next_packet()? {
    match pkt {
        StreamPacket::Video { timestamp, tag } => { /* AVC bytes in `tag.body` */ }
        StreamPacket::Audio { timestamp, tag } => { /* AAC bytes in `tag.body` */ }
        StreamPacket::Metadata(meta)           => { /* onMetaData object (AMF0 or AMF3, bridged to AMF0) */ }
    }
}
```

Multi-client variant — one thread per connection:

```rust
server.serve(|req| {
    if auth_ok(&req.app, &req.stream_name) {
        let session = req.accept().expect("accept");
        route(session);
    } else {
        let _ = req.reject("forbidden");
    }
})?;
```

## Client (push to a remote RTMP server)

```rust
use oxideav_rtmp::RtmpClient;

let mut client = RtmpClient::connect("rtmp://origin.example.com:1935/live/stream-key-abc")?;

client.send_video_sequence_header(&avcc_bytes)?;    // AVCDecoderConfigurationRecord
client.send_audio_sequence_header(&aac_asc)?;       // 2-byte AudioSpecificConfig

loop {
    client.send_video(ts_ms, is_keyframe, &length_prefixed_nalus)?;
    client.send_audio(ts_ms, &raw_aac_frame)?;
}

client.close()?;
```

## Scope

- **RTMP** (`rtmp://`, plain TCP port 1935). No RTMPS yet — wrap our
  `Read + Write` with rustls if you need it, or request an `rtmps`
  feature.
- **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`, a `UI16` index into the
  serialized complex-object table): a reference is dereferenced
  transparently to a clone of the value it points at, so callers never
  see a `Reference` variant and a reference-deduplicated `onMetaData`
  decodes correctly instead of being refused. An out-of-range or
  truncated reference index is a clean `InvalidAmf0` error. 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 decodes `onMetaData` carried 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 the `avmplus-object-marker` `0x11`),
  bridges the AMF3 value graph onto `Amf0Value`, and surfaces it
  through the same `StreamPacket::Metadata` path as AMF0; type-17
  AMF3 commands feed the same stream-teardown detection.
  `RtmpClient::send_metadata_amf3` emits the AMF3-encoded form for
  peers on an AMF3 channel. Externalizable objects (§3.12
  `U29O-traits-ext`, whose `*(U8)` body framing is a private class
  agreement the spec leaves "indeterminable") are decodable via
  `Decoder::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 / AVC `DecoderConfigurationRecord`,
  `AV1CodecConfigurationRecord`, `VPCodecConfigurationRecord` for
  VP8 + VP9), `CodedFrames`, `CodedFramesX` (CTS=0 omitted),
  `SequenceEnd`, and the HDR `PacketTypeMetadata` (`colorInfo`)
  frames all round-trip via `flv::parse_video` / `flv::build_video`.
  The HDR `colorInfo` object is also lifted into a **strongly-typed
  [`ColorInfo`] view** (per `enhanced-rtmp-v2.pdf` §"Metadata Frame"):
  `VideoTag::color_info()` decodes the `["colorInfo", Object]` pair into
  `colorConfig` (`bitDepth` + the ITU-T H.273 `colorPrimaries` /
  `transferCharacteristics` / `matrixCoefficients` enumeration indices),
  `hdrCll` (`maxFall` / `maxCLL` content light level) and `hdrMdcv`
  (SMPTE ST 2086:2018 mastering-display chromaticities + min/max
  luminance), and `VideoTag::color_info_tag(fourcc, &ColorInfo)` rebuilds
  the outbound metadata tag. Every property is `Option<f64>` (AMF's native
  double, byte-exact) and each sub-object is `Option`, so a partial
  `colorInfo` round-trips; the spec's "reset to original color state"
  signal — `Undefined` (RECOMMENDED) or an empty `{}` — surfaces as
  [`ColorInfo::is_reset`] and re-encodes as `Undefined`.
  SI24 `compositionTimeOffset` is emitted on the wire for the three
  NALU-based FourCCs (`hvc1` / `avc1` / `vvc1`) with `CodedFrames`
  and stripped for `CodedFramesX` per 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`, and `SequenceEnd` AudioPacketTypes round-trip via
  `flv::parse_audio` / `flv::build_audio`; bodies are the per-codec
  shapes called out in `enhanced-rtmp-v2.pdf` §"ExAudioTagBody"
  (`OpusHead` ID-header per RFC 7845 §5.1 for Opus SequenceStart,
  `fLaC + STREAMINFO` per 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-3 `AudioSpecificConfig` for FourCC-AAC
  SequenceStart). The `MultichannelConfig` AudioPacketType is also
  decoded end-to-end: `AudioTag::multichannel_config()` lifts the body
  into the strongly-typed `MultichannelConfig` view (per `enhanced-rtmp-v2.pdf`
  §"ExAudioTagBody" — `audioChannelOrder(UI8) | channelCount(UI8) |
  (audioChannelMapping[UI8] | audioChannelFlags(UI32))`), and
  `AudioTag::multichannel_config_tag` rebuilds the matching outbound tag.
  `audio_channel` / `audio_channel_mask` public submodules name all 24
  spec-defined positions (including the 22.2 surround extras from SMPTE
  ST 2036-2-2008). The `Multitrack` AudioPacketType / VideoPacketType
  is also wired end-to-end: the `multitrackType (UB[4]) | realPacketType
  (UB[4])` byte plus the optional shared FourCC (per spec, omitted in
  `ManyTracksManyCodecs` mode) are consumed inline by `parse_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> }`] on
  `VideoTag::multitrack` / `AudioTag::multitrack`. `OneTrack` /
  `ManyTracks` / `ManyTracksManyCodecs` all round-trip — including the
  inner-PacketType-MUST-NOT-be-Multitrack invariant from the spec — via
  the new `VideoTag::multitrack_tag` / `AudioTag::multitrack_tag`
  builders. Reserved `multitrackType` values (3..=15) pass through
  verbatim so a forwarding ingest preserves future modes.
- **Graceful session close**. `RtmpSession::close` emits a
  `UserControl StreamEOF(stream_id)` (RTMP 1.0 §7.1.7) before
  `onStatus("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_event`
  surfaces every server-originated User Control Message defined in
  RTMP 1.0 §3.7 — `StreamBegin`, `StreamEOF`, `StreamDry`,
  `StreamIsRecorded`, `PingResponse` — as typed `ClientEvent` variants
  alongside `onStatus(...)`, `_result`, and `_error` replies, 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 own `RtmpClient::send_ping_request`.
  Server-originated `PingRequest` is auto-replied internally (per spec
  §3.7 — it's a liveness probe, not an application event), and the
  matching `RtmpSession::send_stream_dry` / `send_stream_is_recorded` /
  `send_ping_request` helpers 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 a `ProtocolViolation` if truncated. The rest
  of the protocol-control plumbing (Set Chunk Size, Window Ack Size,
  Set Peer Bandwidth) is handled transparently inside `poll_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`), and `RtmpSession::next_packet`
  (server) / `RtmpClient::poll_event` (client) emit a `build_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_due` is the public
  hook (returns `Some(seq)` when an Acknowledgement is owed, advancing
  the internal mark), with `ChunkReader::received_bytes` /
  `window_ack_size` accessors; 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::MAX` strict-array counts). Every call returns `Result`,
  never panics, never spins, and never over-allocates. Stack-overflow
  protection: `amf::MAX_DECODE_DEPTH` and `amf3::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 `connect` capability
  negotiation** (Veovera 2023+2026). `RtmpClient::connect_with_capabilities`
  advertises a [`ConnectCapabilities`] block in the publisher's `connect`
  Command Object per `enhanced-rtmp-v2.pdf` §"Enhancing NetConnection
  connect Command": the v1 `fourCcList` strict-array, the v2 object
  maps `videoFourCcInfoMap` / `audioFourCcInfoMap` with per-codec
  bitmask values (`FourCcInfoMask.CanDecode = 0x01` /
  `CanEncode = 0x02` / `CanForward = 0x04`, with the `"*"` wildcard
  honoured), and the v2 `capsEx` u32 bitfield (`Reconnect = 0x01` /
  `Multitrack = 0x02` / `ModEx = 0x04` / `TimestampNanoOffset = 0x08`).
  The block is appended *after* the historical `videoFunction` field so
  legacy peers keep parsing the message correctly. `RtmpServer::set_capabilities`
  symmetrically lets a server stamp its OWN capability block into the
  `_result(connect)` info object alongside the
  `NetConnection.Connect.Success` status; the publisher's advertised
  block surfaces on `PublishRequest::capabilities` and the server's
  advertised block surfaces on `RtmpClient::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.ReconnectRequest` status 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`/`level` mandatory,
  `tcUrl`/`description` omitted from the wire when `None`) so an
  ingest can ask its publisher to move ahead of a server update or a
  load-balancing remap, and `RtmpClient::poll_event` surfaces it as
  the typed `ClientEvent::ReconnectRequest { tc_url, description }`
  (the spec's level-MUST-be-`status` rule is enforced — a mismatched
  level stays a plain `OnStatus`). `RtmpClient::resolve_reconnect_url`
  / the free `resolve_tc_url(base, reference)` apply the spec's
  resolution rule for all four documented `tcUrl` shapes (absolute,
  `//host/app`, `/app`, `app`; `None` falls back to the current
  connection's tcUrl, exposed via `RtmpClient::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.rs` proves both
  directions over loopback).
- **Enhanced RTMP v2 `ModEx` prelude** (Veovera 2026). The `ModEx`
  packet-type signal (`enhanced-rtmp-v2.pdf` §"ExVideoTagHeader" /
  §"ExAudioTagHeader") is decoded for both audio and video: a chain
  of size-prefixed `modExData` entries (`UI8 + 1` length, escaping to
  `0xFF` + `UI16 + 1` for 256..=65536-byte payloads) preceding the
  FourCC, each terminated by a `modExType | next-PacketType` nibble
  byte. The chain round-trips through `flv::parse_*` / `flv::build_*`
  via the new `VideoTag::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` (a `bytesToUI24`
  sub-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 the `Packet` timeline** at
  source: the timeline switched to `RTMP_TIME_BASE = 1/1_000_000_000`
  (nanoseconds), so `pts` and `dts` are emitted as
  `ms * RTMP_MS_TO_NS` and the per-message `TimestampOffsetNano` sum
  is added to the *presentation* time per spec — for audio both
  `pts == dts` receive the offset; for video only `pts` (decode
  timestamp stays unmodified, matching the §"ExVideoTagHeader"
  contract "without altering the core RTMP timestamp"). The exposed
  `RTMP_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)://`:

```rust
use oxideav_core::SourceRegistry;
let mut reg = SourceRegistry::new();
oxideav_rtmp::register(&mut reg);
// `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("rtmp://0.0.0.0:1935/live/secret-key")?;
```

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}`
  plus `Amf3Value::to_amf0()` and `amf3::AVMPLUS_OBJECT_MARKER`
- `caps::{ConnectCapabilities, FourCcInfoMap}` plus the spec-mirroring
  `FOURCC_INFO_CAN_DECODE` / `CAPS_EX_*` / `OBJECT_ENCODING_*` /
  `FOURCC_WILDCARD` constants — Enhanced RTMP v1+v2 NetConnection
  `connect` capability negotiation per `enhanced-rtmp-v2.pdf`
  §"Enhancing NetConnection connect Command". Compose with
  `RtmpClient::connect_with_capabilities` (publisher) and
  `RtmpServer::set_capabilities` (ingest) for high-level use, or call
  `ConnectCapabilities::encode_into` / `from_amf0` directly to bridge
  to a custom command pipeline.
- `chunk::{ChunkReader, ChunkWriter, Message, MessageStreamKind}`  `Message::stream_kind()` lifts the raw `msg_stream_id: u32` field
  into a typed `Control` / `NetStream(id)` / `Reserved(raw)` view per
  RTMP Message Formats spec §4.1 + §5, and
  `Message::validate_protocol_control_invariants()` returns
  `Err(ProtocolViolation)` whenever a protocol-control message
  (`msg_type_id` 1..=6) carries a non-zero `msg_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_abort`
  is 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 single
  `Message` of 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.3
  `PreviousTagSize` back-pointers — useful when a publisher wants to
  cut chunk-header overhead by bundling several frames into one
  message before they hit `ChunkWriter::write_message`. **Routed
  end-to-end:** `RtmpSession::next_packet` decomposes incoming
  aggregates into a per-session queue and surfaces each sub as the
  same `StreamPacket::{Audio,Video,Metadata}` the publisher would
  have produced individually, including the spec's teardown-command
  detection on aggregated `closeStream` / `deleteStream` /
  `FCUnpublish` subs. `RtmpClient::poll_event` does the symmetric
  decomposition for server-pushed aggregates, and
  `RtmpClient::send_aggregate(&[Message])` is the outbound helper —
  every sub's `msg_stream_id` is overridden to the active publish
  stream id per §7.1.6, the aggregate is framed on `CSID_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 of `flv_v10_1.pdf`).
  `FlvWriter<W>` frames `VideoTag` / `AudioTag` plus AMF0 script-data
  tags into the on-disk `.flv` layout (9-byte file header +
  alternating `PreviousTagSize` / `FLVTAG` body). `FlvReader<R>` is
  the inverse: walks the §E.2 header, the §E.3 alternating
  back-pointers, and each §E.4.1 `FLVTAG`, surfacing each tag as a
  strongly-typed [`FlvTag`] `Audio` / `Video` / `Script` / opaque
  `Unknown` for reserved `TagType` values. Verifies the §E.3
  `PreviousTagSize == 11 + DataSize` invariant on every tag and
  refuses to advance past a mismatch; bounds the per-tag `DataSize`
  by [`DEFAULT_MAX_TAG_SIZE`] UI24 ceiling = 16 MiB or a
  caller-supplied cap via [`FlvReader::with_max_tag_size`]; surfaces
  a clean `Error::UnexpectedEof` on a truncated header / payload and
  `Error::Other` on a forward-incompatible `Filter = 1` (Annex F
  encrypted body). Useful as a recorder for an `RtmpSession` (write
  each `StreamPacket` to an `io::Write` sink) and as the foundation
  for an HTTP-FLV bridge — the body of an HTTP-FLV response is
  exactly this byte stream, served with `Content-Type: video/x-flv`,
  and a downstream consumer can re-decode it with `FlvReader`
  without re-implementing the §E.3 walk.
- `message::build_*` — builders for every protocol-control /
  command message we emit
- `message::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
  matching `build_user_control_*` builder emits, and the `Unknown`
  variant concatenates `event_type:U16BE | data` verbatim 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 for `SetBufferLength`) on parse and surface
  `Error::ProtocolViolation` on truncation.