Skip to main content

mediadecode/
adapter.rs

1//! Adapter traits — the per-kind backend "vocabulary."
2//!
3//! A backend implements only the kinds it handles. R3D / BRAW /
4//! ARRIRAW / X-OCN / Canon RAW Light implement only [`VideoAdapter`].
5//! FFmpeg implements all four. The buffer type is **not** part of
6//! these traits — it's a struct generic on `Packet` / `Frame` so the
7//! same adapter can be used with different buffer types at different
8//! call sites.
9//!
10//! # The D-seat amputation contract
11//!
12//! Every packet and frame in this crate carries a `D` — the buffer
13//! type its bytes live in — and every backend chooses what to put
14//! there. This is the one law that choice has to obey:
15//!
16//! > **A backend's `D`-seat carrier must be owned, `Send + Sync`, and
17//! > cheap to clone (a refcount bump), with its lifetime fully
18//! > decoupled from the backend's internal buffers at the exit. No
19//! > backend-internal lifetime — an FFI pointer, a pooled buffer, a
20//! > JavaScript handle — crosses the seam.**
21//!
22//! The bytes are copied **once**, at the boundary, and what leaves is
23//! Rust-owned memory. A backend that hands out a view into its own
24//! allocation instead has not exported a frame; it has exported a
25//! borrow with the lifetime erased, and every consumer downstream
26//! inherits a rule it cannot see: hold this only as long as the
27//! decoder lives, do not send it to another thread, do not put it in
28//! a cache. A graph made of such frames cannot fan a message out to
29//! two consumers without the backend's permission.
30//!
31//! What the contract buys, in the order it matters:
32//!
33//! - **`Send + Sync`.** A frame can cross a channel and be *read* from
34//!   several threads at once. Refcounted-view carriers are routinely
35//!   `Send` and not `Sync`, which is exactly the shape that makes
36//!   fan-out impossible.
37//! - **A lifetime that is nobody's business.** The decoder can be
38//!   dropped, flushed, seeked, or reopened while a frame it produced is
39//!   still in flight.
40//! - **Clone is a refcount bump.** The message-carrier law (see
41//!   [`TrackInfo`](crate::demuxer::TrackInfo)) says `Clone` on a message
42//!   is never a deep copy; a carrier that is already owned and
43//!   refcounted keeps that true for free.
44//!
45//! The cost is one copy per exit, and it is the honest one: the
46//! alternative was not zero copies, it was a copy the consumer had to
47//! make itself, later, without knowing why.
48//!
49//! ## The corollary: some payloads cannot be carried at all
50//!
51//! Copying bytes only produces ownership when the bytes *are* the
52//! payload. A backend may be handed something whose bytes are
53//! **addresses of other live objects** — and copying those yields a
54//! carrier that is owned by every type-level test the contract states,
55//! `Send + Sync + 'static`, clone-is-a-refcount-bump, and that dangles
56//! the instant its source is dropped. The copy moved the pointer; it
57//! could not move what the pointer names.
58//!
59//! > **A payload that carries addresses instead of bytes is
60//! > uncarriable.** A backend that meets one must refuse it by name, on
61//! > *both* legs — the one that takes payloads out of the backend and
62//! > the one that hands them back in — and never mint a carrier for it.
63//!
64//! There is no bound on what such a payload's pointers might reach, so
65//! there is no depth of copying that would make it safe; "deeply own
66//! the referenced objects" is not a smaller version of this problem,
67//! it is a different backend. Refusal is the correct answer, not a
68//! conservative one.
69//!
70//! FFmpeg's `AV_PKT_FLAG_TRUSTED` is the concrete instance —
71//! wrapped-`AVFrame` producers use it for packets whose body is an
72//! `AVFrame` pointer structure passed between components inside one
73//! pipeline — and `mediadecode-ffmpeg` refuses it at both legs. A
74//! backend with an equivalent (a handle table, a shared-memory cookie,
75//! an object id) has the same duty.
76//!
77//! **This crate does not name the carrier.** No signature here or in
78//! [`crate::decoder`] mentions a concrete buffer type; `D` and
79//! `Buffer` stay generic and their bounds stay minimal
80//! (`AsRef<[u8]>`), so a consumer states what it spends. What
81//! satisfies the contract is the backend's use-site choice —
82//! `mediadecode-ffmpeg` binds an opaque `FfmpegBytes` over an
83//! `alloc::sync::Arc<[u8]>`, `mediadecode-webcodecs` binds its own
84//! `Arc`-backed view — and a `no_std` backend with a static arena can
85//! satisfy it too. The core neither knows nor cares which, which is
86//! also what lets a backend change its storage without changing its
87//! frames.
88//!
89//! # The two carrier lanes
90//!
91//! *User-ruled 2026-08-25.*
92//!
93//! The amputation contract above governs one lane. A backend may offer
94//! a second, and `mediadecode-ffmpeg` does — both first-class, neither
95//! a feature flag on the other:
96//!
97//! * the **view** lane hands out a refcounted handle onto the
98//!   allocation the substrate already made. Nothing is copied. It is
99//!   the **default**, because the ordinary consumer decodes in place:
100//!   read the frame, use it, drop it, decode on. Paying for a copy of
101//!   bytes you were going to discard is a cost with nothing on the
102//!   other side of it.
103//! * the **owned** lane is the amputation contract: every byte copied
104//!   once at the boundary into memory the caller's language owns.
105//!
106//! ## Which lane, and why
107//!
108//! | | view (default) | owned |
109//! |---|---|---|
110//! | cost per frame | none | one copy |
111//! | `Send` | yes | yes |
112//! | `Sync` | **no** | yes |
113//! | `Clone` | refcount bump | refcount bump |
114//! | lifetime | pinned to the backend's pools | answerable to nobody |
115//! | fan-out to N consumers | no | yes |
116//! | outlives the decoder | the buffer does; the **pool slot** stays out | freely |
117//! | pool recycling | native — it *is* the substrate's pool | needs one built (see the FFmpeg backend's issue #35) |
118//!
119//! And per payload shape, which is where a lane stops being a slogan:
120//!
121//! | payload | view | owned |
122//! |---|---|---|
123//! | compressed packet | window onto the demuxer's buffer | copy |
124//! | packet **submitted** to a decoder | shared when the payload's provenance and extent both prove the substrate may read past it, else copied | copy |
125//! | packet **handed back to the caller** | copy | copy |
126//! | tight video/image plane | window, at the decoder's own stride | copy, at the decoder's own stride |
127//! | padded video/image plane | **copy**, compacted to `row_bytes` | copy, compacted to `row_bytes` |
128//! | audio plane | window over **exactly** the valid samples | copy of exactly the valid samples |
129//! | resampled plane | window onto the resampler's output frame | copy |
130//! | subtitle rect, side data, extradata | **copy** — no refcount exists to share | copy |
131//!
132//! ## The two rules those rows come from
133//!
134//! **A view stops where the writing stopped.** A carrier is an
135//! `AsRef<[u8]>`, so the span it names is a span a consumer may read —
136//! and a span is only nameable if every byte in it was written. An
137//! audio plane is allocated with alignment padding past its samples and
138//! a view stops at the samples, because exposing the rest through a
139//! window is the same information leak it would have been through a
140//! copy. The lanes differ in who owns the bytes, never in which bytes
141//! exist.
142//!
143//! **Sharing is conditional, and the condition is a proof.** Where the
144//! extent is provably all output, the view lane takes a reference;
145//! where it is not, the view lane copies and says so. A padded video
146//! plane is the standing example: only the first `row_bytes` of each
147//! `linesize`-wide row are the decoder's, so *no* lane can hand out the
148//! padded span, and both compact it. Which is why the two lanes carry
149//! identical **content** while their strides and spans may differ — a
150//! parity check between them compares pixels, not buffers.
151//!
152//! ## The rule that decides which conversions may exist
153//!
154//! **A conversion that borrows its source can only copy.** Substrate
155//! packet and frame types share their buffers by refcount and hand out
156//! mutable slices with no copy-on-write, so a caller who still holds
157//! the source holds a mutable alias of every byte a view would read —
158//! from entirely safe code, on either thread, `!Sync` notwithstanding.
159//! There is no signature that borrows a source and returns a shared
160//! view soundly.
161//!
162//! So the view lane's conversions **consume**: hand the packet or frame
163//! over, and the carrier that comes back is the only thing left
164//! pointing at those bytes. The borrowing conversions are the owned
165//! lane, where copying makes the source's fate irrelevant. Two shapes,
166//! one law, and the compiler enforces the difference — a program that
167//! keeps the source and mutates it no longer type-checks.
168//!
169//! This falls out naturally for a backend's own roads: a demuxer owns
170//! the packet it just read, a decoder owns the frame it just decoded,
171//! and a resampler owns its output. The zero-copy chain is intact
172//! end to end; what changed is that a *caller* cannot ask for a view of
173//! something they are still holding.
174//!
175//! ## Two rules that came from being wrong once
176//!
177//! **A shared buffer is refused, not copied.** When a payload's buffer
178//! is referenced by a handle other than the packet it came from, this
179//! backend declines to carry it *by name* — and the reason is sharper
180//! than it first looks. The obvious answer is to copy: a copy is
181//! always sound, and it keeps the API total. But a copy needs a
182//! **read**, and a second reference held by safe code is exactly the
183//! state in which somebody may be writing those bytes right now, from
184//! another thread. The read is the race. Totality is not worth that,
185//! and a refcount protects an allocation's lifetime while saying
186//! nothing about its contents.
187//!
188//! The rule is about *who* holds the other reference, not about the
189//! count. A substrate that keeps its own reference to a container-held
190//! payload — cover art is the standing example — is not the hazard a
191//! caller's second, mutable handle is, and refusing it would have
192//! refused every embedded picture there is.
193//!
194//! **A submission that can be recorded cannot be shared.** "Built,
195//! submitted and dropped inside one call" is a claim about a function,
196//! and it stops being true when the thing you submit to *keeps* what it
197//! is given. A hardware probe that records packets so it can replay
198//! them after a fallback, and then hands that history to the caller as
199//! owned mutable packets, turns a scoped submission into an escape.
200//! Where such a history is being recorded, the body is copied; where
201//! nothing records — every software road, and the hardware road after
202//! it commits — the send stays zero-copy.
203//!
204//! ## Two more rules the send direction adds
205//!
206//! **A payload a caller holds owns its bytes.** Going *into* a decoder
207//! is the one direction where a backend could hand the substrate its
208//! own buffer — but a substrate's packet type is usually mutable, and a
209//! value a caller holds can be asked for that mutable view while the
210//! carrier it came from is still readable. That is an aliasing `&mut`
211//! out of entirely safe code, and being `!Sync` does not prevent it:
212//! one thread suffices. So the zero-copy send is **scoped** — built,
213//! submitted and dropped inside the backend, never surfacing as a value
214//! anyone can hold — and every packet a public API returns is a copy on
215//! both lanes.
216//!
217//! **Trailing capacity is not padding.** Decoders read a fixed number
218//! of bytes past a packet's payload, and a container's own packets
219//! carry exactly that much zeroed slack behind them. Bytes merely
220//! *existing* after a view is not the same fact: a video plane has more
221//! pixels after it and a resampled plane has more samples, and a
222//! bitstream reader running past the payload would consume either as
223//! though it were bitstream. So a carrier records **where it came
224//! from**, at capture, and only a payload captured out of a packet may
225//! be shared back into a decoder — everything else is copied into a
226//! properly padded one.
227//!
228//! **Take the view lane** when a consumer reads a frame and is done
229//! with it — a thumbnailer, a probe, a transcode step, an analysis pass
230//! that reduces each frame to numbers. This is most consumers, which is
231//! why it is the default.
232//!
233//! **Take the owned lane** when a frame has to *travel*: into a graph
234//! that fans it out to several consumers, across a channel to threads
235//! that will both read it, into a cache that outlives the session, or
236//! anywhere `Sync` is required. `mediagraph` is on the owned lane and
237//! belongs there — a graph node cannot hold a pool slot for as long as
238//! a graph might hold a message.
239//!
240//! ## The pool-hostage warning
241//!
242//! A view carrier keeps the substrate's buffer alive. That is the
243//! point, and it is also the catch: **a frame held is a pool slot
244//! held.** Decoders allocate from a fixed pool, and one that runs out
245//! blocks or fails. A consumer that parks view frames in a queue is a
246//! consumer that stalls its own decoder, and the symptom — a decode
247//! that mysteriously stops making progress — points nowhere near the
248//! queue that caused it.
249//!
250//! So the view lane's rule is: **read in place, drop, decode on.** If a
251//! frame needs to outlive the loop that produced it, that is the
252//! question the owned lane answers.
253//!
254//! ## The symmetry worth noticing
255//!
256//! The two lanes converge from opposite directions. The view lane rides
257//! the substrate's own buffer recycling natively, because its carrier
258//! *is* a reference into that pool. The owned lane copies, and copying
259//! per frame is an allocator call per frame — which is why an owned
260//! backend eventually wants a pool of its own to recycle its copies
261//! into.
262//!
263//! One lane gets pooling for free and pays in lifetime; the other gets
264//! lifetime for free and pays for pooling. Neither is the better
265//! answer, which is why both are first-class.
266//!
267//! # The resource governance contract
268//!
269//! *User-ruled 2026-08-25.*
270//!
271//! A decoding backend stands between a caller and a substrate — a C
272//! library, a browser API, a driver — and every one of those substrates
273//! allocates memory in response to bytes an attacker chose. A backend
274//! therefore owes the caller an answer to "how much can this cost?",
275//! and the honest answer has three tiers, not one. Stating where they
276//! end is part of the contract: a boundary that is never written down
277//! gets rediscovered, one review round at a time, as though it were a
278//! defect.
279//!
280//! ## Tier one — what the backend allocates itself
281//!
282//! **Every byte a backend copies or allocates is bounded, by a named
283//! seat or by a format.** No exceptions and no third kind: a buffer
284//! whose size comes from a file answers to a configured ceiling, and a
285//! buffer whose size is a property of a format answers to that format.
286//! A site that appears to be neither has not been thought about yet.
287//!
288//! This tier is provable, and it is proved by enumeration rather than
289//! by assertion: a backend is expected to keep an accounting of its own
290//! allocation sites and what bounds each one.
291//! `mediadecode-ffmpeg` keeps that table in its `buffer` module.
292//!
293//! Two rules this tier has cost real defects to learn:
294//!
295//! - **A judge must dominate the allocator's arithmetic, not the
296//!   payload's.** A budget compared against what the bytes nominally
297//!   weigh is not a budget on what will be spent. Allocators align,
298//!   pad, and round; on ordinary inputs the difference is under one
299//!   percent, which is exactly why under-pricing hides.
300//! - **Everything a conversion can refuse is refused before anything it
301//!   can allocate is allocated.** A correct refusal that arrives after
302//!   the expensive half of the work is a correct refusal that did not
303//!   help.
304//!
305//! ## Tier two — the substrate's own knobs
306//!
307//! **A backend sets every resource knob its substrate offers, at every
308//! interposition point the substrate exposes.** These are not the
309//! backend's allocations; they are the substrate's, intercepted where
310//! the substrate agreed to be interrupted — a ceiling field, an
311//! allocation callback, a negotiation hook, a custom I/O layer.
312//!
313//! This tier is **defense in depth, and it is not a proof.** Each knob
314//! bounds what that knob was built to bound, and the union of them is
315//! whatever the substrate's authors chose to make interruptible. A
316//! backend is obliged to use all of them and obliged not to claim that
317//! using them is the same as bounding the substrate.
318//!
319//! `mediadecode-ffmpeg` enumerates its knobs — what each one is, and
320//! what each one bounds — in its `buffer` module, beside the tier-one
321//! table.
322//!
323//! ## Tier three — the boundary
324//!
325//! **Allocations internal to the substrate, past its knob surface, are
326//! the substrate's territory.** A parser can describe, in a handful of
327//! bytes, a structure whose in-memory form is far larger, and nothing
328//! outside that parser can observe it happen. A driver can size a pool
329//! however it likes behind a declared extent. Where a substrate offers
330//! no interposition point, a backend outside it has none either.
331//!
332//! **This crate does not promise to be a hypervisor for its
333//! substrates.** It promises tier one, it performs tier two, and it
334//! names tier three rather than papering over it. Bounding the input to
335//! an amplification is achievable and is done — a parser cannot
336//! allocate from bytes it was never handed — but bounding the output is
337//! the substrate's own hardening work.
338//!
339//! **A deployment that needs a hard memory bound places the decode
340//! behind an OS-level instrument**: an address-space or memory rlimit,
341//! a cgroup, or a memory-limited worker process it can restart. That is
342//! the industry answer for `libav*` and for every comparable substrate,
343//! and it is the only instrument that actually bounds a foreign
344//! allocator. The seats in tiers one and two **compose with** it — they
345//! turn most hostile inputs into a named error instead of a killed
346//! worker, and they make the worker's limit a backstop rather than a
347//! first line — but they do not replace it, and a caller who treats
348//! them as a replacement has been told otherwise here.
349
350use core::fmt::Debug;
351
352/// Backend vocabulary for compressed/decoded **video**.
353pub trait VideoAdapter {
354  /// Codec identifier (e.g. backend-specific newtype around
355  /// FFmpeg `AVCodecID`, WebCodecs codec string, etc.).
356  type CodecId: Copy + Eq + Debug;
357  /// Pixel format identifier (e.g. backend-specific newtype around
358  /// FFmpeg `AVPixelFormat`, WebCodecs `VideoPixelFormat`, RAW
359  /// `VideoPixelType`, BRAW `BlackmagicRawResourceFormat`).
360  ///
361  /// `Clone`, not `Copy`, for the same reason
362  /// [`AudioAdapter::ChannelLayout`] is: mediaframe 0.3's
363  /// `PixelFormat` carries an owned `Other(SmolStr)` arm at the
364  /// `alloc` tier, and that is the identifier the FFmpeg and
365  /// WebCodecs adapters bind here.
366  type PixelFormat: Clone + Eq + Debug;
367  /// Backend-specific extras carried on every `VideoPacket` (e.g.
368  /// FFmpeg side-data, WebCodecs metadata).
369  type PacketExtra;
370  /// Backend-specific extras carried on every `VideoFrame` (e.g.
371  /// HDR mastering display, RAW sensor metadata, picture type).
372  type FrameExtra;
373}
374
375/// Backend vocabulary for compressed/decoded **audio**.
376pub trait AudioAdapter {
377  /// Codec identifier.
378  type CodecId: Copy + Eq + Debug;
379  /// Sample format identifier (e.g. FFmpeg `AVSampleFormat`,
380  /// WebCodecs `AudioSampleFormat`).
381  type SampleFormat: Copy + Eq + Debug;
382  /// Channel layout identifier (FFmpeg `AVChannelLayout`,
383  /// WebCodecs raw count, RAW SDK fixed layouts).
384  type ChannelLayout: Clone + Eq + Debug;
385  /// Backend-specific extras carried on every `AudioPacket`.
386  type PacketExtra;
387  /// Backend-specific extras carried on every `AudioFrame`.
388  type FrameExtra;
389}
390
391/// Backend vocabulary for compressed/decoded **subtitles**.
392pub trait SubtitleAdapter {
393  /// Codec identifier.
394  type CodecId: Copy + Eq + Debug;
395  /// Backend-specific extras carried on every `SubtitlePacket`.
396  type PacketExtra;
397  /// Backend-specific extras carried on every `SubtitleFrame`.
398  type FrameExtra;
399}
400
401/// Backend vocabulary for a compressed/decoded **still image** — cover
402/// art, an embedded thumbnail, a poster frame.
403///
404/// A separate vocabulary from [`VideoAdapter`] rather than a reuse of
405/// it, because the two disagree about the one thing an adapter exists
406/// to name: what rides the frame. A still's extras are EXIF, an ICC
407/// profile, an orientation — not a picture type, not a field order,
408/// not a best-effort timestamp. Bending [`VideoAdapter::FrameExtra`]
409/// over both would give every motion frame seats that only a still
410/// fills and every still seats that only motion fills.
411///
412/// The compressed side is an
413/// [`AttachmentPacket`](crate::demuxer::AttachmentPacket): a still
414/// image inside a container is an attachment, one whole file, no
415/// timeline. [`PacketExtra`](Self::PacketExtra) is therefore the
416/// attachment's extras, and a backend that also implements
417/// [`DemuxAdapter`](crate::demuxer::DemuxAdapter) will normally bind
418/// the same type in both seats — the packet a demuxer hands out is the
419/// packet an image decoder is fed.
420pub trait ImageAdapter {
421  /// Codec identifier — the still's own codec (MJPEG, PNG, BMP, …),
422  /// in the same namespace the rest of the backend uses.
423  type CodecId: Copy + Eq + Debug;
424  /// Pixel format identifier.
425  ///
426  /// `Clone`, not `Copy`, for the same reason
427  /// [`VideoAdapter::PixelFormat`] is.
428  type PixelFormat: Clone + Eq + Debug;
429  /// Backend-specific extras carried on the
430  /// [`AttachmentPacket`](crate::demuxer::AttachmentPacket) an image
431  /// decoder is fed.
432  type PacketExtra;
433  /// Backend-specific extras carried on every
434  /// [`ImageFrame`](crate::frame::ImageFrame) — EXIF, ICC profile,
435  /// side data.
436  type FrameExtra;
437}
438
439#[cfg(test)]
440mod tests {
441  use super::*;
442
443  /// Zero-sized "loopback" adapter that implements all three traits
444  /// with `()` extras. Proves the traits are object-safe-ish in the
445  /// associated-type sense (i.e. they can be implemented).
446  pub struct Loopback;
447
448  impl VideoAdapter for Loopback {
449    type CodecId = u32;
450    type PixelFormat = u32;
451    type PacketExtra = ();
452    type FrameExtra = ();
453  }
454
455  impl AudioAdapter for Loopback {
456    type CodecId = u32;
457    type SampleFormat = u32;
458    type ChannelLayout = u32;
459    type PacketExtra = ();
460    type FrameExtra = ();
461  }
462
463  impl SubtitleAdapter for Loopback {
464    type CodecId = u32;
465    type PacketExtra = ();
466    type FrameExtra = ();
467  }
468
469  impl ImageAdapter for Loopback {
470    type CodecId = u32;
471    type PixelFormat = u32;
472    type PacketExtra = ();
473    type FrameExtra = ();
474  }
475
476  #[test]
477  fn loopback_compiles() {
478    // The fact that this test compiles means the four traits
479    // are implementable. No runtime assertions necessary.
480    fn _video<A: VideoAdapter>() {}
481    fn _audio<A: AudioAdapter>() {}
482    fn _subtitle<A: SubtitleAdapter>() {}
483    fn _image<A: ImageAdapter>() {}
484    _video::<Loopback>();
485    _audio::<Loopback>();
486    _subtitle::<Loopback>();
487    _image::<Loopback>();
488  }
489}