mediadecode-ffmpeg 0.14.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
//! End-to-end audio decoder coverage against every clip in the
//! [`audio-fixtures`][1] submodule. Demuxes each WAV via FFmpeg,
//! drives `FfmpegAudioStreamDecoder` through the trait surface, and
//! asserts the recovered sample stream matches the expected
//! `(sample_rate, channels, samples)` triple from upstream's
//! `manifest.json`.
//!
//! Two codec directories are exercised — `pcm_s16le/` and
//! `pcm_f32le/`. The fixture set is mostly the former; the f32le
//! sub-corpus exists because one upstream clip
//! (`01_dialogue.wav`) ships at 32-bit float depth even though
//! the rest are 16-bit. Both groups are mono 16 kHz; only the
//! sample format and per-clip sample count vary, and both are
//! checked exactly.
//!
//! When the submodule isn't initialized the test returns early
//! with a hint instead of failing — keeps `cargo test` welcoming
//! for contributors who haven't run `git submodule update --init`
//! yet, while CI (always-`submodules: recursive`) exercises the
//! full sweep. The guard asks about a **fixture file**, never
//! about the directory: an uninitialised submodule still has its
//! mount point, so a directory test answers "present" for a tree
//! that holds nothing. See [`probe_fixture`].
//!
//! [1]: https://github.com/findit-studio/audio-fixtures

use std::{num::NonZeroI32, path::PathBuf};

use ffmpeg_next as ffmpeg;
use mediadecode::{Received, Sent, Timebase, decoder::AudioStreamDecoder};
// The owned family under the names this suite was written with — the
// bare aliases mean the view lane now. Import block only; the
// assertions below are unchanged.
use mediadecode_ffmpeg::{
  FfmpegOwnedAudioStreamDecoder as FfmpegAudioStreamDecoder,
  empty_owned_audio_frame as empty_audio_frame,
};

/// One row per fixture: `(directory, file, sample_rate, channels,
/// expected_samples)`. Kept hard-coded rather than parsed at runtime
/// so a change in `audio-fixtures/manifest.json` (a new file, a
/// re-trim, a re-encode) trips the assertion path rather than
/// silently passing. Update both sides when adding a fixture.
const FIXTURES: &[(&str, &str, u32, u8, u64)] = &[
  // --- pcm_s16le/ ---
  ("pcm_s16le", "02_pyannote_sample.wav", 16_000, 1, 480_000),
  ("pcm_s16le", "03_dual_speaker.wav", 16_000, 1, 960_000),
  ("pcm_s16le", "04_three_speaker.wav", 16_000, 1, 639_573),
  ("pcm_s16le", "05_four_speaker.wav", 16_000, 1, 960_000),
  ("pcm_s16le", "06_long_recording.wav", 16_000, 1, 15_643_627),
  (
    "pcm_s16le",
    "07_yuhewei_dongbei_english.wav",
    16_000,
    1,
    404_213,
  ),
  (
    "pcm_s16le",
    "08_luyu_jinjing_freedom.wav",
    16_000,
    1,
    22_675_308,
  ),
  (
    "pcm_s16le",
    "09_mrbeast_dollar_date.wav",
    16_000,
    1,
    16_671_744,
  ),
  (
    "pcm_s16le",
    "10_mrbeast_clean_water.wav",
    16_000,
    1,
    9_911_979,
  ),
  (
    "pcm_s16le",
    "11_mrbeast_age_race.wav",
    16_000,
    1,
    22_568_310,
  ),
  ("pcm_s16le", "12_mrbeast_schools.wav", 16_000, 1, 15_426_781),
  (
    "pcm_s16le",
    "13_mrbeast_saved_animals.wav",
    16_000,
    1,
    16_882_005,
  ),
  (
    "pcm_s16le",
    "14_mrbeast_strongman_robot.wav",
    16_000,
    1,
    17_648_640,
  ),
  // --- pcm_f32le/ ---
  ("pcm_f32le", "01_dialogue.wav", 16_000, 1, 3_631_361),
];

fn fixtures_root() -> PathBuf {
  PathBuf::from(env!("CARGO_MANIFEST_DIR"))
    .parent()
    .expect("workspace root")
    .join("tests/fixtures/audio")
}

