Releases: OxideAV/oxideav-h261
v0.0.7
Other
- §4.2.1.3 PTYPE display-control flags (split-screen / doc-cam / freeze-release)
- §3.4 forced updating — per-MB cyclic INTRA refresh
- §4.2.3.4 MVD predictor reset at MB-row starts (MBA 12 / 23)
- criterion suite for §3.2.3 loop filter + §3.2.2 integer-pel MC
- RFC 4587 §4.2 MB-level fragmentation with §4.1 context
- §6.2.1 preference-aware a=fmtp formatter
- §6.2.1 strict-conformance
a=fmtpparser - §6.2.1 wire-order preference accessor for fmtp
- §6.2 strict-conformance accessor for RtpMap clock-rate MUST
- drop release-plz.toml — use release-plz defaults across the workspace
- criterion suite for §4.1 / §4.2 start-code scanner
- criterion suite for §5.4 BCH (511, 493) FEC layer
- §5.4.1 single-bit BCH (511,493) error correction (t = 1)
- still-image sub-image transform per H.261 §D.2 + §D.3
- fifth cargo-fuzz target for SDP signalling parser
- Annex A conformance test — §A.1..§A.9 against f64 reference
- fourth cargo-fuzz target for RTP data-path parser
- third cargo-fuzz target for BCH (511,493) FEC multiframe parser
Added
-
§4.2.1.3 PTYPE display-control flags (encoder). The encoder can now
emit the three picture-layer display-control bits the decoder already
parsed — split-screen indicator (bit 1), document-camera indicator
(bit 2), and freeze-picture release (bit 3, §4.3.3). A new
encoder::Ptypestruct carries the three flags and a new
encoder::write_picture_header_ptypewriter threads them into the
picture header;write_picture_header/write_picture_header_full
now delegate to it withPtype::default()(all flags off), so the
canonical motion-video header is byte-for-byte unchanged. Previously
these three bits were hardcoded to "0" with no caller-facing way to set
them. Four new round-trip tests assert each flag, and all three
together (on a CIF Annex-D still-image header to prove independence
from the source-format and HI_RES bits), reach the decoder's
parse_picture_headerexactly. -
§3.4 forced updating (per-MB cyclic INTRA refresh). H.261 §3.4
requires every macroblock to be forcibly INTRA-coded "at least once
per every 132 times it is transmitted" so that inverse-transform
mismatch error cannot accumulate without bound between whole-frame
I-refreshes. The encoder previously relied solely on the frame-level
intra_period(a whole-picture I-refresh). It now also runs a per-MB
forced-update scheduler:H261Encodertracks how many times each
macroblock has been transmitted since its last INTRA coding (global
raster order across all GOBs) and forces the due macroblocks to INTRA
mode inside a P-picture before any counter reaches the period. The
load is spread across frames with a round-robin sweep
(ceil(total_mbs / period)MBs per P-frame) instead of spiking when
every counter hits the cap together.H261Encoder::with_forced_update_period
overrides the period (default132, the spec maximum;0disables).
A new publicencode_inter_picture_forced_updatelets callers driving
the stateless P-picture path supply their own forced-update set (for
example the RFC 4587 §C.3 loss-driven MB refresh). INTRA MBs in a
P-picture reset the §4.2.3.4 MVD predictor since they are never
motion-compensated.
Fixed
- §4.2.3.4 MVD predictor reset at MB-row boundaries (MBA 12 and 23).
The decoder's motion-vector-data predictor only reset to zero at GOB
start, on MBA discontinuities, and when the previous MB was not
motion-compensated. It was missing §4.2.3.4 condition (1): the
predictor "is regarded as zero" for macroblocks 1, 12 and 23 (the
first MB of each of the three rows in an 11×3-MB GOB). MBA 1 was
already covered by the per-GOB context reset, but MBA 12 and 23 were
not — so a conformant stream carrying a non-zero MV at MB 11 (or 22)
immediately followed by a motion-compensated MB 12 (or 23) decoded
the wrong vector. The in-crate encoder had previously worked around
this by forcing the MV to zero at MBs 11 and 22 to keep the two
sides in agreement; with the decoder now spec-conformant, that
constraint is removed and the encoder may use motion compensation at
every MB. The RFC 4587 §4.2 MB-level fragmentation walker, which
carries its own §4.2.3.4 predictor tracking, was given the same
reset so it stays bit-for-bit in lockstep with the decoder. A new
sharedmb::mvd_predictorhelper is the single source of truth for
the three reset conditions, unit-tested across all of them.
Added
-
filter_mccriterion benchmark — §3.2.3 loop filter + §3.2.2
integer-pel motion-comp. The existingtransformbench covered
the inner (I)DCT, andencode/decodecover end-to-end picture
cost, but the two other per-block P-picture reconstruction
primitives the decoder runs on every coded P-block had no isolated
baseline. The newbenches/filter_mc.rstimesmb::apply_loop_filter
(the separable 1/4-1/2-1/4 in-loop filter with 0-1-0 edge taps) and
mb::copy_block_integer(the integer-pel reference fetch) across
three motion regimes (center,mv_nonzero,corner_clamp). Both
functions are nowpub(matching the existingfdct/idct
primitive exports) so an optimisation pass — a SIMD loop filter or a
branchless edge-clamp copy — has an A/B baseline distinct from the
transform numbers. Round-287 release-build aarch64 baseline: loop
filter ≈ 25 ns / block (≈ 2.5 Gelem/s); integer-pel copy ≈ 15 ns /
block (≈ 4.1 Gelem/s) interior, ≈ 14.5 ns fully corner-clamped. -
RFC 4587 §4.2 MB-level fragmentation. The RTP module previously
shipped only the "cheap" GOB-aligned packetizer; a GOB larger than
the payload budget was split at arbitrary byte boundaries with
zeroed context fields, so its continuation packets were not
independently decodable after a loss — the exact problem the §4.1
H.261 header exists to solve. The new
rtp::packetize_mb_fragmentedimplements the §4.2 RECOMMENDED
packetization: a single Huffman-layer walk over the elementary
stream (walk_mb_split_points— MBA/MTYPE/MQUANT/MVD/CBP/TCOEFF
VLCs parsed, nothing dequantised or transformed, per §4.2 "it is
not necessary to decompress the stream fully") records every legal
split point with its §4.1 context, then packets are filled greedily
(multiple GOBs/MBs per packet when they fit, per §3.2) under the
§3.2 rules: an MB is never split across packets, the stream is
never fragmented between a GOB header and MB 1, and no packet
crosses a PSC. Mid-GOB packets carry non-zero SBIT/EBIT plus the
GOBN / MBAP (biased -1) / QUANT / HMVD / VMVD context; the walker
tracks the §4.2.3.4 MV predictor (including the consecutive-MBA
and last-MB-was-MC rules) so the reference MVD is exact.
RtpPacketizer::with_mb_fragmentation(true)routespack_frame
through the new path with an automatic fallback to the byte-split
cheap packetizer when no MB-boundary split exists, and two new
RtpErrorvariants (MalformedStream,FragmentTooLarge) surface
walk/budget failures on the direct path. Eleven new tests cover
it: the Huffman-layer walk is checked bit-for-bit against a
real-decoder (decode_macroblock) oracle on I-pictures across a
quantiser sweep and on a P-picture with live motion vectors; round
trips at multiple budgets are byte-exact throughdepacketize
(which already handled non-zero SBIT/EBIT); fragment chains are
verified bit-contiguous (shared split byte,
next.SBIT == (8 - prev.EBIT) % 8); continuation headers are
matched back to walker split points; the PSC-crossing ban, the
whole-frame-in-one-packet case, theFragmentTooLargepath, and
two end-to-end RTP-session decodes (tests/rtp_e2e.rs) round it
out. -
RFC 4587 §6.2.1 preference-aware
a=fmtpformatter. §6.2.1
states "Parameters offered first are the most preferred picture
mode to be received" — an endpoint expresses its receive preference
purely through token order. The fixed-orderformat_value/
format_fmtpare locked to the §6.2.1 worked-example CIF-first
order, so an endpoint advertising both picture sizes but preferring
to receive QCIF could not express that on the wire. The new
H261FmtpParams::format_value_preferred(preferred)method and
format_fmtp_preferred(pt, ¶ms, preferred)free function emit
the preferred picture-size token first when that size is advertised
(QCIF=1;CIF=2;D=1for a QCIF-preferring endpoint), the other
advertised size second, andDlast (Dis an Annex-D codec
option, not a picture mode, so the §6.2.1 "offered first" rule does
not order it). A CIF preference is byte-identical to the canonical
formatter (format_valueis now a thin wrapper over
format_value_preferred(SourceFormat::Cif)); an unadvertised
preference falls back to the canonical order; the §6.2 "if any"
no-parameters ⇒ no-line rule is preserved. This is the emit-side
dual of the parse-sideparse_preference_orderaccessor: whenever
the params advertise the preferred size, the leading entry of
parse_preference_order(format_value_preferred(fmt))isfmt,
closing the §6.2.1 wire-order loop in both directions. Five new
unit tests insrc/sdp.rscover CIF-preference identity across
five parameter shapes, the QCIF-first emission + wire-order
read-back, the unadvertised-preference fallback (both directions),
the parse round trip under both preferences, and the full
a=fmtpline builder (QCIF-first line, CIF-preference byte
equality withformat_fmtp, empty-paramsNone, reparse through
parse_fmtp). The existing fuzz targetparse_sdp_fmtpand the
stable-CItests/fuzz_seed_corpus_sdp.rsdriver gain a Mode G
oracle: on every input that parses cleanly, (1)
format_value_preferred(Cif) == format_value(), (2) both
preference emissions reparse to equal params, and (3) when the
params advertise the preferred size,parse_preference_order
reads it back as the leading ent...
v0.0.6
Other
- RFC 4587 §6.2.1 offer/answer negotiation helper
- fix truncated
1?TCOEFF prefix panic (daily-fuzz finding) - criterion suite for transform / encode / decode hot paths
- scrub decorative external-implementation attribution
- second cargo-fuzz target for RTCP compound parser
- cargo-fuzz decoder harness + daily workflow
Added
-
SDP offer/answer negotiation helper (RFC 4587 §6.2.1). The
sdpmodule gains the free functionnegotiate_answer(offer, our_capability) -> Result<H261FmtpParams, SdpError>that computes
the §6.2.1 answer parameters from a received offer and our
local capability:- Picture-size intersection. Only sizes both peers advertise
survive into the answer; a disjoint pair (e.g. CIF-only offer vs
QCIF-only capability) errors withSdpError::NoPictureSize,
matching §6.2.1's "SHALL specify at least one supported picture
size". - MPI per shared size. §6.1.1's MPI is the minimum picture
interval, so29.97 / MPIis the upper bound on frame rate.
The answer carriesMPI = max(offer.MPI, our.MPI)per shared
size, i.e. the more restrictive bound binds. - Annex D (
D). §6.2.1: "This option MUST NOT appear unless
the sender of this SDP message is able to decode this option."
The answer'sD=1requires bothoffer.d == Some(true)AND
our_capability.d == Some(true); otherwiseDis omitted from
the answer (matching §6.1.1's "SHOULD NOT be used … if not
supported"). - RFC 2032 fallback. §6.2.1: "If the receiver does not specify
the picture size/MPI parameter … assume that such a receiver is
able to support reception of QCIF resolution with MPI=1." The
helper applies that fallback automatically (equivalent to
H261FmtpParams::rfc2032_fallback()) when the offer carries no
picture-size parameter. The fallback is not applied to
our_capability— that side is local and should be supplied
explicitly.
The companion method
H261FmtpParams::preferred_picture_size()
returns the preferred receiver mode per §6.2.1 ("Parameters offered
first are the most preferred") —Some(SourceFormat::Cif)when CIF
is advertised (matchingformat_value's CIF-before-QCIF emission
order from the §6.2.1 worked example),Some(SourceFormat::Qcif)
when only QCIF is, elseNone. Eight new tests cover the
intersection / MPI-max / disjoint-sizes / Annex-D / RFC-2032-
fallback / format round-trip / max-frame-rate / validate-passes
paths; the negotiation example also runs as a doctest. - Picture-size intersection. Only sizes both peers advertise
Fixed
- Decoder panic on a truncated
1?TCOEFF prefix (round 175,
surfaced by the scheduled dailydecode_h261fuzz harness).
decode_tcoeff(.., is_first = false)saw a bit-reader where exactly
one bit remained and that bit was1. The function took the
b0 == 1branch and then peeked two bits from
peek >> (avail - 2), whereavail = 1caused an unsigned
underflow →attempt to subtract with overflowpanic under debug
/ ASAN builds. The two-bit peek is now gated behindavail >= 2
and the call returnsError::invalid("h261 tcoeff: truncated1?prefix")on the malformed input, restoring the public-surface
contract from the fuzz harness: every call returns — no panic, no
abort, no out-of-bounds. New regression test
tcoeff_truncated_one_bit_does_not_paniccovers it on stable Rust.
Added
-
Criterion benchmark suite (
benches/transform,benches/encode,
benches/decode). Round 175 (depth-mode) wires upcriterion = "0.5"
as a dev-dependency and registers threeharness = falsebench
binaries so future optimisation rounds have a recorded baseline to
A/B against:transformtimes the 8×8 inverse / forward DCT block hot path —
fdct_intra+fdct_signed(encoder forward pass) and
idct_intra+idct_signed(decoder inverse pass). One block per
iteration; throughput reported in samples so per-sample cycle-
equivalents land naturally.encodetimes whole-picture encode through the production
encode_intra_picture/H261Encoder::encode_framepaths in four
scenarios: QCIF intra-only (no ME), single-P from a pre-built I
reference, I + 3 P chain (full rate-controller carryover), and CIF
intra (the 4× area test).decodetimes whole-picture decode throughH261Decoder::send_packetreceive_frame, mirroring the encode scenarios. Each decode
bench runs the in-crate encoder once during setup to produce a
real elementary stream, so the timed loop measures the decoder
alone.
Every benchmark synthesises its YUV source inline from a
deterministic striped pattern plus low-amplitude xorshift noise —
no on-disk fixtures, no third-party CLI, nodocs/files read at
bench time.cargo bench -p oxideav-h261 --no-rundoubles as a
compile-only CI regression guard via the existing matrix.
-
Second
cargo-fuzztarget — RTCP compound parser. New
parse_rtcp_compoundfuzz target drives arbitrary fuzz-supplied bytes
through the public RTCP parser surface (parse_compound,
parse_report,parse_sdes,parse_bye,parse_app) so the §6.1
compound walk (16-bit-length advance), the SR/RR fixed header + RC
block walk, SDES chunk + item walk (including the PRIV inner 8-bit
length), BYE reason-string length-prefix, and APPname/data32-bit
alignment are all exercised against bytes whose shape the fuzzer
dictates. Same contract as the existingdecode_h261target: every
call must return — no panic, no abort, no integer overflow (in debug
/ ASAN builds), no out-of-bounds index, no allocator OOM. The seed
corpus underfuzz/corpus/parse_rtcp_compound/contains nine valid
datagrams (empty RR, SR with no blocks, SR with one block, RR with
two blocks, SDES CNAME, BYE with reason, APP with PING payload, and
two compound packets).tests/fuzz_seed_corpus_rtcp.rsdrives the
same logic on stable Rust against the corpus plus several adversarial
in-line buffers (lying header length, zero-length advance, truncated
compound, SDES PRIV length overflow, BYE reason overflow, APP at the
5-bit subtype maximum, unknown PT=205) so a regression in the public
parser surface trips an existing CI lane rather than waiting for the
daily fuzz run.
v0.0.5
Other
- RFC 4587 §6.1.1/§6.2 video/H261 rtpmap + fmtp parameter mapping
- RFC 3550 §6.7 Application-Defined (APP, PT=204) packet
- RFC 3550 §6.5 SDES + §6.6 BYE + §6.1 compound packets
- RFC 3550 §6.4 Sender/Receiver Report builders + RtpPacketizer counters
- encoder-side RFC 3550 packetiser stamps RFC 4587 RTP packets
- implement RFC 4587 H.261 RTP payload-format wrap/unwrap
- implement §5.2 + Annex B HRD buffer model and §5.4.2 spec test
- implement BCH (511,493) forward error correction framing (§5.4)
Added
-
SDP media-type /
rtpmap/fmtpparameter mapping (oxideav_h261::sdp).
New module implementing the RFC 4587 §6.1.1video/H261media-type
registration and its §6.2 SDP mapping. Thea=rtpmapline is fixed —
encoding nameH261, clock rate90000,m=media namevideo(pinned by
ENCODING_NAME/CLOCK_RATE/MEDIA_NAME);format_rtpmap(pt)emits it
andparse_rtpmapreads it back, confirming the encoding name is H.261
(case-insensitively), tolerating the optional trailing channel field, and
rejecting other codecs / non-rtpmap lines.H261FmtpParams { cif, qcif, d }
models the three §6.1.1 optionala=fmtpparameters:CIF/QCIFcarry an
MPI integer 1..=4 ("max rate29.97 / valuefps"),Dsignals Annex D
still-image support (1/0).format_value/format_fmtpemit
CIF=2;QCIF=1;D=1(CIF before QCIF, the §6.2.1 example order; no line when
no parameters are set, per §6.2 "if any");parse_value/parse_fmtp
reverse it, enforcing the 1..=4 MPI range (MpiOutOfRange) andD ∈ {0,1}
(BadAnnexD), rejecting non-integer values / malformed tokens / duplicate
picture-size params, tolerating whitespace, matching parameter names
case-insensitively, and skipping unknown parameters forward-compatibly. The
§6.2.1 offer/answer helpers:validateenforces "SHALL specify at least one
supported picture size" (NoPictureSize),rfc2032_fallbackreturns the
§6.2.1 default (QCIF MPI=1) for a peer that omits picture-size params, and
max_frame_rate(fmt)returns the exact29.97 / MPIbound as an integer
rational ((2997, 100 * MPI)) so the §6.2.1 "≤ 15 fps for CIF=2" bound is
computed without floating-point round-off. The SDP offer/answer state machine
and the rest of the session description (v=/o=/c=/t=) remain
caller-side — this module owns only the H.261-specificrtpmap/fmtpwire
format. The RFC 2032 H.261-specific RTCP control packets (FIR / NACK) are
deliberately not implemented: RFC 4587 §7.1 mandates new implementations
SHALL ignore them and SHALL NOT use them. 36 new unit tests cover the
spec-example round trip, both line builders/parsers (with and without the
a=prefix), the full 1..=4 MPI range, all six error variants, the
forward-compatible unknown-parameter skip, case-insensitive name matching,
the RFC-2032 fallback, the frame-rate rational, and a full two-line session-
description round trip. -
RTCP APP (Application-Defined) packet (
oxideav_h261::rtcp). Builder and
parser for RFC 3550 §6.7 (PT = 204).build_app(subtype, ssrc, name, data)
emits the standard 4-byte RTCP header (with the 5-bit RC slot reused as the
§6.7 subtype) + SSRC + 4-octet ASCII name + application-dependent data;
parse_appreverses it. The builder enforces three §6.7 invariants —
subtype≤ 31 (5-bit field,AppSubtypeOutOfRange),nameexactly 4
octets (AppNameWrongLength), anddata.len() % 4 == 0
(AppDataNotAligned, "must be a multiple of 32 bits long"). The parser
rejects truncated buffers, V != 2, PT != 204, and length-field-smaller-than-
mandatory-header. APP packets now round-trip throughparse_compoundas a
typedRtcpPacket::App(AppPacket)variant rather than falling into the
catch-allOther; unknown PTs (e.g. RFC 4585 RTPFB = 205) still surface as
Other. §6.7 mandates names be case-sensitive ("uppercase and lowercase
characters treated as distinct"), so the parser surfaces the four bytes
verbatim without case folding. The §6.2 transmission-interval scheduler and
the §A.1 / §A.3 / §A.8 loss-fraction / jitter estimators remain caller-side
(out of scope for the codec). 14 new unit tests cover the header layout for
empty + data-bearing packets, subtype-0 / subtype-31 boundaries, 1024-byte
payload round-trip, byte-exact (non-case-folded) name preservation,
short-header / bad-version / wrong-PT / truncated-by-length / past-stated-
length rejection paths, all three builder validation errors (subtype-32,
name-of-every-non-4-length, data-of-every-non-aligned-length 1..=9), and a
compound RR + SDES + APP round-trip that pulls the App variant back typed.
The pre-existing "compound_preserves_unknown_app_packet" test was updated to
use PT = 205 (a stand-in for an RFC 4585 RTPFB packet this module doesn't
model) since APP is no longer "unknown." -
RTCP SDES + BYE + compound packets (
oxideav_h261::rtcp). Rounds out
the control channel beyond SR/RR (RFC 3550 §6.5 / §6.6 / §6.1).
build_sdes/parse_sdes(PT=202, §6.5) handle Source Description
packets: 0..=31 chunks, each binding an SSRC/CSRC to a list ofSdesItems
—Cname(§6.5.1, mandatory),Name,Email,Phone,Loc,Tool,
Note, andPriv(§6.5.8 prefix/value) — independently 32-bit-aligned
with a trailing END (item-type-0) byte and null padding.build_cname_sdes
is the one-call helper for the minimal "SSRC → CNAME" chunk §6.1 requires
in every compound packet.build_bye/parse_bye(PT=203, §6.6) carry
0..=31 leaving SSRC/CSRC identifiers plus an optional 8-bit-length-prefixed,
null-padded free-text reason.compoundconcatenates pre-built sub-packets
into one datagram body;parse_compoundwalks a received datagram back into
typedRtcpPackets (Report/Sdes/Bye/Otherfor unmodelled PTs
such as APP=204), advancing via each sub-packet's self-delimitinglength
field. Item text / reason strings are validated against the 255-octet 8-bit
length limit (TextTooLong/PrivTooLong); the SC field is capped at 31
(TooManySources); parsers decode text UTF-8-lossily so a malformed
datagram never panics, skip unknown SDES item types forward-compatibly, and
reject truncated / wrong-PT / wrong-version input. Scheduling (§6.2) and the
§A.1/§A.3/§A.8 loss/jitter estimators remain caller-side (out of scope for
the codec). 19 new unit tests cover header/alignment, all-item-type and
multi-chunk round-trips, max-length and empty-chunk edges, the 31/255 caps,
unknown-item skipping, and three compound-packet round-trips (RR+SDES+BYE,
SR-with-block+SDES, RR+unmodelled-APP) plus truncation rejection. -
RTCP Sender / Receiver Report builders (
oxideav_h261::rtcp). The
control-channel companions to the RTP data path (RFC 3550 §6.4).
build_sender_report(PT=200, §6.4.1) emits the 8-byte RTCP header +
20-byte sender-info section (NTP + RTP timestamps, sender's packet &
octet counts) + 0..=31 reception report blocks;build_receiver_report
(PT=201, §6.4.2) is the same minus the sender-info section, with an
empty RR (RC=0) as the canonical "nothing to report" packet.
ReceptionReportBlock(24 bytes: SSRC, 8-bit fraction lost, 24-bit
two's-complement cumulative lost, extended highest sequence number,
jitter, LSR, DLSR) andSenderInforound-trip throughparse_report,
which validates V=2, the SR/RR PT, and the §6.4.1lengthfield
(32-bit words minus one).RtpPacketizernow tracks the session's
running packet/octet counts and the last frame's RTP timestamp, exposed
viapacket_count()/octet_count()/sender_info()and a
sender_report()convenience that drops a conformant SR straight out of
the packetiser state. Scheduling (§6.2), SDES/CNAME/BYE, and the
§A.1/§A.3/§A.8 loss/jitter estimators remain caller-side (out of scope
for the codec). Wired through end-to-end tests that encode QCIF
I-pictures, packetize them, build an SR from the packetiser counters,
and round-trip both SR and RR through the parser. -
Encoder-side RTP packetiser (
RtpPacketizer). Higher-level glue
betweenH261Encoderand the RTP wire format. Construct with
RtpPacketizer::new(payload_type, ssrc, initial_sequence_number, max_rtp_packet_size); callpack_frame(frame_bytes, rtp_timestamp_90khz)once per coded picture. Returns a sequence of
RtpPackets whosebytesfield is a complete RFC 3550 §5.1 fixed
header (V=2, P=0, X=0, CC=0, M, PT, seq, ts, SSRC) followed by the
RFC 4587 §4.1 4-byte H.261 header and the GOB-aligned payload slice.
The marker bit is set on the LAST packet of each frame per RFC 4587
§4.1 ("MUST be set to one in the last packet of a video frame;
otherwise, it MUST be zero"); sequence numbers auto-advance mod
2^16 across frames; the same RTP timestamp is stamped on every
packet of one frame (§4.1). The 7-bit payload type is masked
internally so callers passing au8with the high bit set don't
corrupt the M bit.parse_rtp_fixed_headerparses RFC 3550 §5.1
headers (including any CSRC list) for the receiver side. Wired
through an end-to-end test that drivesH261Encoder.encode_frame()
for an I + P pair, packets them, parses the RTP fixed headers,
reusesdepacketizeon the inner payloads, and decodes the result
back into video frames. -
RTP payload format (RFC 4587). New
oxideav_h261::rtpmodule
implements the H.261 RTP payload-format §4.1 4-byte header (SBIT,
EBIT, I, V, GOBN, MBAP, QUANT, HMVD, VMVD) with bit-exact
pack_header/unpack_header, plus the GOB-aligned cheap
packetizer (packetize_gob_aligned) anddepacketizereassembler
from §4.2. The packetizer splits at byte-aligned PSC / GBSC
boundaries, fragments oversized GOBs at byte boundaries (SBIT/EBIT
stay zero), and sets the RTP marker-bit hint on the last payload of
each frame. Round-trips are byte-exact against `en...
v0.0.4
Other
- drop stale REGISTRARS / with_all_features intra-doc links
- drop dead
linkmedep - wire Encoder trait + registry, spiral+diamond ME
- registry calls: rename make_decoder/make_encoder → first_decoder/first_encoder
- auto-register via oxideav_core::register! macro (linkme distributed slice)
- unify entry point on register(&mut RuntimeContext) (#502)
Added
-
Encoder
Encodertrait implementation and registry wiring.
H261RegistryEncoderwrapsH261Encoderand implements the
oxideav_core::Encodertrait (send_frame/receive_packet/flush).
make_encoder(params)is now registered inregister_codecsvia
.encoder(encoder::make_encoder)so callers can use
reg.first_encoder(¶ms)with H.261.
The factory acceptsparams.width/params.height(QCIF or CIF) and
derives an initial GQUANT fromparams.bit_ratevia an empirical
bits-per-frame-at-Q1 model. The per-GOB MQUANT rate controller then
nudges ±1 step dynamically. At 64 kbit/s QCIF the factory selects
QUANT ≈ 9, delivering ≥ 45 dB PSNR_Y on smooth gradient content. -
Spiral + diamond motion estimation replaces the prior flat full-window
scan. The new search evaluates concentric ring boundaries innermost-first
with early termination when two consecutive rings show no improvement,
then refines with an 8-connected neighbourhood around the winner. On
static/smooth content this saves evaluating the outer rings (~80 % of the
961-point full scan) while maintaining quality; on complex motion it falls
back to the full ±15 range. A compact 3-tap diamond fallback catches any
one-pel miss at ring boundaries.
Changed
- Updated
lib.rsmodule docstring — the crate is no longer decode-only. - README feature matrix updated: MC encode, loop-filter-with-RDO encode,
per-GOB rate control, and registry encoder rows added.
Tests added
register_tests::register_via_runtime_context_installs_encoder_factoryregister_tests::encoder_factory_qcif_defaultsregister_tests::encoder_factory_cifregister_tests::encoder_factory_rejects_bad_dimensionsencoder::tests::make_encoder_derives_quant_from_bit_rate— encodes 8 QCIF
frames via theEncodertrait at 64 kbit/s target; asserts avg PSNR_Y ≥ 35 dB.ffmpeg_roundtrip::registry_encoder_qcif_roundtrip— encodes 4 QCIF frames
via theEncodertrait, feeds the stream to ffmpeg, asserts clean decode.ffmpeg_roundtrip::encoder_psnr_vs_source_at_default_quant— encodes 8
QCIF frames of a moving gradient and verifies avg PSNR_Y ≥ 32 dB after
ffmpeg cross-decode.
v0.0.3
Other
- replace never-match regex with semver_check = false
- migrate to centralized OxideAV/.github reusable workflows
- oxideav-core ^0.2 -> ^0.1 (0.2.0 was yanked)
- implement receive_arena_frame() for true zero-copy
- wire DoS framework (DecoderLimits + ArenaPool + arena Frame)
- adopt slim VideoFrame/AudioFrame shape
- investigate r5/r6 long-clip drift, prove no IDCT precision loss
- fix chained-P decoder bug at GOB end, add MQUANT-delta rate ctrl
- add FIL (loop filter) MTYPEs to P-picture mode decision
- integer-pel motion estimation + MC for P-pictures
- add P-picture (INTER, no MC) Baseline encoder
- pin release-plz to patch-only bumps
Added
receive_arena_frame()— zero-copy decode path.
Overrides the newoxideav_core::Decoder::receive_arena_frame()
method (added in oxideav-core 0.2.0) to return an arena-backed
oxideav_core::arena::sync::Framedirectly, skipping the per-plane
memcpy that the legacyreceive_frame() -> Frame::Video(VideoFrame)
path requires forSend. Internal queueing was reorganised so the
arena lease happens at drain time rather than decode time:
decodedPictures are queued raw and converted to either a
heap-backedVideoFrame(legacy path) or an arenaFrame(new
path) on demand. This keeps the pool short-lived — pool
exhaustion in the typical send-many-then-drain pattern is no
longer possible because the pool only holds slots for the
duration of frames the caller has explicitly drained via
receive_arena_frame.
Changed
-
Bumped
oxideav-coredep from0.1to0.2to pick up the
newDecoder::receive_arena_frametrait method (additive; default
impl preserves backwards compatibility for every other
oxideav-h261consumer). -
DoS-protection wiring (oxideav-core 0.1.8 framework).
H261Decodernow honours [oxideav_core::DecoderLimits] at two
layers, sub-task #85's second proof-of-concept after the h263 port.- Construction.
make_decoder(params)readsparams.limits()and
forwards to the newH261Decoder::with_limits(codec_id, limits)
constructor;H261Decoder::new(codec_id)is a thin wrapper that
usesDecoderLimits::default()(32 k × 32 k pixels, 1 GiB / arena,
8 arenas in flight).with_limitsbuilds an
Arc<oxideav_core::arena::ArenaPool>sized at
limits.max_arenas_in_flightslots ×min(limits.max_alloc_bytes_per_frame, 160 KiB)per arena (the 160 KiB cap is the H.261 worst-case CIF I420
frame plus alignment headroom — no real H.261 picture allocates more
than this regardless of the caller'smax_alloc_bytes_per_frame).
Per-arena alloc-count cap islimits.max_alloc_count_per_frame
(default 1M). - Header-parse cap.
decode_one_picturechecks
(width × height) <= limits.max_pixels_per_frameimmediately after
parse_picture_headerreturns the QCIF / CIF dimensions and surfaces
a tighter-than-format mismatch asError::InvalidData(NOT
ResourceExhausted— H.261's source format is fixed by a single
PTYPE bit, so the bitstream cannot declare an arbitrary size; a
failure here means "this codec's intrinsic frame size doesn't fit in
the caller's caps"). - Arena pool. Each picture decode leases one arena from the pool,
builds anoxideav_core::arena::Frame(refcounted handle to the
leased buffer + per-plane offset/length pairs + aFrameHeader),
materialises aVideoFramefrom it for the publicFrame::Video
enum, then drops the arena handle to return its buffer to the pool.
Pool exhaustion (every slot checked out) surfaces as
Error::ResourceExhaustedfrom the lease call, which propagates up
throughdecode_one_pictureand out ofsend_packet/flush. - Send-boundary trade-off. The
oxideav_core::Decodertrait
requiresSend, but the newoxideav_core::arena::Frameis
Rc<FrameInner>and therefore!Send. TheFrame::Video(VideoFrame)
enum returned byDecoder::receive_framestays heap-backed so
downstream consumers (oxideav-pipeline, oxideplay, etc.) keep the
same shape — the arenaFrameis a transient internal value that
backs each picture's allocation. When the workspace gains an
Arc<FrameInner>parallel-decoder variant the public API can be
migrated without disturbing this crate's pool wiring (the wiring
lives entirely insidedecode_one_picture). - Public test surface.
H261Decoderexposeslimits()and
arena_pool() -> &Arc<ArenaPool>for diagnostics and pool-aware
tests. Five new tests intests/dos_limits.rs:picture_header_too_large_returns_invalid_data— QCIF header
against a 99×99 pixel cap →InvalidDatamentioning
max_pixels_per_frame.picture_header_within_cap_decodes_normally— same QCIF header
against a 1024×1024 cap doesn't trip the dimension check.pool_exhaustion_returns_resource_exhausted— pool sized at 2,
third concurrent lease →ResourceExhausted.default_limits_admit_qcif_and_cif— sanity that the default
32 k × 32 k cap admits CIF.pool_buffer_returns_after_decode— pool sized at 1, lease/drop/
re-lease cycle works.
- Encoder is unchanged (no DoS surface — it consumes caller-owned
VideoFrames and produces compressed packets).
- Construction.
-
Encoder: P-picture (INTER) support with integer-pel motion compensation
(full-window ±15 SAD search per H.261 §3.2.2 / Annex A) and the
Inter+MC+FILMTYPEs (loop filter §3.2.3, separable 1/4-1/2-1/4 with
edge-pel passthrough). Each P-MB picks the cheapest of skip / Inter /
Inter+MC{-only,+CBP} / Inter+MC+FIL{-only,+CBP} via a bit-cost estimator
comparing MTYPE + MVD + CBP + a residual proxy. ffmpeg interop holds on
the FIL stream (ffmpeg_decodes_our_fil_p_pictures). On testsrc QCIF
the pipeline lifts ffmpeg-roundtrip PSNR from r12's 39.27 dB / 8680 B
to 39.40 dB / 8546 B (–1.5 % bytes, +0.13 dB). -
Encoder: per-GOB MQUANT-delta rate controller (§4.2.3.3). Tracks
cumulative bits within each GOB and nudges the quantiser ±1 step (within
a ±6 window around GQUANT) when an MB lands far over the linear bit
budget. Honoured only on MQUANT-bearing MTYPEs (Intra+MQUANT,
Inter+MQUANT, InterMc+CBP+MQUANT, InterMcFil+CBP+MQUANT); other modes
defer the change. Disabled byOXIDEAV_H261_NO_MQUANT=1for A/B
benchmarks. Trims 0.3 % bytes on the testsrc fixture at –0.03 dB
(8517 B / 39.37 dB vs r13's 8546 B / 39.40 dB).
Fixed
- Decoder: chained P-frame mishandling at GOB 5 MBA=33 (last MB of QCIF
GOB 5) on streams where the picture's last MB used MC-only mode. The
GOB MB-loop indecoder::decode_picture_bodyandmb::decode_mba_diff
used to break early onbits_remaining < 16, but the start-code prefix
is itself ≥16 zero bits — fewer than that cannot encode a start code,
so the remaining bits are valid MB data + padding. Now we only invoke
the start-code peek when ≥16 bits remain and otherwise let the VLC
decoder consume what's there. Self-decode of a 5-frame P-chain on
testsrc QCIF jumps from 25–28 dB PSNR (drift from misdecoded final
MBs) to a clean 36–37 dB matching ffmpeg byte-for-byte.
v0.0.2
Other
- drop oxideav-codec/oxideav-container shims, import from oxideav-core
- h261 tests: ffmpeg PSNR conformance suite (QCIF/CIF, I+P, qscale sweep)
- h261 encoder: drop unused is_intra_first arg from emit_runlevel
- h261 encoder: add ffmpeg roundtrip integration test
- h261 encoder: QCIF/CIF I-picture foundation (DCT + quant + VLC + layers)
v0.0.1
chore: Release package oxideav-h261 version 0.0.1