Skip to content

v0.0.6

Latest

Choose a tag to compare

@MagicalTux MagicalTux released this 15 Jun 05:09
· 6 commits to master since this release
119d2fb

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 colorInfo HDR metadata for VideoPacketType.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 carrying colorConfig
    (bitDepth + the ITU-T H.273 colourPrimaries / transferCharacteristics /
    matrixCoefficients enumeration indices), hdrCll (maxFall / maxCLL
    content light level in cd/m2) and hdrMdcv (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 via VideoTag::color_info(), and VideoTag::color_info_tag(fourcc, &ColorInfo) rebuilds the matching outbound metadata tag. Every property
    is Option<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 is Option so a partial colorInfo — e.g. only
    colorConfig — round-trips. The spec's "reset to original color state"
    signal is preserved: an Undefined value (the RECOMMENDED form) and an
    empty {} object both decode to [ColorInfo::is_reset], and a reset
    ColorInfo re-encodes as Undefined. A colorInfo value of the wrong
    AMF type is a clean Error::Other; a metadata tag with a different
    (future) pair name yields Ok(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-name None cases, 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
    SCRIPTDATAVALUE table defines Type == 7 with 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 any onMetaData/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_all packet, or one top-level value for decode), 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 a Reference variant 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
    InvalidAmf0 error 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 of decode.

  • AMF3 §3.12 U29O-traits-ext externalizable 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 the externalizable_body
    field verbatim. Decoder::register_externalizable(class_name, reader)
    closes that asymmetry: a caller that knows a specific class's
    IExternalizable.writeExternal framing registers a body-length
    resolver (ExternalizableReader = Box<dyn Fn(&[u8], usize) -> Result<usize>>), and decode then captures exactly that many body
    bytes into Amf3Value::Object::externalizable_body, advances pos
    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 survive reset_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 refinement comment in its control-message branch. ChunkReader now
    counts every byte it consumes off the wire (basic header, message
    header, extended timestamp, payload) as the §5.3 sequence number via
    a new read_exact_counted funnel, 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 exposes ack_due()
    returns Some(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
    emit build_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). New tests/acknowledgement_window.rs drives a
    raw publisher (built from the public chunk / message /
    handshake modules) that advertises a 256-byte window and confirms
    the real RtmpServer acks back with a plausible sequence number;
    four chunk.rs unit 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
    the NetConnection.Connect.ReconnectRequest status event from
    enhanced-rtmp-v2.pdf §"Reconnect Request" — until now the crate
    advertised the matching capsEx Reconnect bit (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
    mandatory code = NetConnection.Connect.ReconnectRequest
    (exported as RECONNECT_REQUEST_CODE) + level = "status" pairs
    and the optional tcUrl / description properties (omitted from
    the wire when None, per their "optional" marking in the spec's
    Info Object table). RtmpSession::send_reconnect_request is 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 (so next_packet pumping
    continues unchanged). On the publisher side,
    RtmpClient::poll_event now 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 new resolve_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
    (absolute rtmp://host:port/app, network-path //host/app,
    absolute-path /app, and relative-path app); RtmpClient::tc_url()
    exposes the stored base. Tests: wire-shape + optional-property
    omission in src/message.rs, classification + resolution tables in
    src/client.rs, and tests/reconnect_request.rs drives 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). New ChunkReader::abort_partial(chunk_stream_id) -> bool gives 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," so abort_partial
    clears the half-filled reassembly buffer for the named csid and
    returns true when 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. Like ChunkReader::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_bytes asserting the
    byte-exact §5.2 payload, chunk::tests::abort_partial_discards_in_ flight_message driving a two-chunk message truncated after its first
    chunk so the reader holds a partial buffer, then asserting
    abort_partial discards 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 at docs/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 in client.rs into a reusable building
    block on the message module. 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, and Unknown 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 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, and UserControlEvent is 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 + matching RtmpSession server-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 only StreamBegin (UCM 0) and StreamEOF (UCM 1) reached
    the publisher as typed [ClientEvent] variants, and the remaining
    spec-defined events were either swallowed silently into
    [ClientEvent::Other] (StreamDry UCM 2, SetBufferLength UCM 3,
    StreamIsRecorded UCM 4, PingResponse UCM 7) or had no builder
    available at all (StreamDry, SetBufferLength, StreamIsRecorded,
    PingRequest from the server side, PingRequest /
    PingResponse from 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_event decodes these into
    three new typed [ClientEvent] variants:
    [ClientEvent::StreamDry] (carries stream_id, distinct from
    StreamEof: per spec §3.7 StreamDry is a transient
    "no-data-right-now" signal whereas StreamEof is "playback
    finished"), [ClientEvent::StreamIsRecorded] (server announces
    the stream is on-demand / archival), and
    [ClientEvent::PingResponse] (carries the echoed 4-byte
    timestamp_ms for RTT measurement). SetBufferLength is
    publisher-direction inbound; the classify path validates the
    8-byte event-data length (returning Error::ProtocolViolation on
    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 a ClientEvent would expose a protocol-level
    liveness probe as an application event, which the spec assigns
    to the client. New RtmpClient::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_request give an ingest the symmetric server-side
    emitters. The MessageStreamKind classifier + protocol-control
    invariant validator from the round-247 commit keeps these on
    msg_stream_id == 0 per RTMP Message Formats §5 — the new
    builders all stamp msg_stream_id = 0 directly. 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_event driving a
    hand-rolled chunk-stream PingResponse injection,
    client_rejects_truncated_set_buffer_length confirming the
    8-byte validation refuses a 6-byte payload with a
    ProtocolViolation). Resolves the remaining Other-swallow gap
    identified by reading RTMP Commands Messages spec §3.7 against
    the round-247 classify-path coverage.

  • Typed MessageStreamKind accessor + spec-§5 protocol-control
    invariant validator on chunk::Message
    (src/chunk.rs,
    src/lib.rs, tests/message_stream_kind.rs). The chunk-reassembled
    Message's raw msg_stream_id: u32 now 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_FFFF handle 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), and Reserved(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
    above 0x00FF_FFFF is 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_id 1..=6) carries a non-zero msg_stream_id per 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.rs covers the round-trip case (build a
    SetChunkSize via build_set_chunk_size, render through
    ChunkWriter, reassemble through ChunkReader, confirm the typed
    view + validator both pass), the NetStream classification case
    (msg_stream_id == 1NetStream(1)), the forged-msid rejection
    case (SetChunkSize stamped with msg_stream_id = 1 refused 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
    and RtmpClient::poll_event, plus RtmpClient::send_aggregate
    outbound
    (src/server.rs, src/client.rs,
    tests/aggregate_routing.rs). Round 230 closes the matching consumer
    side of the round-229 aggregate parser + builder. Previously a
    publisher that bundled several frames into one Aggregate Message
    (RTMP 1.0 §7.1.6, message type id 22 — fewer chunk headers per A/V
    burst) had its body silently swallowed by the dispatch loop's _ => swallow fallback arm: the parse_aggregate entry point was reachable
    only via a hand-written caller pulling raw Message values out of
    [chunk::ChunkReader]. Server-side: next_packet now drains a new
    per-session pending_subs: VecDeque<Message> queue ahead of every
    wire read, and a fresh MSG_AGGREGATE arm decomposes incoming
    aggregates into that queue using the same aggregate::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
    giant next_packet body into a handle_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 existing closeStream / deleteStream /
    FCUnpublish teardown detection) all flow through the same arms as
    individually-sent messages. A sub whose msg_type_id is itself 22
    (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: RtmpClient carries the same
    pending_subs queue and a refactored poll_event loops over both
    the queue and the wire read so a server that ever bundled its
    onStatus / _result / UserControl replies into an aggregate
    surfaces the per-sub ClientEvents in publish order. Queued subs
    classified as ClientEvent::Other are dropped from the surface so a
    caller pumping poll_event doesn't observe N back-to-back Others.
    New outbound helper RtmpClient::send_aggregate(&[Message]) -> Result<()> is the symmetric publisher API on top of
    aggregate::build_aggregate: 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 (6), and a zero-length slice is
    a no-op. 4 new integration tests in tests/aggregate_routing.rs:
    (1) a video + audio + onMetaData aggregate round-trips through real
    loopback RtmpClient::send_aggregate
    ChunkReader::read_messageparse_aggregate
    RtmpSession::next_packet and 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.timestamp exactly (1000 → 1000, 1023 → 1023, no
    drift); (3) an aggregate carrying a closeStream AMF0 command sub
    drives the same teardown path the standalone command takes — the
    server reports Ok(None) after the prior media sub instead of
    spinning on the post-FIN socket; (4) the client-side poll_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 by client_stream_eof.rs
    unchanged). Total lib tests: 217 (unchanged — the new work is
    end-to-end via the integration harness, where queue draining
    through real TcpStream state is the load-bearing surface).
    Total integration tests: 73 → 77 (+4). Resolves the
    next_packet / poll_event half 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_BASE switched from 1/1000 (ms) to 1/1_000_000_000 (ns)
    to fold TimestampOffsetNano onto the Packet timeline

    (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 millisecond Packet
    timeline is a follow-up." enhanced-rtmp-v2.pdf §"ExVideoTagHeader"
    / §"ExAudioTagHeader" assigns the TimestampOffsetNano ModEx
    subtype (the only ModExType defined 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_000 constant exported alongside
    RTMP_TIME_BASE so a consumer can recover the wire ms value
    (pts / RTMP_MS_TO_NS) when it needs the legacy unit.
    Multiple TimestampOffsetNano ModEx entries are summed via the
    existing VideoTag::timestamp_offset_nano /
    AudioTag::timestamp_offset_nano accessors (one
    bytesToUI24 per entry); ModEx entries of other subtypes do not
    feed the sum, matching the typed accessor contract. The
    StreamInfo::time_base exposed by RtmpPacketSource follows
    RTMP_TIME_BASE so a downstream PacketSource consumer (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
    TimestampOffsetNano contributions (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 by RTMP_MS_TO_NS; the ModEx
    integration test in tests/enhanced_rtmp_video.rs now 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
    Message of 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. New aggregate
    module exposes parse_aggregate(&Message) -> Result<Vec<Message>>
    and build_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.3 PreviousTagSize == 11 + DataSize back-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 timestamp t_i onto the stream clock as
    t_i + (aggregate.timestamp - t_0); the builder sets
    aggregate.timestamp == subs[0].timestamp so the SHOULD-be-zero
    offset holds. Sub Stream ID fields are written as 0 on the wire
    (per §7.1.6 / §E.4.1) and the parser overrides every decoded sub's
    msg_stream_id with 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 typed Result::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.rs cover: 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-header StreamID = 0
    invariant on build; outer timestamp = 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 → clean UnexpectedEof; and a
    1024-iteration deterministic-xorshift fuzz pass guaranteeing
    parse_aggregate is panic-free on arbitrary bytes. 2 new
    integration tests in tests/aggregate_chunk_round_trip.rs drive
    build_aggregate → ChunkWriter::write_message → ChunkReader::read_message → parse_aggregate on 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.rs extend 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 connect capability negotiation
    (src/caps.rs, src/message.rs, src/client.rs, src/server.rs,
    tests/connect_capabilities.rs). The fourCcList /
    videoFourCcInfoMap / audioFourCcInfoMap / capsEx properties
    defined in enhanced-rtmp-v2.pdf §"Enhancing NetConnection connect
    Command" are now exchanged end-to-end between
    RtmpClient::connect_with_capabilities and RtmpServer::set_capabilities.
    New ConnectCapabilities struct exposes all four entries plus the
    legacy objectEncoding byte; new FourCcInfoMap keeps the per-codec
    (FourCC, mask) entries in insertion order and implements the spec's
    wildcard-OR rule via effective_mask. New constants mirror the spec
    enums verbatim: FourCcInfoMask (FOURCC_INFO_CAN_DECODE = 0x01 /
    _CAN_ENCODE = 0x02 / _CAN_FORWARD = 0x04) and CapsExMask
    (CAPS_EX_RECONNECT = 0x01 / _MULTITRACK = 0x02 / _MOD_EX = 0x04
    / _TIMESTAMP_NANO_OFFSET = 0x08); the "*" catch-all key is the new
    FOURCC_WILDCARD constant. The Command Object properties are appended
    to the historical app / tcUrl / flashVer / fpad /
    capabilities / audioCodecs / videoCodecs / videoFunction
    block in the documented spec order (objectEncodingfourCcList
    videoFourCcInfoMapaudioFourCcInfoMapcapsEx); 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
    matching build_connect_result_with_caps builder. Surfaced to callers
    as PublishRequest::capabilities (client-advertised) and
    RtmpClient::server_capabilities() (server-advertised); the
    ConnectCapabilities::from_amf0 parser silently drops malformed
    values (non-numeric mask bytes, non-finite numbers, negative masks,
    String for capsEx, etc.) and saturates out-of-u32 numbers to
    u32::MAX, matching the spec's "fail gracefully" rule. Resolves the
    r0.0.4 README note "The connect command's fourCcList
    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 / capsEx not being
    populated by RtmpClient::connect. 18 new unit tests in
    src/caps.rs cover: FourCcInfoMask / CapsExMask constants match
    the spec table; FourCC wildcard is the single-byte "*";
    FourCcInfoMap::insert preserves insertion order across duplicate
    keys; effective_mask ORs 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_fourcc wildcard + explicit; supports_caps_ex bit-test;
    malformed capsEx falls back to default; non-object inputs return
    empty; objectEncoding round-trips 0 / 3; ECMA-array parses the same
    as Object. Plus 4 unit tests in src/message.rs:
    build_connect_with_caps with empty caps matches legacy bytes
    exactly; non-empty caps append the properties in documented order
    after videoFunction; build_connect_result_with_caps echoes the
    block inside the info object alongside
    NetConnection.Connect.Success; empty caps match legacy
    build_connect_result byte-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; capsEx bit-test surfaces
    Reconnect / Multitrack / ModEx / TimestampNanoOffset after a real
    loopback. Total lib tests: 177 → 198 (+21 — 18 caps + 4 message tests
    hosted in the existing message::tests module). Total integration
    tests: 65 → 69 (+4).

  • FLV file / byte-stream reader (src/flv_file.rs,
    tests/flv_file_record.rs). Inverse of the round-204 FlvWriter:
    new FlvReader<R: Read> wraps a Read source and walks the §E.2
    9-byte file header, the §E.3 alternating PreviousTagSize /
    FLVTAG body, and each §E.4.1 FLVTAG header, surfacing every
    tag as a strongly-typed FlvTag enum
    (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 existing flv::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, and ModEx
    preludes — round-trips byte-for-byte through reader → writer
    without re-implementing the §E.3 walk. Script tags decode as an
    AMF0 Name + Value pair per §E.4.4; a script body that fails AMF0
    decode is preserved verbatim as FlvTag::Unknown (tag_type = 18) so a forwarding consumer never silently drops bytes.
    FlvReader::new consumes the §E.2 header (signature F L V +
    version + TypeFlagsAudio / TypeFlagsVideo + UI32 DataOffset)
    and the mandatory PreviousTagSize0 == 0 back-pointer eagerly,
    refusing wrong-signature / wrong-version / nonzero-PreviousTagSize0
    inputs up front. A larger DataOffset (forward-compatible header
    extension) is skipped transparently. Verifies the §E.3
    PreviousTagSize == 11 + DataSize invariant on every tag and
    refuses to advance past a mismatch (forged producer / transport
    corruption). UI24 DataSize is bounded by a configurable
    max_tag_size (default = UI24 ceiling, DEFAULT_MAX_TAG_SIZE)
    via the new FlvReader::with_max_tag_size constructor — HTTP-FLV
    proxies generally want a tighter cap, trusted local files can
    raise it back to the wire ceiling. The §E.4.1 StreamID = 0
    invariant is enforced, the §E.4.1 Filter = 1 (Annex F encrypted
    body) surfaces as a clean Error::Other rather than silently
    passing through, and truncated header / payload / back-pointer
    all surface as Error::UnexpectedEof. FlvReader::read_tag
    returns Ok(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_all consumes the rest of the
    stream into a Vec<FlvTag> for one-shot use. 17 new tests (15
    unit in src/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 HEVC CodedFrames /
    full 4-tag stream (script + video SH + audio SH + video inter)
    byte-for-byte through reader → writer; AMF0 onMetaData
    name+value pair decoded into typed EcmaArray; TimestampExtended
    high byte re-joined into a 32-bit value; bad signature / wrong
    version / nonzero PreviousTagSize0 / DataOffset < 9 rejected
    cleanly; forward-compatible header padding (DataOffset = 11)
    skipped transparently; corrupt PreviousTagSize / oversize
    DataSize / nonzero StreamID / Filter = 1 rejected with
    matching error variants; truncated FLVTAG header and payload
    surface Error::UnexpectedEof; unknown TagType value (5) lifted
    as FlvTag::Unknown with the verbatim body. End-to-end
    integration test drives an RTMP loopback, records every received
    StreamPacket to an in-memory FLV byte stream via FlvWriter,
    then walks the resulting buffer back through FlvReader and
    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). New flv_file module exposing
    FlvWriter<W: Write>, FlvHeaderFlags, build_flv_header, and
    build_flv_tag per docs/container/flv/flv_v10_1.pdf Annex E.
    The writer emits the §E.2 9-byte file header (signature F L
    V, version, TypeFlagsAudio / TypeFlagsVideo, DataOffset = 9) and the §E.3 alternating PreviousTagSize / FLVTAG body,
    framing each VideoTag / AudioTag via the existing
    flv::build_video / flv::build_audio paths and tracking the
    PreviousTagSize back-pointer (11 + DataSize) automatically.
    write_script_data(timestamp_ms, name, &Amf0Value) emits an
    AMF0 name + value pair as a §E.4.4 script-data tag (type 18) —
    the canonical use is an onMetaData tag emitted right after the
    header. The §E.4.1 24-bit Timestamp + 8-bit TimestampExtended
    splitting is handled transparently so callers pass a single
    u32 timestamp. write_raw_tag is the escape hatch for callers
    who build their own payload (e.g. an Annex F encrypted body).
    Composes with RtmpSession so an RTMP ingest can be recorded to
    an .flv file or re-served over HTTP-FLV (an HTTP-FLV response
    body is exactly this byte stream with Content-Type: video/x-flv) without re-parsing any payload. 19 new tests (16
    unit in src/flv_file.rs, 3 integration in
    tests/flv_file_record.rs): header signature + flags + offset
    bytes match §E.2 exactly; 9-byte UI24 DataSize + UI8
    TimestampExtended layout matches §E.4.1 byte-for-byte (both
    the timestamp-under-24-bit and timestamp-over-24-bit-needing-
    TimestampExtended paths); PreviousTagSize0 always 0 (§E.3);
    multi-tag back-pointer tracking across an interleaved video +
    audio + video sequence (20 / 16 / 23 byte tags); over-UI24
    payload rejected as InvalidInput (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 HEVC CodedFrames ExHeader
    round-trip preserves FourCC + composition_time; AMF0 onMetaData
    script-tag name-then-value layout round-trips; finish()
    idempotency; post-finish writes return BrokenPipe; the
    escape-hatch write_raw_tag lets 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_audio proves
    every recorded payload re-parses unchanged. Total integration
    tests: 61 → 64 (+3); total tests: 202 → 222 (+20 including the
    new doc-test).