/// A clip this suite is about to read, used as the availability probe.
///
/// **Not `fixtures_root().exists()`.** `git clone` without `--recursive`
/// leaves `tests/fixtures/audio` as an *empty directory* — the submodule
/// mount point is created either way — so a directory test passes on a
/// tree that holds no fixtures at all, and the sweep below then panicked
/// on the first `ffmpeg::format::input` instead of skipping (issue #34).
/// A skip guard that cannot tell "not fetched" from "fetched" is not a
/// guard.
///
/// The probe is the **first row of [`FIXTURES`]** rather than a path
/// written out again here, so the file the guard asks about is by
/// construction one the sweep really opens: a fixture renamed upstream
/// moves both at once, where a second hard-coded path would rot into a
/// guard that skips a corpus that is present, or admits one that is not.
fn probe_fixture() -> PathBuf {
  let (codec_dir, name, ..) = FIXTURES.first().expect("the fixture table is not empty");
  fixtures_root().join(codec_dir).join(name)
}

fn decode_clip(path: &std::path::Path, expected: (u32, u8, u64)) {
  let (expected_sample_rate, expected_channels, expected_samples) = expected;

  let mut input = ffmpeg::format::input(&path).expect("open input");
  let stream = input
    .streams()
    .best(ffmpeg::media::Type::Audio)
    .expect("audio stream");
  let stream_index = stream.index();
  let stream_tb = stream.time_base();
  let time_base = Timebase::new(
    stream_tb.numerator(),
    NonZeroI32::new(stream_tb.denominator().max(1)).expect("non-zero den"),
  );

  let mut decoder = FfmpegAudioStreamDecoder::open(
    stream.parameters(),
    time_base,
    mediadecode_ffmpeg::DecoderLimits::default(),
  )
  .expect("open audio decoder");

  let mut frame = empty_audio_frame();
  let mut total_samples: u64 = 0;
  let mut frame_count: u64 = 0;
  let mut observed_sample_rate: Option<u32> = None;
  let mut observed_channels: Option<u8> = None;

  for (s, av_packet) in input.packets() {
    if s.index() != stream_index {
      continue;
    }
    let Some(pkt) = mediadecode_ffmpeg::boundary::owned_audio_packet_from_ffmpeg_in(
      &av_packet,
      mediadecode::Timebase::default(),
      mediadecode_ffmpeg::PacketLimits::default(),
    )
    .expect("a wrappable payload") else {
      continue;
    };
    assert_eq!(
      decoder.send_packet(&pkt).expect("audio send_packet"),
      Sent::Accepted,
      "these fixtures feed a session this loop has just drained",
    );
    while matches!(
      decoder
        .receive_frame(&mut frame)
        .expect("audio receive_frame"),
      Received::Frame
    ) {
      total_samples = total_samples.saturating_add(frame.nb_samples() as u64);
      frame_count = frame_count.saturating_add(1);
      observed_sample_rate.get_or_insert(frame.sample_rate());
      observed_channels.get_or_insert(frame.channel_count());
    }
  }
  assert_eq!(decoder.send_eof().expect("send_eof"), Sent::Accepted);
  // The tail, and it has an end that says so.
  loop {
    match decoder
      .receive_frame(&mut frame)
      .expect("audio receive_frame")
    {
      Received::Frame => {
        total_samples = total_samples.saturating_add(frame.nb_samples() as u64);
        frame_count = frame_count.saturating_add(1);
      }
      Received::NeedsInput => panic!("a decoder at EOF asked for input"),
      Received::Ended => break,
    }
  }

  assert!(frame_count > 0, "no audio frames decoded for {path:?}");
  assert_eq!(
    observed_sample_rate,
    Some(expected_sample_rate),
    "sample rate drift on {path:?}",
  );
  assert_eq!(
    observed_channels,
    Some(expected_channels),
    "channel count drift on {path:?}",
  );
  assert_eq!(
    total_samples,
    expected_samples,
    "sample count drift on {} ({total_samples} got, {expected_samples} expected)",
    path.file_name().unwrap_or_default().to_string_lossy(),
  );
}

