Other
- strongly-typed colorInfo HDR metadata for VideoPacketType.Metadata
- decode AMF0 object references (marker 0x07) per FLV §E.4.4.2
- decode externalizable objects via registered per-class handlers (§3.12 U29O-traits-ext)
- RTMP §5.3 Acknowledgement honoured on received-byte window
- Enhanced RTMP v2 NetConnection.Connect.ReconnectRequest end-to-end
- RTMP §5.2 Abort Message builder + reader partial-discard
- typed UserControlEvent enum + round-trip parser
- drop release-plz.toml — use release-plz defaults across the workspace
- bump publisher-close drain windows for Ubuntu CI scheduling
- RTMP §3.7 StreamDry / StreamIsRecorded / PingResponse + builders
- typed MessageStreamKind accessor + spec-§5 protocol-control invariant validator
- bind set_read_timeout to the reader's actual socket clone
- replace drop(client) with close() so Windows CI doesn't race the flush
- route Aggregate Messages (type 22) through next_packet + poll_event
- fold ModEx TimestampOffsetNano onto the Packet timeline
- Aggregate Message (type 22) parser + builder
- Enhanced RTMP v1+v2 NetConnection connect capability negotiation
- rephrase FlvReader::with_max_tag_size docs
- FLV file / byte-stream reader (Annex E)
- FLV file / byte-stream writer (Annex E)
Added
-
Strongly-typed
colorInfoHDR metadata forVideoPacketType.Metadata
(src/flv.rs). Enhanced RTMP §"Metadata Frame" defines the
VideoPacketType.Metadata(= 4) video message as an AMF-encoded
sequence of[name, value]pairs, the only defined name being
"colorInfo"— an HDR metadata object carryingcolorConfig
(bitDepth + the ITU-T H.273 colourPrimaries / transferCharacteristics /
matrixCoefficients enumeration indices),hdrCll(maxFall / maxCLL
content light level in cd/m2) andhdrMdcv(SMPTE ST 2086:2018
mastering-display chromaticity coordinates + min/max luminance). The
body previously passed through as opaque AMF bytes; it now lifts into
the typed [ColorInfo] / [ColorConfig] / [HdrCll] / [HdrMdcv]
views viaVideoTag::color_info(), andVideoTag::color_info_tag(fourcc, &ColorInfo)rebuilds the matching outbound metadata tag. Every property
isOption<f64>(AMF's native double, kept byte-exact rather than
coerced to an integer that would lose a fractional luminance value), and
each sub-object isOptionso a partialcolorInfo— e.g. only
colorConfig— round-trips. The spec's "reset to original color state"
signal is preserved: anUndefinedvalue (the RECOMMENDED form) and an
empty{}object both decode to [ColorInfo::is_reset], and a reset
ColorInfore-encodes asUndefined. AcolorInfovalue of the wrong
AMF type is a cleanError::Other; a metadata tag with a different
(future) pair name yieldsOk(None)rather than an error. Seven tests
cover full-HDR10 round-trip, colorConfig-only partial, the two reset
forms, the non-metadata-tag / non-colorInfo-nameNonecases, and the
wrong-type rejection. -
AMF0 object references (marker 0x07) — decoded and dereferenced
transparently (src/amf.rs). The FLV v10.1 spec §E.4.4.2
SCRIPTDATAVALUEtable definesType == 7with the wire shape
IF Type == 7 { UI16 }— a 16-bit big-endian index into the table of
complex objects (Object, ECMA array, strict array) serialized so far
in the same context. The decoder previously rejected marker 0x07
outright, so anyonMetaData/command payload that used a reference to
deduplicate a repeated complex value failed to decode entirely. The
decoder now maintains a per-context reference table (scoped to one
decode_allpacket, or one top-level value fordecode), appends
each complex value as it is decoded — reserving the slot before the
body so a reference appearing inside that body still resolves — and
resolves a reference to a clone of the indexed value. Callers never
see aReferencevariant in the value graph; encoding always emits
the expanded (inline) form, which is byte-valid AMF0. An
out-of-range or truncated reference index surfaces a clean
InvalidAmf0error rather than a panic. Six tests cover prior-object
resolution, second-object indexing, in-body references, out-of-range
and truncated indices, and the per-value scope ofdecode. -
AMF3 §3.12
U29O-traits-extexternalizable objects — decodable via
registered per-class handlers (src/amf3.rs). The spec encodes an
externalizable object's body as "an indeterminable number of bytes as
*(U8)" whose framing is a private agreement between the sending and
receiving classes; the generic decoder cannot know where the body
ends and so previously refused every externalizable object outright,
even though the encoder already re-emitted theexternalizable_body
field verbatim.Decoder::register_externalizable(class_name, reader)
closes that asymmetry: a caller that knows a specific class's
IExternalizable.writeExternalframing registers a body-length
resolver (ExternalizableReader = Box<dyn Fn(&[u8], usize) -> Result<usize>>), anddecodethen captures exactly that many body
bytes intoAmf3Value::Object::externalizable_body, advancespos
past them, and inserts the object into the object reference table like
any other complex value. An over-long body length is rejected before
any out-of-bounds read; an unregistered externalizable class is still
refused loudly (the decoder never guesses). Handlers are decoder
configuration and survivereset_tables. Six tests: fixed-length and
length-prefixed handlers, decode→encode wire round-trip, overrun
rejection, handler persistence across reset, and object-reference
participation. -
RTMP 1.0 §5.3 Acknowledgement / §5.5 Window Acknowledgement Size —
honoured end-to-end (src/chunk.rs,src/server.rs,
src/client.rs). Until now both peers advertised a Window
Acknowledgement Size but neither side ever sent the §5.3
Acknowledgement the spec mandates ("the client or the server sends
the acknowledgment to the peer after receiving bytes equal to the
window size") — the client code even carried a// future refinementcomment in its control-message branch.ChunkReadernow
counts every byte it consumes off the wire (basic header, message
header, extended timestamp, payload) as the §5.3 sequence number via
a newread_exact_countedfunnel, stores the peer-negotiated window
(set_window_ack_size, fed from inbound §5.5 Window Ack Size and the
§5.6 Set Peer Bandwidth output-bandwidth value, which the spec
defines as equal to the window size), and exposesack_due()—
returnsSome(seq)the first time the received-byte count crosses
the window, re-arming only after another full window so a steady
stream never spams acks.RtmpSession::next_packet(server) and
RtmpClient::poll_event(client) call it after each wire read and
emitbuild_ack(seq)when one is owed; both setup paths
(drive_until_publish/wait_for_result) seed the window so the
obligation is live before the first media frame.received_bytes()
/window_ack_size()accessors round out the public surface.
Resetting the window re-bases the byte accounting so an
already-counted byte never instantly owes an ack, and with no window
negotiated the obligation stays dormant (byte-identical to the
pre-§5.3 behaviour). Newtests/acknowledgement_window.rsdrives a
raw publisher (built from the publicchunk/message/
handshakemodules) that advertises a 256-byte window and confirms
the realRtmpServeracks back with a plausible sequence number;
fourchunk.rsunit tests cover the byte-count, window-crossing,
one-ack-per-window, and re-base behaviours. -
Enhanced RTMP v2 Reconnect Request — end-to-end
(src/message.rs,src/server.rs,src/client.rs). Round 277 wires
theNetConnection.Connect.ReconnectRequeststatus event from
enhanced-rtmp-v2.pdf§"Reconnect Request" — until now the crate
advertised the matchingcapsExReconnectbit (CAPS_EX_RECONNECT)
during connect-capability negotiation but had no way to send or
react to the event itself. New
message::build_reconnect_request(tc_url, description)emits the
spec's NetConnection-level onStatus shape —["onStatus", 0.0, null, info]on message stream 0, with the Info Object carrying the
mandatorycode = NetConnection.Connect.ReconnectRequest
(exported asRECONNECT_REQUEST_CODE) +level = "status"pairs
and the optionaltcUrl/descriptionproperties (omitted from
the wire whenNone, per their "optional" marking in the spec's
Info Object table).RtmpSession::send_reconnect_requestis the
ingest-side helper — used "prior to the shutdown of the live
streaming server or when the server intends to remap the client to
another server instance," after which the old server keeps
processing publisher messages per spec (sonext_packetpumping
continues unchanged). On the publisher side,
RtmpClient::poll_eventnow classifies the event into the new typed
ClientEvent::ReconnectRequest { tc_url, description }variant
(code match alone is not enough — the spec says level MUST be
status, so a mismatched level falls through as a plain
OnStatus). The newresolve_tc_url(base, reference)/
RtmpClient::resolve_reconnect_url(Option<&str>)helpers apply the
spec's target-resolution rule — "if not specified, use the tcUrl for
the current connection. A relative URI reference should be resolved
relative to the tcUrl for the current connection" — covering all
four reference shapes the Info Object table gives as examples
(absolutertmp://host:port/app, network-path//host/app,
absolute-path/app, and relative-pathapp);RtmpClient::tc_url()
exposes the stored base. Tests: wire-shape + optional-property
omission insrc/message.rs, classification + resolution tables in
src/client.rs, andtests/reconnect_request.rsdrives both
loopback flows end-to-end — including the spec's "old server SHOULD
continue processing messages from the client until the client
disconnects" behaviour, proven by publishing a post-request keyframe
that the requesting server still receives. -
Abort Message (protocol control type 2) builder + reader-side
partial-message discard (src/message.rs,src/chunk.rs). Round 271
closes the last protocol-control message that had a type-id constant
(MSG_ABORT = 2) but no builder and no consumer effect. New
message::build_abort(chunk_stream_id)emits the exact RTMP 1.0 §5.2
wire layout — a single 4-byte big-endian chunk stream ID (Figure 3) on
the control stream (msg_type_id = MSG_ABORT,msg_stream_id = 0,
timestamp = 0). NewChunkReader::abort_partial(chunk_stream_id) -> boolgives the message its spec-mandated receiver behaviour: per §5.2
the Abort Message tells a peer "waiting for chunks to complete a
message" to "discard the partially received message over a chunk
stream and abort processing of that message," soabort_partial
clears the half-filled reassembly buffer for the named csid and
returnstruewhen a non-empty partial was actually discarded.
Only the in-flight payload bytes are cleared; the csid's header state
(last timestamp / type / length / extended-timestamp latch) is left
intact because a subsequent fmt-1/2/3 chunk still relies on it per
§5.3.2. An Abort for a csid with no in-flight message — or one the
reader has never seen — is a no-op, matching the spec's "if it is
waiting for chunks" precondition. LikeChunkReader::set_chunk_size,
the reader does not auto-apply an inbound Abort; the message-layer
caller dispatches the decoded 4-byte csid here. Total lib tests:
226 → 228 (+2 —message::tests::abort_wire_bytesasserting the
byte-exact §5.2 payload,chunk::tests::abort_partial_discards_in_ flight_messagedriving a two-chunk message truncated after its first
chunk so the reader holds a partial buffer, then asserting
abort_partialdiscards it, is idempotent on the now-empty csid, and
is a no-op for an unseen csid). Sourced entirely from RTMP 1.0 §5.2
(staged atdocs/streaming/rtmp/rtmp_specification_1.0.pdf/
rtmp.part2.Message-Formats.pdf). -
message::UserControlEvent— public typed view of a User Control
Message body per RTMP 1.0 §3.7 / §7.1.7 (src/message.rs,
src/lib.rs). Round 264 promotes the previously-private
classify-and-extract logic inclient.rsinto a reusable building
block on themessagemodule.UserControlEvent::parse(payload)
classifies the 2-byte BE event type + variable event data into one
of the seven spec-defined variants —
[UserControlEvent::StreamBegin { stream_id }] (UCM 0),
[UserControlEvent::StreamEof { stream_id }] (UCM 1),
[UserControlEvent::StreamDry { stream_id }] (UCM 2),
[UserControlEvent::SetBufferLength { stream_id, buffer_ms }]
(UCM 3, the only 8-byte event-data variant),
[UserControlEvent::StreamIsRecorded { stream_id }] (UCM 4),
[UserControlEvent::PingRequest { timestamp_ms }] (UCM 6),
[UserControlEvent::PingResponse { timestamp_ms }] (UCM 7) — or
the catch-all [UserControlEvent::Unknown { event_type, data }]
for the spec-reserved type 5 and any future event type ≥ 8. The
reverse direction is [UserControlEvent::to_message]: each
spec-defined variant rebuilds byte-for-byte the same protocol
control [Message] (msg_type_id = MSG_USER_CONTROL,
msg_stream_id = 0,timestamp = 0) the matching
build_user_control_*builder emits, andUnknownconcatenates
event_type:U16BE | dataverbatim so a forwarding ingest preserves
forward-compatible UCMs without re-encoding. Spec-defined variants
validate their fixed event-data size on parse (4 bytes for the
stream-id-carrying variants and ping, 8 bytes for
SetBufferLength) and surface
[Error::ProtocolViolation] on truncation;
[UserControlEvent::Unknown] accepts any tail length (including
zero) so a forwarding ingest never rejects forward-compatible
messages. [UserControlEvent::event_type] /
[UserControlEvent::is_spec_defined] accessors round out the
surface, andUserControlEventis re-exported at the crate root.
Total lib tests: 222 → 226 (+4 —
user_control_event_parse_recognises_spec_types,
user_control_event_round_trip_matches_builder_bytes,
user_control_event_unknown_preserves_event_type_and_tail,
user_control_event_parse_rejects_truncated_payload). Sourced
entirely from RTMP 1.0 spec §3.7 / §7.1.7 (staged at
docs/streaming/rtmp/rtmp-v1-0-spec-veovera.pdf/
rtmp.part3.Commands-Messages.pdf). -
RTMP 1.0 §3.7 User Control Message events surfaced through
ClientEvent+ matchingRtmpSessionserver-side helpers
(src/message.rs,src/client.rs,src/server.rs,
tests/user_control_events.rs). Round 248 closes the
publish-direction User Control Message coverage gap: prior to this
commit onlyStreamBegin(UCM 0) andStreamEOF(UCM 1) reached
the publisher as typed [ClientEvent] variants, and the remaining
spec-defined events were either swallowed silently into
[ClientEvent::Other] (StreamDryUCM 2,SetBufferLengthUCM 3,
StreamIsRecordedUCM 4,PingResponseUCM 7) or had no builder
available at all (StreamDry,SetBufferLength,StreamIsRecorded,
PingRequestfrom the server side,PingRequest/
PingResponsefrom the client side). Closed end-to-end: new
message::build_user_control_stream_dry/
_set_buffer_length(stream_id, buffer_ms)/
_stream_is_recorded/_ping_request(timestamp_ms)/
_ping_response(timestamp_ms)builders emit the exact §3.7 wire
layouts (2-byte BE event type + 4-byte BE stream id for the
stream-id-carrying variants; 4-byte BE timestamp for ping; 4-byte
stream id + 4-byte buffer length for the only 8-byte UCM event,
SetBufferLength).RtmpClient::poll_eventdecodes these into
three new typed [ClientEvent] variants:
[ClientEvent::StreamDry] (carriesstream_id, distinct from
StreamEof: per spec §3.7StreamDryis a transient
"no-data-right-now" signal whereasStreamEofis "playback
finished"), [ClientEvent::StreamIsRecorded] (server announces
the stream is on-demand / archival), and
[ClientEvent::PingResponse] (carries the echoed 4-byte
timestamp_msfor RTT measurement).SetBufferLengthis
publisher-direction inbound; the classify path validates the
8-byte event-data length (returningError::ProtocolViolationon
truncation per the spec's fixed-size invariant) and otherwise
reports it as [ClientEvent::Other]. Server-originated
PingRequest(UCM 6) is auto-replied internally as before —
promoting it to aClientEventwould expose a protocol-level
liveness probe as an application event, which the spec assigns
to the client. NewRtmpClient::send_ping_request(timestamp_ms)
emits a UCM-6 from the publisher direction so an outer event
loop can measure round-trip time by stamping a monotonic clock
into the request and subtracting from the matching
[ClientEvent::PingResponse] echoed back; new
RtmpSession::send_stream_dry/send_stream_is_recorded/
send_ping_requestgive an ingest the symmetric server-side
emitters. TheMessageStreamKindclassifier + protocol-control
invariant validator from the round-247 commit keeps these on
msg_stream_id == 0per RTMP Message Formats §5 — the new
builders all stampmsg_stream_id = 0directly. Total lib
tests: 217 → 222 (+5 —user_control_stream_dry_wire_bytes,
user_control_set_buffer_length_wire_bytes,
user_control_stream_is_recorded_wire_bytes,
user_control_ping_request_wire_bytes,
user_control_ping_response_wire_bytes). Total integration
tests: 77 → 82 (+5 —client_observes_stream_dry_from_server,
client_observes_stream_is_recorded_from_server,
client_auto_replies_to_server_ping_request_without_surfacing,
client_surfaces_server_ping_response_as_typed_eventdriving a
hand-rolled chunk-stream PingResponse injection,
client_rejects_truncated_set_buffer_lengthconfirming the
8-byte validation refuses a 6-byte payload with a
ProtocolViolation). Resolves the remainingOther-swallow gap
identified by reading RTMP Commands Messages spec §3.7 against
the round-247 classify-path coverage. -
Typed
MessageStreamKindaccessor + spec-§5 protocol-control
invariant validator onchunk::Message(src/chunk.rs,
src/lib.rs,tests/message_stream_kind.rs). The chunk-reassembled
Message's rawmsg_stream_id: u32now lifts into a typed
three-way classification —Control(msg_stream_id == 0, the
"control stream" defined in RTMP Message Formats spec §5 carrying
every NetConnection command + every protocol-control / user-control
message),NetStream(id)(the canonical 1..=0x00FF_FFFFhandle a
server returns from_result(createStream)per the RTMP Commands
Messages spec §4.1.3 and that publishers stamp into every A/V /
metadata / aggregate message from then on), andReserved(raw)(any
value with bits set in the top byte — the Message Formats spec §4.1
message header carries the stream ID as a 3-byte field, so anything
above0x00FF_FFFFis reserved).Message::stream_kind()returns
the typed view;Message::is_control_stream()is the convenience
shorthand;Message::validate_protocol_control_invariants()returns
Err(ProtocolViolation)whenever a protocol-control message
(msg_type_id1..=6) carries a non-zeromsg_stream_idper the
Message Formats spec §5 mandate "Protocol control messages MUST
have message stream ID 0 (called as control stream)" and whenever
the §4.1 reserved high-byte rule is violated. The new types
re-export through the crate root (oxideav_rtmp::Message/
oxideav_rtmp::MessageStreamKind). Four-test integration suite in
tests/message_stream_kind.rscovers the round-trip case (build a
SetChunkSize viabuild_set_chunk_size, render through
ChunkWriter, reassemble throughChunkReader, confirm the typed
view + validator both pass), the NetStream classification case
(msg_stream_id == 1→NetStream(1)), the forged-msid rejection
case (SetChunkSize stamped withmsg_stream_id = 1refused with a
diagnostic naming "protocol-control" + "msg_stream_id"), and the
reserved-high-byte rejection case (msg_stream_id = 0x0100_0001). -
Aggregate Message (type 22) dispatch through
RtmpSession::next_packet
andRtmpClient::poll_event, plusRtmpClient::send_aggregate
outbound (src/server.rs,src/client.rs,
tests/aggregate_routing.rs). Round 230 closes the matching consumer
side of the round-229aggregateparser + builder. Previously a
publisher that bundled several frames into one Aggregate Message
(RTMP 1.0 §7.1.6, message type id22— fewer chunk headers per A/V
burst) had its body silently swallowed by the dispatch loop's_ => swallowfallback arm: theparse_aggregateentry point was reachable
only via a hand-written caller pulling rawMessagevalues out of
[chunk::ChunkReader]. Server-side:next_packetnow drains a new
per-sessionpending_subs: VecDeque<Message>queue ahead of every
wire read, and a freshMSG_AGGREGATEarm decomposes incoming
aggregates into that queue using the sameaggregate::parse_aggregate
the round-229 commit ships — the §7.1.6 timestamp re-normalisation
(t_i + (aggregate.timestamp - t_0)) and the spec's
"aggregate.msg_stream_id overrides sub.msg_stream_id" rule are both
applied transparently. Per-message dispatch logic factored out of the
giantnext_packetbody into ahandle_message(&mut self, Message) -> Result<Option<StreamPacket>>helper so wire-read subs and queued
subs share one code path. The five real-world sub-message types
(audio / video / data AMF0 / data AMF3 / command AMF0 / command
AMF3 — including the existingcloseStream/deleteStream/
FCUnpublishteardown detection) all flow through the same arms as
individually-sent messages. A sub whosemsg_type_idis itself22
(a forged or speculative nested aggregate; the spec doesn't model
this but a defensive parser must survive it) is forwarded back to
the queue and decomposed on the next dispatch tick, so a bounded
nesting depth resolves to bounded parser work rather than stack
growth. Client-side:RtmpClientcarries the same
pending_subsqueue and a refactoredpoll_eventloops over both
the queue and the wire read so a server that ever bundled its
onStatus/_result/UserControlreplies into an aggregate
surfaces the per-subClientEvents in publish order. Queued subs
classified asClientEvent::Otherare dropped from the surface so a
caller pumpingpoll_eventdoesn't observe N back-to-backOthers.
New outbound helperRtmpClient::send_aggregate(&[Message]) -> Result<()>is the symmetric publisher API on top of
aggregate::build_aggregate: every sub'smsg_stream_idis
overridden to the active publish stream id per §7.1.6, the
aggregate is framed onCSID_DATA(6), and a zero-length slice is
a no-op. 4 new integration tests intests/aggregate_routing.rs:
(1) a video + audio + onMetaData aggregate round-trips through real
loopbackRtmpClient::send_aggregate→
ChunkReader::read_message→parse_aggregate→
RtmpSession::next_packetand surfaces three discrete
StreamPackets in publish order; (2) a two-sub aggregate with a
23-ms gap confirms the §7.1.6 offset reaches the per-sub
StreamPacket.timestampexactly (1000 → 1000, 1023 → 1023, no
drift); (3) an aggregate carrying acloseStreamAMF0 command sub
drives the same teardown path the standalone command takes — the
server reportsOk(None)after the prior media sub instead of
spinning on the post-FIN socket; (4) the client-sidepoll_event
contract is exercised via a smoke check holding the dispatch
contract live as a tested public-API surface (the full server →
client aggregate flow is covered byclient_stream_eof.rs
unchanged). Total lib tests: 217 (unchanged — the new work is
end-to-end via the integration harness, where queue draining
through realTcpStreamstate is the load-bearing surface).
Total integration tests: 73 → 77 (+4). Resolves the
next_packet/poll_eventhalf of the RTMP 1.0 §7.1.6
"Aggregate Message body is not yet decomposed" gap (round 229 closed
the parser / builder half).
Changed
RTMP_TIME_BASEswitched from 1/1000 (ms) to 1/1_000_000_000 (ns)
to foldTimestampOffsetNanoonto thePackettimeline
(src/adapter.rs,tests/packet_source.rs,
tests/enhanced_rtmp_audio.rs,tests/enhanced_rtmp_video.rs,
README,src/lib.rs). Resolves the r0.0.5 README follow-up
"folding that nanosecond offset into the millisecondPacket
timeline is a follow-up."enhanced-rtmp-v2.pdf§"ExVideoTagHeader"
/ §"ExAudioTagHeader" assigns theTimestampOffsetNanoModEx
subtype (the onlyModExTypedefined today) the duty of adjusting
the presentation time of the current media message without
altering the core RTMP timestamp; the spec explicitly carries a
TODO: Integrate this nanosecond offset into timestamp management
marker, which is what this commit closes on the consumer side.
audio_to_packet(timestamp_ms, &AudioTag)now emits
pts == dts == timestamp_ms * RTMP_MS_TO_NS + AudioTag::timestamp_offset_nano()(audio has no separate decode
time, so both PTS and DTS receive the offset);
video_to_packet(timestamp_ms, &VideoTag)emits
dts = timestamp_ms * RTMP_MS_TO_NS(decode timestamp,
unmodified per spec) and
pts = (timestamp_ms + composition_time) * RTMP_MS_TO_NS + VideoTag::timestamp_offset_nano()so legacy AVC composition-time
offsets in milliseconds and Enhanced-RTMP nanosecond presentation
offsets compose without precision loss. New public
RTMP_MS_TO_NS = 1_000_000constant exported alongside
RTMP_TIME_BASEso a consumer can recover the wire ms value
(pts / RTMP_MS_TO_NS) when it needs the legacy unit.
MultipleTimestampOffsetNanoModEx entries are summed via the
existingVideoTag::timestamp_offset_nano/
AudioTag::timestamp_offset_nanoaccessors (one
bytesToUI24per entry); ModEx entries of other subtypes do not
feed the sum, matching the typed accessor contract. The
StreamInfo::time_baseexposed byRtmpPacketSourcefollows
RTMP_TIME_BASEso a downstreamPacketSourceconsumer (e.g.
oxideav-cli's pipeline executor) reads a single uniform
nanosecond clock from the registry. 5 new lib unit tests in
src/adapter.rs::tests(audio_timestamp_offset_nano_folds_into_ presentation_time,video_timestamp_offset_nano_folds_into_ pts_only,video_timestamp_offset_nano_stacks_on_cts_and_ dts_unchanged,video_timestamp_offset_nano_sums_multiple_ modex_entries,time_base_is_nanoseconds) cover: a single
750_000-ns audio offset stacking on both PTS and DTS; a
123_456-ns video offset reaching PTS only; a HEVC × CodedFrames
CTS (17 ms) + 500_000-ns offset composing on PTS without
perturbing DTS; a multi-entry ModEx chain with an interleaved
unknown ModExType correctly summing only the
TimestampOffsetNanocontributions (200_000 + 300_000); and the
RTMP_TIME_BASE == 1/1_000_000_000+RTMP_MS_TO_NS == 1_000_000
invariants. Existing tests that previously asserted ms-valued
PTS/DTS were rewritten to multiply byRTMP_MS_TO_NS; the ModEx
integration test intests/enhanced_rtmp_video.rsnow asserts the
nano fold reaches PTS only on the recovered HEVC CodedFrames.
Total lib tests: 212 → 217 (+5).
Added
-
Aggregate Message (type 22) parser + builder (
src/aggregate.rs,
tests/aggregate_chunk_round_trip.rs,tests/injection_robustness.rs).
RTMP 1.0 §7.1.6 defines the Aggregate Message as a single
Messageof type id 22 whose payload carries a sequence of
FLV-shaped sub-messages so several audio / video / data frames can
travel through the chunk stream as one message. Newaggregate
module exposesparse_aggregate(&Message) -> Result<Vec<Message>>
andbuild_aggregate(stream_id, &[Message]) -> Result<Message>,
both re-exported at the crate root. The sub-header layout mirrors
§6.1.1 (1 + 3 + 4 + 3 = 11 bytes) which the spec explicitly says
"matches the format of FLV file" — the FLV §E.4.1 split-timestamp
(UI24 ts_low | UI8 ts_high) and the §E.3PreviousTagSize == 11 + DataSizeback-pointer invariant are both honoured. The
§7.1.6 timestamp re-normalisation rule ("the difference between
the timestamps of the aggregate message and the first sub-message
is the offset used to renormalize the timestamps of the
sub-messages") is applied transparently by the parser, lifting
each sub's wire timestampt_ionto the stream clock as
t_i + (aggregate.timestamp - t_0); the builder sets
aggregate.timestamp == subs[0].timestampso the SHOULD-be-zero
offset holds. SubStream IDfields are written as 0 on the wire
(per §7.1.6 / §E.4.1) and the parser overrides every decoded sub's
msg_stream_idwith the aggregate's, matching the spec ("the
message stream ID of the aggregate message overrides the message
stream IDs of the sub-messages"). Adversarial inputs all surface
as typedResult::Err: truncated headers / payloads / back
pointers →UnexpectedEof; mismatched back pointer or
non-type-22 outer message →InvalidChunk; UI24-cap overflow on
the build side →InvalidChunk. 14 new lib unit tests in
src/aggregate.rscover: three-sub round-trip with zero offset;
the §7.1.6 offset shift applied to two subs with a deliberately
non-zero outer timestamp; empty aggregate symmetry; wrong outer
type rejection; truncated sub-header / sub-payload / back-pointer
fail-fast; mismatched back pointer; sub-headerStreamID = 0
invariant on build; outertimestamp = subs[0].timestamp
invariant on build; 100-sub round-trip (proves bookkeeping
scales); UI24-cap rejection on a 16-MiB+1-byte sub payload; a
forged UI24-max DataSize → cleanUnexpectedEof; and a
1024-iteration deterministic-xorshift fuzz pass guaranteeing
parse_aggregateis panic-free on arbitrary bytes. 2 new
integration tests intests/aggregate_chunk_round_trip.rsdrive
build_aggregate → ChunkWriter::write_message → ChunkReader::read_message → parse_aggregateon a realistic
video+audio+script bundle and assert the byte-exact §6.1.1
sub-header layout (offsets [1..4] DataSize UI24 BE, [4..7] +
[7] FLV split-timestamp, [8..11] StreamID UI24 = 0, trailing UI32
back-pointer =11 + DataSize). 2 new entries in
tests/injection_robustness.rsextend the property-test sweep
with 1024 random-byte aggregate payloads (no panics) plus an
oversize-DataSize fail-fast assertion. Total lib tests: 198 → 212
(+14). Total integration tests: 69 → 73 (+4). Resolves the RTMP
1.0 §7.1.6 portion of the README's "Aggregate Message body is
not yet decomposed" gap. -
Enhanced RTMP v1+v2 NetConnection
connectcapability negotiation
(src/caps.rs,src/message.rs,src/client.rs,src/server.rs,
tests/connect_capabilities.rs). ThefourCcList/
videoFourCcInfoMap/audioFourCcInfoMap/capsExproperties
defined inenhanced-rtmp-v2.pdf§"Enhancing NetConnection connect
Command" are now exchanged end-to-end between
RtmpClient::connect_with_capabilitiesandRtmpServer::set_capabilities.
NewConnectCapabilitiesstruct exposes all four entries plus the
legacyobjectEncodingbyte; newFourCcInfoMapkeeps the per-codec
(FourCC, mask)entries in insertion order and implements the spec's
wildcard-OR rule viaeffective_mask. New constants mirror the spec
enums verbatim:FourCcInfoMask(FOURCC_INFO_CAN_DECODE = 0x01/
_CAN_ENCODE = 0x02/_CAN_FORWARD = 0x04) andCapsExMask
(CAPS_EX_RECONNECT = 0x01/_MULTITRACK = 0x02/_MOD_EX = 0x04
/_TIMESTAMP_NANO_OFFSET = 0x08); the"*"catch-all key is the new
FOURCC_WILDCARDconstant. The Command Object properties are appended
to the historicalapp/tcUrl/flashVer/fpad/
capabilities/audioCodecs/videoCodecs/videoFunction
block in the documented spec order (objectEncoding→fourCcList→
videoFourCcInfoMap→audioFourCcInfoMap→capsEx); empty /
default fields are skipped so an empty capability block produces
byte-identical output to the pre-2023 [build_connect]. The server's
_result(connect)info object echoes its own capabilities through the
matchingbuild_connect_result_with_capsbuilder. Surfaced to callers
asPublishRequest::capabilities(client-advertised) and
RtmpClient::server_capabilities()(server-advertised); the
ConnectCapabilities::from_amf0parser silently drops malformed
values (non-numeric mask bytes, non-finite numbers, negative masks,
StringforcapsEx, etc.) and saturates out-of-u32 numbers to
u32::MAX, matching the spec's "fail gracefully" rule. Resolves the
r0.0.4 README note "Theconnectcommand'sfourCcList
advertisement (Enhanced RTMP v1 Table 5) is not populated by the
client yet" and the symmetric r0.0.4 audio-/v2-video notes about
audioFourCcInfoMap/videoFourCcInfoMap/capsExnot being
populated byRtmpClient::connect. 18 new unit tests in
src/caps.rscover:FourCcInfoMask/CapsExMaskconstants match
the spec table; FourCC wildcard is the single-byte"*";
FourCcInfoMap::insertpreserves insertion order across duplicate
keys;effective_maskORs in the wildcard entry; AMF0 round-trip;
malformed mask entries dropped; oversize mask saturates; default
capabilities emit nothing; documented v1+v2 order; full round-trip
through encode→AMF0 wire→decode for a fully-populated block;
has_fourccwildcard + explicit;supports_caps_exbit-test;
malformedcapsExfalls back to default; non-object inputs return
empty;objectEncodinground-trips 0 / 3; ECMA-array parses the same
as Object. Plus 4 unit tests insrc/message.rs:
build_connect_with_capswith empty caps matches legacy bytes
exactly; non-empty caps append the properties in documented order
aftervideoFunction;build_connect_result_with_capsechoes the
block inside the info object alongside
NetConnection.Connect.Success; empty caps match legacy
build_connect_resultbyte-for-byte. Plus 4 integration tests in
tests/connect_capabilities.rs: full loopback round-trips both
directions through a real TCP socket; legacy client against a v2
server still receives the server's advertisement; v2 client against a
legacy server observes empty server caps;capsExbit-test surfaces
Reconnect / Multitrack / ModEx / TimestampNanoOffset after a real
loopback. Total lib tests: 177 → 198 (+21 — 18 caps + 4 message tests
hosted in the existingmessage::testsmodule). Total integration
tests: 65 → 69 (+4). -
FLV file / byte-stream reader (
src/flv_file.rs,
tests/flv_file_record.rs). Inverse of the round-204FlvWriter:
newFlvReader<R: Read>wraps aReadsource and walks the §E.2
9-byte file header, the §E.3 alternatingPreviousTagSize/
FLVTAGbody, and each §E.4.1FLVTAGheader, surfacing every
tag as a strongly-typedFlvTagenum
(Audio { timestamp_ms, tag: AudioTag }/
Video { timestamp_ms, tag: VideoTag }/
Script { timestamp_ms, name, value: Amf0Value }/
Unknown { tag_type, timestamp_ms, body }). Audio + video bodies
decode through the existingflv::parse_audio/flv::parse_video
paths so every wire shape the writer emits — legacy AVC/AAC,
Enhanced-RTMP v1 FourCC (hvc1/av01/vp09), Enhanced-RTMP
v2 FourCC (vp08/avc1/vvc1/ Opus / FLAC / AC-3 / E-AC-3 /
MP3 / FourCC-AAC),Multitrack,MultichannelConfig, andModEx
preludes — round-trips byte-for-byte through reader → writer
without re-implementing the §E.3 walk. Script tags decode as an
AMF0Name + Valuepair per §E.4.4; a script body that fails AMF0
decode is preserved verbatim asFlvTag::Unknown(tag_type = 18) so a forwarding consumer never silently drops bytes.
FlvReader::newconsumes the §E.2 header (signatureFLV+
version +TypeFlagsAudio/TypeFlagsVideo+ UI32DataOffset)
and the mandatoryPreviousTagSize0 == 0back-pointer eagerly,
refusing wrong-signature / wrong-version / nonzero-PreviousTagSize0
inputs up front. A largerDataOffset(forward-compatible header
extension) is skipped transparently. Verifies the §E.3
PreviousTagSize == 11 + DataSizeinvariant on every tag and
refuses to advance past a mismatch (forged producer / transport
corruption). UI24DataSizeis bounded by a configurable
max_tag_size(default = UI24 ceiling,DEFAULT_MAX_TAG_SIZE)
via the newFlvReader::with_max_tag_sizeconstructor — HTTP-FLV
proxies generally want a tighter cap, trusted local files can
raise it back to the wire ceiling. The §E.4.1StreamID = 0
invariant is enforced, the §E.4.1Filter = 1(Annex F encrypted
body) surfaces as a cleanError::Otherrather than silently
passing through, and truncated header / payload / back-pointer
all surface asError::UnexpectedEof.FlvReader::read_tag
returnsOk(None)on a clean end-of-stream at a tag boundary and
latches so subsequent calls don't re-enter the reader on an
exhausted source.FlvReader::read_allconsumes the rest of the
stream into aVec<FlvTag>for one-shot use. 17 new tests (15
unit insrc/flv_file.rs, 1 integration in
tests/flv_file_record.rs, plus 1 new doc-test added via the
module-level rewording): writer-then-reader round-trips for empty
stream / AVC sequence header / AAC sequence header / interleaved
video+audio+video triple / Enhanced-RTMP v2 HEVCCodedFrames/
full 4-tag stream (script + video SH + audio SH + video inter)
byte-for-byte through reader → writer; AMF0onMetaData
name+value pair decoded into typedEcmaArray;TimestampExtended
high byte re-joined into a 32-bit value; bad signature / wrong
version / nonzeroPreviousTagSize0/DataOffset < 9rejected
cleanly; forward-compatible header padding (DataOffset = 11)
skipped transparently; corruptPreviousTagSize/ oversize
DataSize/ nonzeroStreamID/Filter = 1rejected with
matching error variants; truncated FLVTAG header and payload
surfaceError::UnexpectedEof; unknownTagTypevalue (5) lifted
asFlvTag::Unknownwith the verbatim body. End-to-end
integration test drives an RTMP loopback, records every received
StreamPacketto an in-memory FLV byte stream viaFlvWriter,
then walks the resulting buffer back throughFlvReaderand
asserts every tag's body matches the publisher's input. Total lib
tests: 162 → 177 (+15); total integration tests: 64 → 65 (+1). -
FLV file / byte-stream writer (
src/flv_file.rs,
tests/flv_file_record.rs). Newflv_filemodule exposing
FlvWriter<W: Write>,FlvHeaderFlags,build_flv_header, and
build_flv_tagperdocs/container/flv/flv_v10_1.pdfAnnex E.
The writer emits the §E.2 9-byte file header (signatureFL
V, version,TypeFlagsAudio/TypeFlagsVideo,DataOffset = 9) and the §E.3 alternatingPreviousTagSize/FLVTAGbody,
framing eachVideoTag/AudioTagvia the existing
flv::build_video/flv::build_audiopaths and tracking the
PreviousTagSizeback-pointer (11 + DataSize) automatically.
write_script_data(timestamp_ms, name, &Amf0Value)emits an
AMF0name + valuepair as a §E.4.4 script-data tag (type 18) —
the canonical use is anonMetaDatatag emitted right after the
header. The §E.4.1 24-bitTimestamp+ 8-bitTimestampExtended
splitting is handled transparently so callers pass a single
u32timestamp.write_raw_tagis the escape hatch for callers
who build their own payload (e.g. an Annex F encrypted body).
Composes withRtmpSessionso an RTMP ingest can be recorded to
an.flvfile or re-served over HTTP-FLV (an HTTP-FLV response
body is exactly this byte stream withContent-Type: video/x-flv) without re-parsing any payload. 19 new tests (16
unit insrc/flv_file.rs, 3 integration in
tests/flv_file_record.rs): header signature + flags + offset
bytes match §E.2 exactly; 9-byte UI24DataSize+ UI8
TimestampExtendedlayout matches §E.4.1 byte-for-byte (both
the timestamp-under-24-bit and timestamp-over-24-bit-needing-
TimestampExtended paths);PreviousTagSize0always 0 (§E.3);
multi-tag back-pointer tracking across an interleaved video +
audio + video sequence (20 / 16 / 23 byte tags); over-UI24
payload rejected asInvalidInput(16 MiB+ would forge the size
field otherwise); legacy AVC sequence-header round-trip through
parse_video; AAC sequence-header round-trip through
parse_audio; Enhanced-RTMP v2 HEVCCodedFramesExHeader
round-trip preserves FourCC + composition_time; AMF0 onMetaData
script-tag name-then-value layout round-trips;finish()
idempotency; post-finishwrites returnBrokenPipe; the
escape-hatchwrite_raw_taglets a caller pass an opaque
payload; empty FLV stream (header +PreviousTagSize0-only)
parses to zero tags; end-to-end RTMP loopback →FlvWriter→
byte-by-byte FLV walker →parse_video/parse_audioproves
every recorded payload re-parses unchanged. Total integration
tests: 61 → 64 (+3); total tests: 202 → 222 (+20 including the
new doc-test).