mediadecode-ffmpeg 0.8.0

FFmpeg adapter for the `mediadecode` abstraction layer — implements its `VideoAdapter` / `AudioAdapter` / `SubtitleAdapter` traits and the matching push-style decoder traits, with hardware-acceleration auto-probe across VideoToolbox / VAAPI / NVDEC / D3D11VA and software fallback via ffmpeg-next.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
# Changelog

All notable changes to the [`mediadecode-ffmpeg`](https://crates.io/crates/mediadecode-ffmpeg)
crate are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this crate adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

The backend-agnostic core it adapts has its own log at
[`mediadecode/CHANGELOG.md`](../mediadecode/CHANGELOG.md).

## [Unreleased]

## [0.8.0] - 2026-08-24

### Added

- **`FfmpegDemuxer` implements `Demuxer::take_tracks`**, the
  owned-tracks door `mediadecode`'s demux tier gained in place of a
  `TrackInfo` / `TrackParams` `Clone` — see
  [`mediadecode`'s CHANGELOG]../mediadecode/CHANGELOG.md for the
  message-carrier law behind that redirect. The implementation is
  `mem::take` on the session's own `Vec<TrackInfo<Ffmpeg>>`, built once
  at `open` and untouched until a caller takes it.

  `TrackExtra` does not gain `Clone` either, for the same law. An
  interim version of this entry hand-wrote one through the existing
  checked `try_clone`, to satisfy the same channel bound, and it came
  back out — a full `avcodec_parameters_copy` is not the refcount bump
  a message's `Clone` is required to be. `try_clone` and
  `clone_parameters` are unaffected: they remain the checked copy this
  type offers the one caller that genuinely wants an owned duplicate,
  and `Default` remains absent for the reason it always was — there is
  no checked substitute for `Parameters::default()` to route through.

### Changed (BREAKING)

- **Every struct-shaped enum variant across this crate's errors is now
  a tuple variant wrapping a named payload struct**, following
  `mediadecode`'s own `TrackParams` / `DemuxedPacket` /
  `SubtitlePayload` sweep and the shape this crate's own `Error`
  (`HwDeviceInitFailed`, `AllBackendsFailed`, `FallbackFailed`) and
  `FrameError` already used. A struct variant has no nameable type of
  its own to return from `is_<variant>` / `unwrap_<variant>` /
  `try_unwrap_<variant>`, and traps its fields instead of giving them a
  reusable, documented, accessor-bearing home.

  - **`ResampleError`** — all twelve struct arms (`SourceChanged`,
    `PlaneCount`, `SampleCount`, `UnsupportedRate`,
    `UnsupportedFormat`, `UnsupportedLayout`, `TooManyPlanes`,
    `TimestampOutOfRange`, `ChannelDropped`, `RematrixUnsupported`,
    `TimestampOverflow`, `OutputBuffer`) move to `Variant(Payload)`.
    `Again`, `AfterEof`, `Resample(Error)` and `QueueAlloc` are
    unchanged (already unit or newtype).
  - **`PacketBufferError`** — all eight arms (`Refcount`, `Bounds`,
    `SideDataEntries`, `SideDataArray`, `SideDataPayload`,
    `SideDataBytes`, `UnrepresentableFlags`, `SideDataAlloc`) move the
    same way; every payload keeps the enum's own `Copy + Clone + Debug
    + PartialEq + Eq`.
  - **`DemuxError`**`AttachmentAlloc`, `ParametersMissing`,
    `ParametersAlloc`, `ParametersCopy`, `PacketBuffer`, `ReaderPanic`
    move; `Ffmpeg(#[from] ffmpeg_next::Error)` is unchanged.
  - **`boundary::PacketBuildError`**`UnknownSideData` and
    `SideDataAlloc` move. This crate already had *two* same-named
    `SideDataAlloc` payloads in scope at this file (the read-side one
    on `PacketBufferError`, imported here, and this write-side one,
    native to this module) — the import is aliased
    `SideDataAlloc as BufferSideDataAlloc`; the native struct keeps the
    bare name.
  - **`convert::ConvertError`**`UnsupportedPixelFormat`,
    `InvalidPlaneLayout`, `BufferAcquireFailed` move (found during the
    sweep, not in the original census — this enum already violated the
    same rule). This enum hand-writes `Display` rather than using
    `thiserror`; the extracted payloads keep that idiom (their own
    `impl Display`), and the enum's `Display` delegates to it per
    variant.
  - **`video::VideoDecodeError::PostCommitNeverResynced`** — also
    found during the sweep — moves to
    `PostCommitNeverResynced(PostCommitNeverResynced)`. `Decode` and
    `Convert` were already newtype variants.

  Every extracted payload is `thiserror`-derived where the enum already
  was (`#[error(transparent)]` on the wrapper, `#[from]`, the original
  message moved verbatim onto the payload's own `#[error("...")]`) or
  hand-`Display` where the enum already was (`ConvertError`) — no
  crate's error idiom changed, only the variant shape. A match that
  used to destructure fields now binds the payload and reads it through
  accessors, e.g. `Err(ResampleError::PlaneCount { expected, found })`
  becomes `Err(ResampleError::PlaneCount(p))` with `p.expected()` /
  `p.found()`.

  **The accessor face rides the reshape, same as `mediadecode`'s.**
  `ResampleError`, `PacketBufferError`, `DemuxError`,
  `boundary::PacketBuildError`, `convert::ConvertError`,
  `video::VideoDecodeError`, `audio::AudioDecodeError`,
  `subtitle::SubtitleDecodeError`, and `error::Error` — the crate's
  full error taxonomy, not just the arms the reshape sweep touched —
  now derive `derive_more::{IsVariant, Unwrap, TryUnwrap}`, with
  `#[unwrap(ref, ref_mut)]` and `#[try_unwrap(ref, ref_mut)]` — every
  arm answers `is_<variant>()`,
  `unwrap_<variant>()` / `_ref()` / `_mut()`, and
  `try_unwrap_<variant>()` / `_ref()` / `_mut()`. `Backend`,
  `PictureType`, `resampler::SpecEnd`, and `error::FallbackOrigin` gain
  `IsVariant` too; `FallbackOrigin`'s hand-written `is_post_commit()`
  and `ResampleError`'s hand-written `is_again()` are gone — the
  derive answers both now, `&self`-receiver in place of the old
  by-value one (transparent at every call site, all of which already
  used method-call syntax). This crate did not carry a `derive_more`
  dependency before this entry; it does now, with the `is_variant` /
  `unwrap` / `try_unwrap` features (no `display` — this crate's error
  types keep `thiserror` / hand-`Display`, unchanged).

## [0.7.0] - 2026-08-23

### Added

- **`FfmpegDemuxer``mediadecode`'s demux tier over `libavformat`.**
  Opens from a path (`open`) or from any `Read + Seek` byte source
  through a custom `AVIOContext` (`open_reader`). `Seek` is mandatory
  and not negotiable: MP4 routinely puts `moov` at the end, so a reader
  that cannot go backwards cannot be probed at all — and the face's seek
  law would be unimplementable.

  The track table is built once at open, in stream order, so
  `TrackIndex(i)` and `AVStream.index` are the same number by
  construction. Each row carries the stream's `Parameters`, which is
  what opens a decoder for it — a deep copy with no tie back to the
  format context, so a decoder outlives the demuxer that named it.

  **Three normalizations happen here, and they are all about
  attachments.** libavformat presents cover art as a *video* stream
  carrying `AV_DISPOSITION_ATTACHED_PIC`; this layer maps it to
  `TrackKind::Attachment`, so the `Video` arm is true motion video and
  nothing else. A font's bytes never appear in the packet stream at all
  — an `AVMEDIA_TYPE_ATTACHMENT` stream produces no packets and the
  payload lives in codec extradata — so the packet is synthesized at
  open. And cover art's real packet, which libavformat parks in
  `AVStream.attached_pic` *and* some demuxers also emit, is hoisted at
  open and its duplicate dropped. Every attachment track leaves the open
  with exactly one packet queued — or the open fails — before a single
  `av_read_frame` has run, which is what makes "exactly one packet,
  before any timed packet" a property of the construction rather than a
  promise the pull loop has to keep. A stream that declares cover art
  and parks no payload (a state this build's demuxers do not produce:
  `ff_add_attached_pic` sets the disposition and fills the packet in one
  call) still gets its one packet, empty and marked `synthesized`,
  rather than a place in a queue of packets that may never arrive.

  `seek` converts the target to `AV_TIME_BASE` units and seeks over the
  window `[i64::MIN, target]` — FFmpeg's backward convention, landing on
  the nearest keyframe at or before the target. It clears only the EOF
  latch this layer set itself, leaving a genuine sticky I/O error
  intact, and does not touch the attachment bookkeeping: an attachment
  already handed out is never handed out again, and one not yet handed
  out is still owed.

  A track whose kind is `Unknown` has no delivery arm and its packets
  are not delivered; a corrupt packet is skipped and the read resumed,
  since `AVERROR_INVALIDDATA` is not latched into the `AVIOContext`.

  **A panicking reader is an error, not an abort.** libavformat drives
  an `open_reader` byte source from `extern "C"` callbacks, where a
  panic cannot unwind and terminates the process. Every call into the
  caller's reader runs under `catch_unwind`: the panic is latched,
  reported to C as an ordinary I/O error, and surfaced as
  `DemuxError::ReaderPanic` with the panic's own message from the next
  `open_reader`, `next_packet` or `seek`. The session is terminal from
  there. The caught payload is described and then deliberately
  forgotten: `panic_any` takes any `Send` value and safe code can give
  it a `Drop` that panics, and dropping it after the catch would send
  that second panic out of `read`/`seek` and into the `extern "C"`
  callback — the abort this guard exists to prevent, reached through the
  guard itself (measured: SIGABRT). Leaking one value on a path that has
  already made the session terminal is the cheaper half of that trade by
  a distance.

  **Container metadata is read as bytes, not trusted as text.**
  `ffmpeg_next`'s `DictionaryRef::get` builds its `&str` with
  `from_utf8_unchecked`, and FFmpeg does not validate demuxed metadata
  as UTF-8; a track's `filename` / `mimetype` therefore go through
  `av_dict_get` and a bounded walk, with invalid bytes replaced rather
  than trusted.

- **Boundary helpers that carry a timebase.**
  `video_packet_from_ffmpeg_in`, `audio_packet_from_ffmpeg_in`,
  `subtitle_packet_from_ffmpeg_in` and `data_packet_from_ffmpeg_in` take
  the stream's timebase and stamp it onto every timestamp. An
  `AVPacket`'s integers are ticks in a timebase the packet does not
  carry, so the existing four-argument-less helpers stamp `1/1` and
  leave the caller to remember what the ticks meant; a demuxer holds the
  track table and has no reason to forget. The originals are unchanged
  and now delegate. `attachment_packet_from_ffmpeg` joins them for
  cover-art payloads, which have no timestamps to carry.

- **`FfmpegResampler``mediadecode`'s resample seam over
  `swresample`.** `FfmpegResampler::new(source, target)` takes both
  specs as [`ResampleSpec`]s and neither is inferred. The source is read
  off a demuxed track (`ResampleSpec::from_parameters`, the
  "source from `TrackInfo`" path) or off the opened decoder
  (`ResampleSpec::from_decoder`); the target is the caller's options.

  **Output timestamps are counted, not computed.** The timeline is
  anchored on the first *input* timestamp and advanced by the number of
  samples actually produced, so no arithmetic depends on how many
  samples a given `swr_convert_frame` happened to yield and the frames
  drained after `send_eof` continue the same line. The tail is real:
  the 44.1 kHz → 16 kHz lane pins that `send_eof` still has a frame to
  give, which is the difference between a file's last tens of
  milliseconds surviving and not.

  **A frame whose rate, sample format or channel layout is not the
  source spec is refused by name** (`ResampleError::SourceChanged`), and
  "nothing ready yet" is `ResampleError::Again` — the same mechanism
  `AudioStreamDecoder::receive_frame` uses, one tier along.
  `send_frame` after `send_eof` is refused too rather than silently
  accepted; `flush` is the way back to a reusable resampler.

  Two things this layer had to learn about FFmpeg to be correct, both
  recorded where they are relied on. A WAV without a
  `WAVE_FORMAT_EXTENSIBLE` channel mask genuinely declares **no**
  layout, and FFmpeg reports that unspecified layout in the codec
  parameters, in the codec context and on every decoded frame —
  substituting a default would make the source spec disagree with the
  frames it describes and refuse all of them. But `swr_init` *does*
  substitute a default internally, and then compares every frame handed
  to it against that one — so the frames this layer stages carry the
  post-init layout while the mid-stream check keeps comparing against
  the declared one. Custom and ambisonic layouts are refused outright:
  both keep a heap-allocated channel map that a `Copy` layout wrapper
  cannot own safely, and a silent approximation would be worse than a
  refusal. `from_parameters` / `from_decoder` answer `None` for them,
  and — because `ResampleSpec::new` is `const` and total —
  `FfmpegResampler::new` is the choke point that refuses them by name
  (`ResampleError::UnsupportedLayout`), along with a rate of zero or
  past `c_int`, `AV_SAMPLE_FMT_NONE`, and a planar spec with more
  channels than a frame has plane slots
  (`ResampleError::TooManyPlanes` — planar 22.2 is twenty-four planes
  against the model's eight, unusable as a source because no frame
  could carry it and worse as a target because the failure would land
  after `swr` had consumed the input). Nothing hazardous, and nothing
  unusable, reaches `swr` or a staged `AVFrame` whichever route a spec
  came in by.

  **The pair is checked too, not just each end.** Two individually
  valid layouts can still lose whole channels between them: `swr` mixes
  the channel positions its rematrix table knows and processes the rest
  of the input as though it were absent. Measured against the linked
  FFmpeg 9 with a tone isolated in each source channel, packed
  22.2 → mono drops fifteen of twenty-four and `cube` → stereo drops two
  of *eight* — so channel count is not the rule. The rule is asked of
  FFmpeg: `swr_build_matrix2` builds the matrix it would use, and a pair
  where any source channel reaches no output is refused
  (`ResampleError::ChannelDropped`), as is one FFmpeg will not matrix at
  all (`ResampleError::RematrixUnsupported`). LFE's mix level is forced
  non-zero for the question, so FFmpeg's deliberate downmix policy —
  which leaves LFE out — is not mistaken for a channel it cannot carry.
  Accepting such a conversion knowingly needs an explicit mix-matrix
  seat on the spec; until that exists, refusal is the honest answer.

  The pair judged is the **effective** one — the layouts `swr` is
  configured with, resolved before the check rather than after it. An
  unspecified layout becomes FFmpeg's default for its channel count, and
  twenty-four unspecified channels resolve to exactly the 22.2 whose
  explicit conversion is refused, so judging the declared pair left that
  routing a door. This is the second half of the crate's two-layout
  bookkeeping and it composes with the first: the **declared** layout is
  what decoded frames carry and stays the yardstick for the mid-stream
  refusal (*is this frame the stream I was built for?*), while the
  **effective** layout is what `swr`, every staged `AVFrame` and now
  this check use (*what will `swr` actually do?*). A maskless WAV — one
  channel resolving to mono, two to stereo — keeps converting exactly as
  before.

  **Nothing that can fail is left on the far side of the conversion.**
  Every resource a converted frame needs — the output frame, one
  refcounted view per plane, a placeholder for every unused slot, and
  the room in the ready queue — is acquired *before*
  `swr_convert_frame` touches a sample, on the ordinary path and on the
  EOF drain alike; the views are taken at full capacity and trimmed
  afterwards by a shrink that allocates nothing. The step that turns a
  converted frame into a `mediadecode` one is consequently infallible by
  signature, which is what makes the property hold rather than
  hold-for-now: a failure after `swr` has consumed input leaves a
  session no caller can act on, since retrying feeds the same samples
  twice and continuing loses them.

  **State is committed after the work it describes succeeds.** A frame
  refused for its geometry does not anchor or advance the output
  timeline; timestamps are counted with checked arithmetic and an
  overflow is `ResampleError::TimestampOverflow` — raised *before*
  `swr` consumes the frame, checked against the most samples the call
  could produce, so a refused conversion leaves a session the caller can
  still use (the check used to run after the conversion, which left the
  filter's history moved, an output frame built and dropped, and the
  timeline anchored where every later frame overflowed too) — rather
  than `i64::MAX` repeated; the anchor itself is rescaled with the checked rung before
  anything is staged, so a timestamp that cannot land on the output
  timeline is `ResampleError::TimestampOutOfRange` with the resampler
  untouched, rather than a clamp — a positive clamp used to surface only
  after `swr` had consumed the input, and a negative one landed on
  `i64::MIN`, which is `AV_NOPTS_VALUE`, erasing the timestamp instead
  of reporting it; plane geometry is settled in checked arithmetic *before* any
  allocation is sized from a frame header, and every allocation is
  checked (`frame::Audio::new` dereferences `av_frame_alloc` unchecked
  and discards `av_frame_get_buffer`'s return, so this crate allocates
  its audio frames through a local helper that does neither). `flush`
  rebuilds the `swr` context rather than draining it: a drain that gave
  up and a drain that finished are indistinguishable from outside, and a
  reset that reports success must not leave the previous stream's tail
  inside the filter.

- **`resample` feature, on by default.** `FfmpegResampler`,
  `ResampleError`, `ResampleSpec`, `SpecEnd` and the
  `ffmpeg-next/software-resampling` link they need move behind it, so a
  decode-only consumer building `--no-default-features --features std`
  drops `libswresample` from the link line entirely rather than merely
  not calling into it. The core `mediadecode::resampler` trait module
  stays ungated — it adds no dependency of its own, backend or
  otherwise, over what the crate already compiles unconditionally.

- **`SampleFormat::to_ffmpeg` / `SampleFormat::from_ffmpeg`** — the
  direction this newtype was missing. A raw sample-format integer read
  out of a container cannot be cast back into the bindgen enum to reach
  FFmpeg's safe API, so `to_ffmpeg` matches it against compile-time
  constants instead, exactly as `boundary::from_av_pixel_format` comes
  the other way.

- **`DataPacketExtra`, `AttachmentPacketExtra`, `TrackExtra`** — the
  demux tier's `*Extra` carriers, and `impl DemuxAdapter for Ffmpeg`
  binding them. `AttachmentPacketExtra::synthesized` records whether a
  payload came from a real packet or was built out of codec extradata,
  which is the first thing to check when an attachment looks wrong.
  `TrackExtra::disposition` is the raw `AV_DISPOSITION_*` bit set rather
  than `ffmpeg_next`'s `Disposition`, whose `from_bits_truncate` would
  drop bits this build has no constant for.

### Fixed

- **A sparse thumbnail track is a timed track, not one attachment.**
  Classification tested `AV_DISPOSITION_ATTACHED_PIC` alone, and FFmpeg
  documents `AV_DISPOSITION_TIMED_THUMBNAILS` — "the stream is sparse,
  and contains thumbnail images, often corresponding to chapter
  markers" — as *only ever* used together with it. Such a stream was
  therefore read as cover art: the exactly-once attachment contract
  queued the parked copy and the delivery loop dropped every timestamped
  thumbnail after it. The two bits are now read off the raw
  `AVStream.disposition` (`ffmpeg_next`'s `Disposition` mints no
  `TIMED_THUMBNAILS` constant, and its `from_bits_truncate` drops what
  it cannot name — which is how the distinction went missing), and a
  timed-thumbnail stream goes to the **`Video`** arm. That does not
  bend "cover art is an attachment, not video": the reason behind that
  ruling is that a still with no timeline must not look like a motion
  track, and this stream *is* on the timeline — sparse video, with a
  codec id, a frame size and packets that carry timestamps.

- **A packet's side data arrives with the packet, and a side-data-only
  packet is no longer mistaken for an empty marker.** The `*Extra`
  carriers have always documented a `side_data` seat and the conversion
  never filled one; measured on this repository's own generated corpus,
  every container carries at least one packet with real side data
  (`AV_PKT_DATA_SKIP_SAMPLES` — the encoder-delay trim an MP3 or AAC
  stream must be cut by), so that data was dropped at the boundary on
  every file. Worse, a packet with **no body** and only side data —
  FFmpeg's shape for `AV_PKT_DATA_NEW_EXTRADATA` and for a parameter
  change — read as "empty, skip it", so a decoder could be left running
  on parameters the container had already replaced, with nothing said.

  All four timed arms now collect side data — **whole, or not at all**.
  The collection is bounded (64 entries or as many as this build names,
  whichever is larger; 256 KiB; `try_reserve_exact`), and every bound is
  an **error**, not a truncation: a packet whose side data cannot be
  carried complete is refused by name
  (`PacketBufferError::SideDataEntries` / `SideDataBytes` /
  `SideDataAlloc`, surfacing as `DemuxError::PacketBuffer`) rather than
  delivered missing the entries a decoder acts on. Truncating would put
  the original defect back twice over: a body-bearing packet reaching
  the codec without its `NEW_EXTRADATA`, and a side-data-only packet
  losing its only content and vanishing as an empty marker. The arms
  also deliver a side-data-only packet with an owned empty buffer.

  The same rule reaches the pointers, not only the caps: a count is
  judged before the array it describes, so a malformed or over-cap count
  is refused whether or not the array happens to be null
  (`SideDataArray` names the missing array), and an entry declaring
  bytes it does not carry is refused (`SideDataPayload`) rather than
  read as an empty entry that charges the budget nothing. A zero-size
  entry is still a marker and still welcome.
  `SubtitlePacketExtra` and `DataPacketExtra` gained the seat the other
  two already had. `AttachmentPacketExtra` deliberately did not: an
  attachment is its bytes, so a packet carrying none carries no
  attachment, and that arm still answers `Ok(None)`.

  **And the reverse direction reattaches it**, which is what makes the
  capture worth anything: the three `ffmpeg_packet_from_*_packet`
  helpers are what the trait decoders hand to the codec, and they
  rebuilt a packet from body and timestamps alone. Measured end to end
  on `cover.mp3`: with the trim reattached the decoder returns 88 200
  samples — exactly the two seconds the file holds — and without it
  89 856, the encoder's padding included. A side-data type this build of
  FFmpeg does not name is refused
  (`PacketBuildError::UnknownSideData`) rather than dropped or handed to
  C as an invalid discriminant.

- **Every packet flag survives both directions.** Forward conversion
  went through `ffmpeg_next`'s `Packet::flags()`, whose `Flags` bit set
  names `KEY` and `CORRUPT` and truncates the rest away before this
  crate sees them; the reverse rebuilt only those two. `PacketFlags` is
  a bit set whose documented lossless door is `from_bits_retain`, so
  both directions were breaking its contract — and losing
  `AV_PKT_FLAG_DISCARD`, which tells a consumer to decode a packet and
  throw its output away, makes preroll output look like something to
  keep. `AVPacket.flags` is now read and written as the raw integer it
  is, so `DISCARD`, `TRUSTED`, `DISPOSABLE` and the bits nothing names
  yet all round-trip. A compile-time assertion states that every flag
  this build names fits the byte `PacketFlags` carries; a packet
  carrying one that does not is refused
  (`PacketBufferError::UnrepresentableFlags`) rather than delivered a
  bit short. The hoisted cover-art packet reads its flags through the
  same reader: it is built by hand from `AVStream.attached_pic` rather
  than through the boundary conversion, and it used to be built with
  none at all — FFmpeg marks an attached picture `AV_PKT_FLAG_KEY`, so
  every cover this crate delivered arrived saying it was not a
  keyframe. A synthesized attachment still carries no flags, because
  no packet was parked to read them from.

- **`TrackExtra` no longer derives `Clone` or `Default`, and the
  decoder handoff is checked.** Both derives went through
  `ffmpeg_next`'s `Clone` / `Default` for `Parameters`, so copying a
  track row from safe public code reached the same unchecked
  allocation — measured, a SIGSEGV — and the documented handoff was
  `parameters().clone()`, which is that same clone. `Clone` cannot
  report a failure, so the type does not implement it:
  `TrackExtra::try_clone` is the row copy with an answer, and
  `TrackExtra::clone_parameters` is the handoff that opens a decoder.
  `parameters()` still lends the parameters for inspection —
  `ResampleSpec::from_parameters` reads them — but nothing in this crate
  asks a caller to clone them unchecked any more.

  **And a `TrackExtra` cannot exist over parameters that were never
  allocated.** `Parameters::new()` / `Default` hand back a null-backed
  value when `avcodec_parameters_alloc` failed and report nothing, so
  checking only the destination of a copy left the source trusted: once
  the allocator recovered, `avcodec_parameters_copy(out, NULL)`
  dereferenced null from safe public code. `TrackExtra::new` is
  therefore fallible and refuses one
  (`DemuxError::ParametersMissing`), which gives the type a non-null
  invariant from birth, and the copier checks its source as well —
  belt and braces, because the invariant is a promise and the check is
  a fact. `ResampleSpec::from_parameters` and `from_decoder` grew the
  same guard: the first used to ask `medium()` on the way in, which
  dereferences the pointer inside ffmpeg-next before any code of this
  crate runs.

- **A track's codec parameters are copied with both fallible steps
  checked.** `ffmpeg_next`'s `Clone` for `Parameters` checks neither:
  `Parameters::new` does not test `avcodec_parameters_alloc` for null
  and `clone_from` dereferences it immediately — measured, that is a
  SIGSEGV under a failed allocation — while the copy's return value is
  discarded, so a partial copy (a failed extradata allocation, say)
  produced parameters that look complete and open a decoder wrong.
  Every track in the table goes through this, so it is a whole session
  built on parameters that are not the file's. A local helper checks
  both legs and reports `DemuxError::ParametersAlloc` /
  `ParametersCopy`; a partial copy is freed on the way out.

### Changed (BREAKING)

- **Wrapping a packet's payload answers `Result<Option<_>>`.**
  `FfmpegBuffer::from_packet`, `video_packet_from_ffmpeg`,
  `audio_packet_from_ffmpeg`, `subtitle_packet_from_ffmpeg`, the four
  `*_packet_from_ffmpeg_in` variants and `attachment_packet_from_ffmpeg`
  now return `Result<Option<_>, PacketBufferError>`.

  `Ok(None)` is a packet that carries no payload — the empty marker some
  demuxers emit, and the only thing a pull loop may skip. An `Err` is a
  payload that *is* there and could not be carried: `av_buffer_ref`
  refusing a reference under memory pressure, or a packet whose payload
  does not lie inside its own buffer. The single `None` these used to
  share made the second look like the first, so a demuxer under memory
  pressure dropped real compressed bytes and read on as though the file
  had said so. `FfmpegDemuxer::next_packet` surfaces the failure as
  `DemuxError::PacketBuffer`, naming the stream.

  Call sites that skipped `None` add one `?` or an `expect`; nothing
  else changes.

- **The three `ffmpeg_packet_from_*_packet` helpers answer
  `Result<Packet, PacketBuildError>`.** They can now fail for a reason
  `ffmpeg_next::Error` cannot spell: a side-data entry whose type this
  build of FFmpeg does not name, or whose allocation failed.
  `Error::PacketBuild` carries it into the decoder error types.

## [0.6.0] - 2026-08-21

### Added

- **A layout that falls off the constant table now gets one more
  chance: FFmpeg's own name for it.** `channel_layout_from_ffmpeg`
  keeps its constant-arm table as the first and authoritative rung —
  a mapped constant is answered there, byte for byte, and renders
  nothing and parses nothing. Only on a miss does the adapter ask
  `av_channel_layout_describe` what FFmpeg calls the layout and read
  that word through `mediaframe::audio::ChannelLayout`'s `FromStr`,
  the total door the vocabulary already exposes.

  A named variant wins; anything the vocabulary does not name still
  lands on the absent sentinel with FFmpeg's rendering in `text()`.
  The rendering never rides `known_kind`'s `Other` escape, so
  "unrecognised" stays one value rather than one per spelling.

  What the rung reaches that the table cannot: `binaural`, `5.1.2`
  and `9.1.6` — all three named by `ChannelLayout`, none of them
  minted as an `ffmpeg_next` constant — plus FFmpeg 9's own `5.1.4`,
  whose side-surround mask `ffmpeg_sys_next`'s bundled
  `channel_layout_fixed.h` misses, its
  `AV_CH_LAYOUT_5POINT1POINT4_BACK` still carrying FFmpeg 8's
  back-surround formula. And, from here on, any layout a later
  FFmpeg adds that the vocabulary already names, with no edit in
  this crate: FFmpeg speaks the name, the vocabulary reads the word,
  one source.

  On the `ChannelLayoutDescription` path the layout is described
  once and that single rendering feeds both `known_kind` and
  `text`, so the two can never answer from different FFmpeg calls.

### Changed (BREAKING)

- **`channel_layout`'s conversions produce `mediaframe::audio` types,
  and are named after them.** `mediadecode::channel` is gone (see
  [`mediadecode`]../mediadecode/CHANGELOG.md#unreleased); the
  vocabulary lives upstream now, and the `Kind` ornament is buried with
  the enum it named.

  | was | is | returns |
  | --- | -- | ------- |
  | `channel_layout_kind_from_ffmpeg` | `channel_layout_from_ffmpeg` | `ChannelLayout` |
  | `audio_channel_order_kind_from_ffmpeg` | `channel_order_from_ffmpeg` | `ChannelOrder` |
  | `audio_channel_order_kind_from_raw` | `channel_order_from_raw` | `ChannelOrder` |
  | `audio_channel_layout_from_ffmpeg` | `channel_layout_description_from_ffmpeg` | `ChannelLayoutDescription` |
  | `audio_channel_layout_from_raw_ptr` | `channel_layout_description_from_raw_ptr` | `ChannelLayoutDescription` |

  The bodies are unchanged: same raw-pointer discipline (`order` is
  read as `i32` before any `AVChannelOrder` value is formed), same
  union guards, same `av_channel_layout_describe` buffer growth, same
  UTF-8-lossy label decoding. Only the target vocabulary moves.
  FFmpeg's own `ChannelLayout` is imported as `AvChannelLayout` inside
  the module so the plain name belongs to what the functions produce.

- **`AudioAdapter::ChannelLayout` and the `AudioFrame` alias bind
  `mediaframe::audio::ChannelLayoutDescription`.** A caller that stored
  a decoded layout in a type of its own re-spells that type; a caller
  that only passed frames along needs nothing. The layout's *name* now
  arrives as `ChannelLayout` rather than `ChannelLayoutKind`, the
  absent case is `ChannelLayout::default()` (the `Other("")` sentinel)
  rather than `ChannelLayoutKind::Unknown`, and FFmpeg's rendering is
  read back with `text()` where it was `description()`.

- **Three layout arms answer to different idents, and one is gone.**
  Upstream's idents follow FFmpeg's constants except where a constant
  names an arrangement no reader would recognise, so `SURROUND` is
  `Ch3_0`, `AV_CH_LAYOUT_2_1` is `Ch3_0Back` and `AV_CH_LAYOUT_2_2` is
  `QuadSide`. The `_7POINT1_TOP_BACK` arm is deleted outright:
  `ffmpeg-next` defines that constant as an alias of
  `AV_CH_LAYOUT_5POINT1POINT2_BACK`, which this table already matches
  twelve arms earlier, so the arm could never be reached and the
  variant it produced does not exist upstream.

  Layouts the table still cannot name — `BINAURAL`, `_5POINT1POINT2`
  (the side one) and `_9POINT1POINT6`, all three named by
  `mediaframe::audio::ChannelLayout` — have no `ffmpeg_next`
  `ChannelLayout` constant to match against. The describe rung above
  reaches them all the same; without it they would keep arriving as
  the absent sentinel with FFmpeg's spelling preserved in `text()`,
  exactly as before.

- **`mediaframe` is a direct dependency**, pinned with `alloc`: the
  audio household is compiled only at the alloc-or-std tier.

## [0.5.0]

Tracks `mediadecode` 0.5.0, which crosses `mediaframe` 0.4 → 0.5 — a
breaking minor. `mediaframe` is a public dependency of the core crate
and its `PixelFormat` and `color` types appear in this adapter's own
signatures (`convert`, `pixdesc`, `frame`), so this adapter's
re-exported surface moves with it. See
[`mediadecode` 0.5.0](../mediadecode/CHANGELOG.md#050).

Nothing about the FFmpeg boundary's behaviour changes, and no source
line moved. The version bump is the public-dependency crossing, not a
change of conduct: `mediaframe` 0.5 adds no pixel-format or colour
variant, so this crate's exhaustive maps over `PixelFormat` — which
would have been `E0004` had one appeared — are unchanged, and the
FFmpeg constant tables still round-trip.

Two upstream conveniences arrive for free: the re-exported vocabularies
carry `ROSTER`, and `BayerPattern` is closed, so a downstream matching
it can drop its wildcard arm.

## [0.4.0] - 2026-08-19

### Changed (BREAKING)

- **`convert::ConvertError::UnsupportedPixelFormat` names the format
  again.** It was `UnsupportedPixelFormat(PixelFormat)`; it is now a
  struct variant carrying `format`, `raw` and `name`. The `format`
  field is the old payload unchanged — still `PixelFormat::None` for
  the fall-through, because this restores the *diagnostic*, not the
  `Unknown(u32)` variant mediaframe 0.3 struck. What is restored is
  everything the message lost with it:
  - `raw: i32` — the `AVFrame.format` integer exactly as FFmpeg wrote
    it, present unconditionally.
  - `name: Option<SmolStr>` — FFmpeg's own name for that integer, from
    `av_get_pix_fmt_name`, or `None` when libavutil has no descriptor
    for it.

  The rendered message goes from `unsupported pixel format None` back
  to `unsupported pixel format None (AVPixelFormat <n> =
  "videotoolbox_vld")`, and to `… (AVPixelFormat 99999, unnamed by
  libavutil)` where there is no name. This supersedes the note under
  0.4.0 below, which recorded the message losing the raw integer.

  Callers matching `ConvertError::UnsupportedPixelFormat(pf)` move to
  `ConvertError::UnsupportedPixelFormat { format, .. }`.

  The lookup does not weaken the crate's FFI stance. `av_get_pix_fmt_name`
  is redeclared with a plain `c_int` parameter rather than the bindgen
  enum, so an integer outside our build's discriminant set is never
  turned into an `AVPixelFormat` — which is the whole reason the
  fall-through exists in the first place. Two tests pin that libavutil
  answers such integers with null rather than misbehaving.

Tracks `mediadecode` 0.4.0, which crosses `mediatime` 0.1 → 0.3 and
`mediaframe` 0.1 → 0.3 — two breaking minors each. Both are public
dependencies of the core crate, so this adapter's re-exported
signatures move with them. See
[`mediadecode` 0.4.0](../mediadecode/CHANGELOG.md#040). This release
also crosses `ffmpeg-next` 8.1 → 9. Nothing about the FFmpeg
boundary's *behaviour* changes: the same raw integers are accepted,
the same formats are deliverable, and the same frames are rejected.

### Changed (BREAKING)

- **`ffmpeg-next` 8.1 → 9**, tracking FFmpeg 9. `ffmpeg_next::Packet`,
  `Frame`, `Error`, `decoder::Audio` and `decoder::Subtitle` appear in
  this crate's public signatures, so `ffmpeg-next` is a public
  dependency and downstream crates must move to the same major.
  No adapter source changed: the bump is spelling-clean, and the
  boundary's behaviour is unaffected. What it buys is the
  `ffmpeg_9_0` code path — `ffmpeg-sys-next` 8.1's version table
  topped out at `ffmpeg_8_1` and gated 9.0 off even when linked
  against a 9.x system library. Two surfaces moved in FFmpeg 9 but do
  not reach this crate: capability queries (`Audio`/`Video`'s
  `rates` / `formats` / `channel_layouts`) now read
  `avcodec_get_supported_config` instead of the codec struct's
  fields, and none of them are called here; and the codec-id
  vocabulary dropped `V308` / `V408` / `V410` while adding
  `WEBP_ANIM` / `APPLE_APAC`, which `CodecId` absorbs as a
  `#[repr(transparent)]` `i32` with a fall-through `Debug` arm. The
  pixel formats this crate already mapped to `None` for want of an
  `AV_PIX_FMT_*` constant (`V210`, `V410Le`, `Yuva420p12Le`,
  `Yuva444p14Le`) are still absent in 9, so their fall-through
  stands.
- **The pixel-format fall-through is now `PixelFormat::None`.**
  `boundary::from_av_pixel_format` returned
  `PixelFormat::Unknown(raw as u32)` for a raw `AVFrame.format`
  integer with no mapping; mediaframe 0.3 struck that variant, so the
  raw integer no longer rides along in the returned value.
  `PixelFormat::None` is a *named* member of the vocabulary (FFmpeg's
  own `AV_PIX_FMT_NONE`, and the `Default`), which is also now the
  exact answer for `AV_PIX_FMT_NONE` itself. Callers matching
  `PixelFormat::Unknown(_)` switch to `PixelFormat::None`; callers
  that read the payload have the raw integer at the call site, where
  `is_hardware_pix_fmt` already reads it. Rejection is unchanged —
  `pixdesc::to_av_pixel_format`, `pixdesc::is_deliverable`,
  `frame::is_supported_cpu_pix_fmt` and both geometry tables refuse
  the fall-through exactly as before. `boundary::empty_video_frame` /
  `try_empty_video_frame` fill the same placeholder.
- **`Timebase` construction is signed.** `mediatime` 0.2 made
  `Timebase`'s numerator and denominator `i32` / `NonZeroI32`, so the
  README example, `examples/decode_via_trait.rs`, and the three
  integration tests drop their `as u32` casts on
  `ffmpeg::Rational::numerator()` /
  `.denominator()` — which are C `int`s to begin with. A stream
  declaring a negative numerator now panics in `Timebase::new`
  instead of wrapping to a huge `u32`.

### Changed

- **`mediadecode` dep**: bumped to `0.4`.
- Crate-internal pixel-format predicates and tables now borrow rather
  than consume, since `mediaframe::PixelFormat` is no longer `Copy`:
  `pixdesc::{is_deliverable, plane_geometry, to_av_pixel_format}`,
  `frame::{is_supported_cpu_pix_fmt, plane_row_bytes_for,
  plane_height_for}` and `convert::{is_yuvj, map_range_for}` take
  `&PixelFormat`. All are `pub(crate)` or private — no public
  signature moves.
- `convert::ConvertError::UnsupportedPixelFormat` still carries the
  format by value; the two construction sites move it in after the
  borrowing checks, so no clone was introduced. Its rendered message
  changes for the fall-through case, from
  `unsupported pixel format Unknown(119)` to
  `unsupported pixel format None` — the raw integer is no longer in
  that log line.

[0.4.0]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.4.0

## [0.3.3] - 2026-06-25

Every byte-addressable CPU pixel format FFmpeg can produce now decodes
and delivers ([#15](https://github.com/findit-studio/mediadecode/pull/15)).
The hand-maintained per-format geometry table is gone from the decode
path; libavutil's own descriptor is the authority.

### Added

- **`pixdesc` module.** Per-plane geometry — visible row bytes and row
  count — is derived from `av_image_fill_linesizes` and
  `av_image_fill_plane_sizes` for the exact `(format, width, height)`,
  so it is correct by construction for any format FFmpeg can describe
  rather than for the formats someone remembered to tabulate. The
  safety stance is unchanged: an `AVPixelFormat` is never constructed
  from a runtime integer, only mapped from a recognised `PixelFormat`
  to a compile-time `AV_PIX_FMT_*` constant.

### Fixed

- **The YUVJ family decodes.** `yuvj420p` / `yuvj422p` / `yuvj440p` /
  `yuvj444p` / `yuvj411p` previously fell through to `Unknown` and were
  rejected at convert; they now map, deliver, and carry their JPEG
  range on `ColorInfo::range`. This is the real-world fix for MJPEG and
  JPEG-range footage.
- **`boundary::from_av_pixel_format` covers 251 formats**, up from 63.

### Changed

- **Deliberate exclusions**, by descriptor flag rather than by omission:
  Bayer mosaics (a demosaic question, not a geometry one), GPU surface
  formats (the hardware path transfers to a CPU format first), paletted,
  monochrome and sub-byte-packed RGB. All are rejected up front by
  `pixdesc::is_deliverable` with a layout the crate can state, instead
  of being read as `linesize × height` bytes of guesswork.
- Four formats have no constant in the linked `ffmpeg-sys-next` 8.1 and
  so still fall through: `V210`, `V410LE`, `YUVA420P12LE`,
  `YUVA444P14LE`.

> Downstream note carried from the PR: this crate now delivers formats
> the colconv resample layer did not yet handle at the time, which
> decode and then fail at resample. That is not a regression — they
> failed earlier, at decode, before this release — and closing the gap
> is colconv-side work.

[0.3.3]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.3.3

## [0.3.2] - 2026-06-24

Hardware decode that collapses **after** the probe has committed now
falls back to software instead of failing the stream
([#13](https://github.com/findit-studio/mediadecode/pull/13)).

### Added

- **`FallbackOrigin`** (`Probe` / `PostCommit`) on the
  `AllBackendsFailed` payload, with `AllBackendsFailed::origin()` and
  `AllBackendsFailed::new_post_commit()`. The wrapper routes its
  software-fallback replay on this explicit signal rather than inferring
  the origin from whether `unconsumed_packets` is empty — both origins
  can be empty (a probe-era failure on the very first packet has no
  history to surface either), so emptiness cannot tell them apart.
  Conflating the two made a probe-era first-packet cap trip look
  post-commit, and the packet could be dropped in silence.

### Fixed

- **Runtime HW failure no longer ends the stream.** When the committed
  backend fails after the probe has collapsed, the decoder opens a
  software decoder cold, forwards the failing call's packet (or EOF),
  and resyncs at the next keyframe — a bounded, logged gap rather than
  a dead stream. Probe-era failures keep the previous behaviour: the
  buffered history is replayed and the current packet routed on.

[0.3.2]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.3.2

## [0.3.1] - 2026-06-14

Additive release ([#10](https://github.com/findit-studio/mediadecode/pull/10)).

### Added

- **`Clone` on the decode-path error types**`Error`,
  `AllBackendsFailed`, `FallbackFailed`, `AudioDecodeError` and
  `ConvertError` — so a consumer can forward one error event to several
  per-stream subscribers. Every payload was already cheaply clonable:
  `ffmpeg_next::Error` is `Copy`, `ffmpeg_next::Packet` is `Clone`, and
  `Backend` is `Copy`. `mediadecode::AudioFrame` gains `Clone` in the
  same release — see
  [`mediadecode` 0.3.1]../mediadecode/CHANGELOG.md#031---2026-06-14.

[0.3.1]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.3.1

## [0.3.0] - 2026-06-07

Tracks `mediadecode` 0.3.0, which flips the shared vocabulary crate from
`videoframe` 0.2 to `mediaframe` 0.1
([#7](https://github.com/findit-studio/mediadecode/pull/7),
[#8](https://github.com/findit-studio/mediadecode/pull/8)). See
[`mediadecode` 0.3.0](../mediadecode/CHANGELOG.md#030---2026-06-07) for
what moved in the vocabulary itself.

### Changed (BREAKING)

- **`mediadecode` dep**: bumped to `0.3`. The re-exported vocabulary
  types are `mediaframe`'s now, so this adapter's type aliases and
  signatures carry the new identity.
- **Two colour-transfer mappings are renamed**, tracking upstream:
  `ColorTransfer::Bt470M``Gamma22` and `Bt470Bg``Gamma28`. The
  FFmpeg wire mapping is untouched — the same `AVCOL_TRC_GAMMA22` /
  `AVCOL_TRC_GAMMA28` land on the same values under their new spelling.

### Changed

- Version bumped to 0.3.0 with the rest of the workspace.

[0.3.0]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.3.0

## [0.2.0] - 2026-05-15

Tracks `mediadecode` 0.2.0. The pixel-vocabulary types
(`PixelFormat`, color enums, frame primitives) now live in the
`videoframe` crate and are re-exported through `mediadecode`; this
release adapts the FFmpeg boundary to the new `PixelFormat::Unknown(u32)`
shape and updates the type aliases the crate re-exports.

### Changed (BREAKING)

- **`PixelFormat::Unknown` shape**: re-exported `PixelFormat` is now
  `Unknown(u32)` (tuple variant) instead of the prior unit variant
  — see [`mediadecode` 0.2.0]../mediadecode/CHANGELOG.md#020---2026-05-15.
- **FFmpeg boundary fallback** now preserves the raw `AVPixelFormat`
  identifier through `PixelFormat::Unknown(raw as u32)` instead of
  collapsing to a bare `Unknown`. Round-trips losslessly via
  `PixelFormat::{from_u32, to_u32}`.
- **Type aliases reshape**: `VideoFrame`, `AudioFrame`,
  `SubtitleFrame`, `VideoPacket`, `AudioPacket`, `SubtitlePacket`
  inherit the upstream `PixelFormat` shape change. Downstream
  callers matching on `Unknown` in destination frames need to
  switch to `Unknown(_)`.
- **`Error` enum variants** are now newtype-tuple form wrapping
  payload structs (matches the convention in
  [`videoframe`]https://crates.io/crates/videoframe). Affected
  variants: `HwDeviceInitFailed`, `AllBackendsFailed`,
  `FallbackFailed`. Pure tuple variants (`Ffmpeg`, `NoCodec`,
  `BackendUnsupportedByCodec`) unchanged. Callers destructuring
  `Err(Error::AllBackendsFailed { attempts, .. })` must switch to
  `Err(Error::AllBackendsFailed(p))` and call `p.attempts()` /
  `p.unconsumed_packets()`. Owning-move paths for the rescued
  packets are preserved via `p.into_unconsumed_packets()` /
  `p.into_parts()`, so non-seekable callers can still relinquish
  the `Vec<Packet>` without cloning. The hand-written `Debug` that
  printed `[N packets]` (because `ffmpeg_next::Packet` has no
  `Debug`) now lives on the payload structs.
  All three new variants also carry `#[from]`, joining `Ffmpeg`
  which already had it — so `impl From<HwDeviceInitFailed> for Error`,
  `impl From<AllBackendsFailed> for Error`, and
  `impl From<FallbackFailed> for Error` are auto-generated, and
  helpers returning `Result<_, HwDeviceInitFailed>` etc. can be
  `?`-propagated into `Result<_, Error>` directly.

### Changed

- **`mediadecode` dep**: bumped to `0.2`.
- Boundary mapping in `pixel_format_from_ffmpeg` and the
  side-data conversion paths updated to the new
  `PixelFormat::Unknown(u32)` shape (17 fallback / assertion /
  default-frame sites across `mediadecode-ffmpeg` and
  `mediadecode-webcodecs`).

### Added

- **`Debug` impl for `Frame`** — manual `core::fmt::Debug` impl
  showing dimensions / format so the only public type previously
  without `Debug` is now printable.
  Closes [issue #4 — finding 2]https://github.com/findit-studio/mediadecode/issues/4.
- **`#[must_use]`** on every consuming `with_*` builder method
  across the crate's public surface.
  Closes [issue #4 — finding 3]https://github.com/findit-studio/mediadecode/issues/4.

[0.1.0]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.1.0
[0.2.0]: https://github.com/findit-studio/mediadecode/releases/tag/mediadecode-ffmpeg-v0.2.0

## [0.1.0] - 2026-05-09

Initial public release.

### Added

- **Adapter type.** `Ffmpeg` zero-sized type implementing
  `mediadecode::adapter::VideoAdapter`, `AudioAdapter`, and
  `SubtitleAdapter`.
- **Buffer.** `FfmpegBuffer` — zero-copy refcounted view over an
  `AVBufferRef`, with `empty` / `from_packet` / `from_plane`
  constructors and panic-free `try_*` counterparts.
- **Video decoder.** `FfmpegVideoStreamDecoder` mirrors
  `ffmpeg::decoder::Video`'s `send_packet` / `receive_frame` shape and
  auto-probes the host's HW backends — VideoToolbox on Apple,
  VAAPI / CUDA on Linux, D3D11VA / CUDA on Windows — falling through
  to a software decoder when none open. `open_with(_, _, Backend::…)`
  pins a specific backend (no probe).
- **Audio decoder.** `FfmpegAudioStreamDecoder` over
  `ffmpeg::decoder::Audio`, producing zero-copy `AudioFrame`s.
- **Subtitle decoder.** `FfmpegSubtitleStreamDecoder` over the legacy
  synchronous `ffmpeg::decoder::Subtitle::decode` API, bridged into the
  trait's `send_packet` / `receive_frame` shape.
- **Type aliases.** `VideoPacket`, `AudioPacket`, `SubtitlePacket`,
  `VideoFrame`, `AudioFrame`, `SubtitleFrame` — pre-parameterized with
  this crate's adapter, buffer, and extras types.
- **Boundary helpers.** `video_packet_from_ffmpeg`,
  `audio_packet_from_ffmpeg`, `subtitle_packet_from_ffmpeg` — convert a
  borrowed `ffmpeg::Packet` into the matching `mediadecode` packet
  without copying the compressed payload. Empty-frame builders
  `empty_video_frame`, `empty_audio_frame`, `empty_subtitle_frame`
  produce well-formed destinations for `receive_frame`.
- **Recovery.** `VideoDecodeError::AllBackendsFailed { unconsumed_packets, .. }`
  carries any packets the decoder had already accepted from the
  demuxer when every backend is exhausted, so non-seekable callers
  (live streams, pipes, network sources) can replay them through their
  own software decoder without re-demuxing.

### Safety

The FFmpeg FFI surface is hardened against malformed or
version-skewed decoder output:

- All bindgen enum reads go through `addr_of!` + `read_unaligned` to
  avoid creating invalid Rust enum values from raw memory.
- `AVFrameSideDataType` values are mapped through an explicit
  whitelist of known `AV_FRAME_DATA_*` constants — never `transmute`d.
- `CStr::from_ptr` calls are replaced with a bounded
  `bounded_cstr_bytes` helper that searches at most
  `SUBTITLE_MAX_TEXT_BYTES_PER_RECT + 1` bytes for a NUL terminator.
- Signed counts (`AVFrame.nb_side_data`, `AVSubtitle.num_rects`, …)
  are clamped to non-negative values before any `as usize` cast,
  preventing OOB walks under corrupt input.
- Side-data and subtitle conversions enforce caps on entries and total
  bytes (`SIDE_DATA_MAX_ENTRIES`, `SIDE_DATA_MAX_TOTAL_BYTES`,
  `HW_COPY_SIDE_DATA_MAX_*`, `SUBTITLE_MAX_*`).
- `send_packet` consumes the demuxer packet only after the probe
  rescue records it, so a non-seekable caller can rebuild the input
  stream from `unconsumed_packets` on `AllBackendsFailed`.
- `cpu_frame_bytes` sizes against the underlying `AVBufferRef.size`
  rather than `linesize × plane_height_for(AVFrame.height)`, so
  cropped or heavily aligned streams report correct byte counts.