#[test]
fn decode_all_audio_fixtures() {
  let probe = probe_fixture();
  if !probe.is_file() {
    eprintln!(
      "skip: {} not found — run `git submodule update --init --depth=1` \
       to fetch the audio-fixtures submodule, then re-run this test.",
      probe.display()
    );
    return;
  }

  let root = fixtures_root();
  ffmpeg::init().expect("ffmpeg init");

  for (codec_dir, name, sample_rate, channels, samples) in FIXTURES {
    let path = root.join(codec_dir).join(name);
    eprintln!("decoding {codec_dir}/{name}");
    decode_clip(&path, (*sample_rate, *channels, *samples));
  }
  eprintln!(
    "decoded {} fixtures end-to-end through the trait surface",
    FIXTURES.len(),
  );
}

/// **The skip guard's question, asked of the state that used to defeat
/// it** (issue #34).
///
/// A `git clone` without `--recursive` leaves the submodule's mount
/// point as an empty directory, so `root.exists()` — the guard this
/// suite shipped with — answered *present* and the sweep then panicked
/// opening a file that was never fetched, on exactly the fresh worktree
/// the skip exists to welcome.
///
/// The two assertions are the whole finding: the directory question says
/// yes and the fixture question says no, on one tree. Built here rather
/// than measured against the real checkout, because the bug's state is
/// *the corpus absent* and a suite that has it cannot reproduce that.
#[test]
fn an_empty_submodule_directory_does_not_read_as_a_present_corpus() {
  let scratch = tempfile::tempdir().expect("a temp dir");
  let root = scratch.path().join("audio");
  std::fs::create_dir(&root).expect("the submodule mount point");

  assert!(
    root.exists(),
    "an uninitialised submodule leaves the directory, which is why a directory test cannot \
     be the guard",
  );

  let (codec_dir, name, ..) = FIXTURES.first().expect("the fixture table is not empty");
  assert!(
    !root.join(codec_dir).join(name).is_file(),
    "the guard asks about a clip the sweep opens, and an empty mount point has none",
  );
}

/// **The uniform contract, and the one asymmetry it makes visible
/// instead of hiding.**
///
/// All three decoder faces answer [`Sent`] now, which is the point: a
/// consumer generic over the traits reads one protocol whichever
/// backend it holds. What differs between them is *behaviour*, not
/// shape — and the audio road's difference is that it never answers
/// [`Sent::MustDrain`] for a parked frame.
///
/// That is deliberate and documented at the seat: the video decoder has
/// two scratches and can change which is current mid-fallback, so a
/// submission under a park would leave the retry reading the other one;
/// this decoder has a single scratch, so a submission under a park
/// costs nothing. Before the reform the asymmetry was invisible at the
/// trait tier — video and subtitle raised a `FramePending` arm that the
/// audio error type simply did not have, so a generic consumer could
/// not have discovered either fact. Now it is one vocabulary with one
/// backend answering it differently, which is a property a test can
/// pin.
#[test]
fn the_audio_face_answers_the_same_vocabulary_without_a_park_refusal() {
  let path = fixtures_root().join("pcm_s16le/02_pyannote_sample.wav");
  if !path.exists() {
    eprintln!("skipping: run `git submodule update --init` for {path:?}");
    return;
  }
  ffmpeg::init().expect("ffmpeg init");

  let mut input = ffmpeg::format::input(&path).expect("open input");
  let stream = input
    .streams()
    .best(ffmpeg::media::Type::Audio)
    .expect("audio stream");
  let stream_index = stream.index();
  let mut decoder = FfmpegAudioStreamDecoder::open(
    stream.parameters(),
    Timebase::default(),
    mediadecode_ffmpeg::DecoderLimits::default(),
  )
  .expect("open audio decoder");

  let mut packets = input
    .packets()
    .filter_map(|(s, p)| (s.index() == stream_index).then_some(p));
  let mut take = || {
    let av = packets.next().expect("the fixture has packets");
    mediadecode_ffmpeg::boundary::owned_audio_packet_from_ffmpeg_in(
      &av,
      mediadecode::Timebase::default(),
      mediadecode_ffmpeg::PacketLimits::default(),
    )
    .expect("a wrappable payload")
    .expect("packet has a buffer")
  };

  // Two packets, back to back, with **no drain between them**. The
  // first fills the scratch seat; on the video or subtitle road the
  // second would be told to drain. Here it is simply taken.
  assert_eq!(
    decoder.send_packet(&take()).expect("no fault"),
    Sent::Accepted,
  );
  assert_eq!(
    decoder.send_packet(&take()).expect("no fault"),
    Sent::Accepted,
    "the audio road has one scratch, so a send under a park loses nothing",
  );

  // And the rhythm still works: frames come out, and the end says so.
  let mut frame = empty_audio_frame();
  let mut delivered = 0u32;
  while matches!(
    decoder.receive_frame(&mut frame).expect("no fault"),
    Received::Frame
  ) {
    delivered += 1;
  }
  assert!(delivered > 0, "the two packets produced no frame at all");

  assert_eq!(decoder.send_eof().expect("no fault"), Sent::Accepted);
  loop {
    match decoder.receive_frame(&mut frame).expect("no fault") {
      Received::Frame => {}
      Received::NeedsInput => panic!("a decoder at EOF asked for input"),
      Received::Ended => break,
    }
  }
}

/// **Class audit: the substrate's own post-EOF refusal, observed rather
/// than assumed.**
///
/// The subtitle seam needed a hand-rolled `eof` latch on its send side
/// because `avcodec_decode_subtitle2` has no state machine to refuse
/// for it. The claim that the *stream* decoders need no such latch —
/// that libavcodec answers `AVERROR_EOF` to a packet after a flush
/// packet, and that this crate's send gate reports it as a fault rather
/// than laundering it into `Sent::MustDrain` — is the other half of
/// that finding, and it is checked here against a live decoder instead
/// of being read off the docs.
///
/// The failure this excludes is the one the subtitle seam actually had:
/// a post-EOF packet accepted, decoded, and a terminal `Received::Ended`
/// reversed with no `flush` in the story.
#[test]
fn a_packet_after_eof_is_refused_by_the_audio_substrate() {
  let path = fixtures_root().join("pcm_s16le/02_pyannote_sample.wav");
  if !path.exists() {
    eprintln!("skipping: run `git submodule update --init` for {path:?}");
    return;
  }
  ffmpeg::init().expect("ffmpeg init");

  let mut input = ffmpeg::format::input(&path).expect("open input");
  let stream = input
    .streams()
    .best(ffmpeg::media::Type::Audio)
    .expect("audio stream");
  let stream_index = stream.index();
  let mut decoder = FfmpegAudioStreamDecoder::open(
    stream.parameters(),
    Timebase::default(),
    mediadecode_ffmpeg::DecoderLimits::default(),
  )
  .expect("open audio decoder");

  let packet = input
    .packets()
    .find_map(|(s, p)| (s.index() == stream_index).then_some(p))
    .expect("the fixture has packets");
  let pkt = mediadecode_ffmpeg::boundary::owned_audio_packet_from_ffmpeg_in(
    &packet,
    mediadecode::Timebase::default(),
    mediadecode_ffmpeg::PacketLimits::default(),
  )
  .expect("a wrappable payload")
  .expect("packet has a buffer");

  assert_eq!(decoder.send_packet(&pkt).expect("no fault"), Sent::Accepted);
  assert_eq!(decoder.send_eof().expect("no fault"), Sent::Accepted);

  // Drain to the settled end.
  let mut frame = empty_audio_frame();
  loop {
    match decoder.receive_frame(&mut frame).expect("no fault") {
      Received::Frame => {}
      Received::NeedsInput => panic!("a decoder at EOF asked for input"),
      Received::Ended => break,
    }
  }

  // The reversal, refused — by libavcodec, reported by the send gate.
  // **Never `MustDrain`**: draining changes nothing, so that answer
  // would be a loop whose next offer can never succeed.
  for _ in 0..2 {
    let refused = decoder.send_packet(&pkt);
    assert!(
      refused.is_err(),
      "a packet after end-of-stream must be a fault, got {refused:?}",
    );
  }
  assert_eq!(
    decoder.receive_frame(&mut frame).expect("no fault"),
    Received::Ended,
    "the refused packet must not have re-armed the decoder",
  );

  // `flush` is the way back on this road too.
  decoder.flush().expect("flush");
  assert_eq!(decoder.send_packet(&pkt).expect("no fault"), Sent::Accepted);
}