mediadecode-ffmpeg 0.3.2

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
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
use super::*;

use mediadecode::decoder::VideoStreamDecoder;
use std::num::NonZeroU32;

// ---------------------------------------------------------------------------
//  Fake-HW fallback seam: synthetic clip + driver
// ---------------------------------------------------------------------------

/// A synthetic encoded clip: real mpeg4 packets (so the SW decoder genuinely
/// decodes them) plus their key flags and PTS. Encoded with a moving pattern
/// and a fixed GOP so the stream has real keyframes and P-frames.
struct SyntheticClip {
  parameters: ffmpeg_next::codec::Parameters,
  /// Encoded packets in decode order.
  packets: Vec<Packet>,
}

/// Encode a small multi-GOP mpeg4 clip in-process. `gop` forces a keyframe
/// every `gop` frames; a moving diagonal gradient gives the encoder real
/// inter-frame prediction so P-frames actually appear. `max_b_frames == 0`
/// keeps decode order == display order (simple monotonic PTS).
fn encode_synthetic_clip(width: u32, height: u32, frames: usize, gop: u32) -> SyntheticClip {
  use ffmpeg_next as ff;
  ff::init().expect("ffmpeg init");

  let codec = ff::codec::encoder::find(ff::codec::Id::MPEG4).expect("mpeg4 encoder present");
  let ctx = ff::codec::context::Context::new_with_codec(codec);
  let mut enc = ctx.encoder().video().expect("video encoder context");
  enc.set_width(width);
  enc.set_height(height);
  enc.set_format(ff::format::Pixel::YUV420P);
  enc.set_time_base(ff::Rational::new(1, 25));
  enc.set_gop(gop);
  enc.set_max_b_frames(0);
  enc.set_bit_rate(500_000);
  let mut opened = enc.open_as(codec).expect("open encoder");
  let parameters = ff::codec::Parameters::from(&opened);

  let mut packets: Vec<Packet> = Vec::new();
  let drain = |opened: &mut ff::codec::encoder::Video, out: &mut Vec<Packet>| {
    loop {
      let mut pkt = Packet::empty();
      match opened.receive_packet(&mut pkt) {
        Ok(()) => out.push(pkt),
        Err(_) => break,
      }
    }
  };

  let mut frame = ff::frame::Video::new(ff::format::Pixel::YUV420P, width, height);
  for i in 0..frames as i64 {
    let ystride = frame.stride(0);
    {
      let data = frame.data_mut(0);
      for y in 0..height as usize {
        for x in 0..width as usize {
          data[y * ystride + x] = ((x + y + i as usize * 4) & 0xff) as u8;
        }
      }
    }
    let cstride = frame.stride(1);
    for p in 1..3usize {
      let data = frame.data_mut(p);
      for y in 0..(height as usize / 2) {
        for x in 0..(width as usize / 2) {
          data[y * cstride + x] = (128 + ((x as i64 - i) & 0x3f)) as u8;
        }
      }
    }
    frame.set_pts(Some(i));
    opened.send_frame(&frame).expect("send_frame");
    drain(&mut opened, &mut packets);
  }
  opened.send_eof().expect("encoder send_eof");
  drain(&mut opened, &mut packets);

  assert!(
    packets.len() >= 8,
    "synthetic clip needs enough packets ({} too few)",
    packets.len()
  );
  assert!(packets[0].is_key(), "first packet must be a keyframe");
  SyntheticClip {
    parameters,
    packets,
  }
}

/// The HW-exhaustion shape a [`FakeHw`] raises at its `fail_at_send`.
#[derive(Clone, Copy)]
enum FailShape {
  /// Post-commit runtime failure: empty rescue, `FallbackOrigin::PostCommit`.
  /// The wrapper degrades and continues — the SW decoder opens cold and
  /// resyncs at the next keyframe.
  PostCommit,
  /// Probe-era failure: `FallbackOrigin::Probe` carrying the decoder's
  /// buffered packet history (every packet accepted so far, in order). The
  /// wrapper replays that history losslessly, then forwards the current packet.
  ProbeEra,
}

/// A test HW seam modelling the runtime-failure flow.
///
/// * `inert()` — never driven (a placeholder seam).
/// * `never_failing(...)` — delivers a frame 1:1 for the whole clip.
/// * `failing(.., doom_from_send, fail_at_send, shape)` — models a HW backend
///   that decodes the early frames fine and then hits content it can't decode.
///   It delivers a well-formed CPU frame 1:1 for every accepted packet until
///   `doom_from_send`; from that send onward it still *accepts* packets but
///   delivers **no** frames for them; on the `fail_at_send` send it returns the
///   chosen [`FailShape`] without accepting that packet.
struct FakeHw {
  width: u32,
  height: u32,
  /// First `send_packet` index (0-based) from which packets are accepted but
  /// no frame is delivered — modelling a HW decoder that buffered packets but
  /// cannot produce frames from them.
  doom_from_send: usize,
  /// `send_packet` index at which to fail. `usize::MAX` => never fail.
  fail_at_send: usize,
  /// The exhaustion shape raised at `fail_at_send`.
  shape: FailShape,
  /// Number of `send_packet` calls seen so far.
  sends: usize,
  /// CPU frames queued by accepted pre-doom `send_packet`s, delivered FIFO by
  /// `receive_frame`. Each carries the accepted packet's PTS.
  queued: VecDeque<i64>,
  /// Refcounted clones of every packet accepted so far — the probe-era
  /// `unconsumed_packets` history surfaced on a [`FailShape::ProbeEra`] failure.
  history: Vec<Packet>,
}

impl FakeHw {
  fn inert() -> Self {
    Self {
      width: 0,
      height: 0,
      doom_from_send: usize::MAX,
      fail_at_send: usize::MAX,
      shape: FailShape::PostCommit,
      sends: 0,
      queued: VecDeque::new(),
      history: Vec::new(),
    }
  }

  fn failing(
    width: u32,
    height: u32,
    doom_from_send: usize,
    fail_at_send: usize,
    shape: FailShape,
  ) -> Self {
    Self {
      width,
      height,
      doom_from_send,
      fail_at_send,
      shape,
      sends: 0,
      queued: VecDeque::new(),
      history: Vec::new(),
    }
  }

  /// Never fails — stays on the HW path for the whole clip, delivering 1:1.
  fn never_failing(width: u32, height: u32) -> Self {
    Self::failing(width, height, usize::MAX, usize::MAX, FailShape::PostCommit)
  }
}

impl HwInner for FakeHw {
  fn send_packet(&mut self, packet: &Packet) -> Result<(), Error> {
    let idx = self.sends;
    self.sends += 1;
    if idx == self.fail_at_send {
      // The packet is NOT accepted; raise the chosen exhaustion shape.
      return match self.shape {
        FailShape::PostCommit => Err(Error::AllBackendsFailed(
          crate::error::AllBackendsFailed::new_post_commit(Vec::new()),
        )),
        FailShape::ProbeEra => Err(Error::AllBackendsFailed(
          crate::error::AllBackendsFailed::new(Vec::new(), std::mem::take(&mut self.history)),
        )),
      };
    }
    // Accept the packet. Track it as probe-era history, and deliver a frame for
    // it only before the doomed span.
    if let Ok(cloned) = crate::decoder::try_clone_packet(packet) {
      self.history.push(cloned);
    }
    if idx < self.doom_from_send {
      self.queued.push_back(packet.pts().unwrap_or(0));
    }
    Ok(())
  }

  fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error> {
    match self.queued.pop_front() {
      Some(pts) => {
        let mut av =
          frame::Video::new(ffmpeg_next::format::Pixel::YUV420P, self.width, self.height);
        av.set_pts(Some(pts));
        *frame.as_inner_mut() = av;
        Ok(())
      }
      None => Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
        errno: ffmpeg_next::error::EAGAIN,
      })),
    }
  }

  fn send_eof(&mut self) -> Result<(), Error> {
    Ok(())
  }

  fn flush(&mut self) -> Result<(), Error> {
    self.queued.clear();
    Ok(())
  }

  fn as_video_decoder(&self) -> Option<&VideoDecoder> {
    None
  }
}

/// A HW seam that decodes a prefix 1:1 and then raises a **post-commit**
/// `AllBackendsFailed` from `send_eof` — the only way to drive the `send_eof`
/// fallback arm (the general [`FakeHw`]'s `send_eof` always succeeds). Every
/// `send_packet` is accepted and (until the queue is drained) delivers a frame
/// FIFO, so the stream is fully HW-decoded right up to the EOF-time failure;
/// the SW fallback then opens cold and, fed only `send_eof` with no packets,
/// can never produce a frame.
struct FakeHwEofFails {
  width: u32,
  height: u32,
  /// PTS of accepted packets, delivered FIFO by `receive_frame`.
  queued: VecDeque<i64>,
}

impl FakeHwEofFails {
  fn new(width: u32, height: u32) -> Self {
    Self {
      width,
      height,
      queued: VecDeque::new(),
    }
  }
}

impl HwInner for FakeHwEofFails {
  fn send_packet(&mut self, packet: &Packet) -> Result<(), Error> {
    self.queued.push_back(packet.pts().unwrap_or(0));
    Ok(())
  }

  fn receive_frame(&mut self, frame: &mut Frame) -> Result<(), Error> {
    match self.queued.pop_front() {
      Some(pts) => {
        let mut av =
          frame::Video::new(ffmpeg_next::format::Pixel::YUV420P, self.width, self.height);
        av.set_pts(Some(pts));
        *frame.as_inner_mut() = av;
        Ok(())
      }
      None => Err(Error::Ffmpeg(ffmpeg_next::Error::Other {
        errno: ffmpeg_next::error::EAGAIN,
      })),
    }
  }

  fn send_eof(&mut self) -> Result<(), Error> {
    Err(Error::AllBackendsFailed(
      crate::error::AllBackendsFailed::new_post_commit(Vec::new()),
    ))
  }

  fn flush(&mut self) -> Result<(), Error> {
    self.queued.clear();
    Ok(())
  }

  fn as_video_decoder(&self) -> Option<&VideoDecoder> {
    None
  }
}

/// Drive the decoder over `clip`, draining every available frame after each
/// `send_packet` and after EOF. Returns the PTS of every delivered frame in
/// order. A `None` PTS surfaces as `i64::MIN` so a hole is visible.
fn drive(dec: &mut FfmpegVideoStreamDecoder, clip: &SyntheticClip) -> Vec<i64> {
  let mut out: Vec<i64> = Vec::new();
  let mut dst = crate::empty_video_frame();

  let mut drain_frames = |dec: &mut FfmpegVideoStreamDecoder, out: &mut Vec<i64>| {
    loop {
      match dec.receive_frame(&mut dst) {
        Ok(()) => out.push(dst.pts().map(|t| t.pts()).unwrap_or(i64::MIN)),
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          break;
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
        Err(e) => panic!("receive_frame: {e:?}"),
      }
    }
  };

  for av_pkt in &clip.packets {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    drain_frames(dec, &mut out);
  }
  dec.send_eof().expect("send_eof");
  drain_frames(dec, &mut out);
  out
}

/// Index of the keyframe that starts the `n`-th (1-based) GOP, i.e. the `n`-th
/// keyframe in decode order.
fn nth_keyframe(clip: &SyntheticClip, n: usize) -> usize {
  clip
    .packets
    .iter()
    .enumerate()
    .filter(|(_, p)| p.is_key())
    .nth(n - 1)
    .map(|(i, _)| i)
    .unwrap_or_else(|| panic!("clip must have at least {n} keyframes (multi-GOP)"))
}

// ---------------------------------------------------------------------------
//  Post-commit fallback: degrade-and-continue, resync at next keyframe
// ---------------------------------------------------------------------------

/// End-to-end: a fake HW decoder commits, decodes the first GOP, then fails
/// **post-commit mid-GOP**. The wrapper must (1) flip to software, (2) NOT panic
/// or error — the dropped span is an accepted, logged gap, and (3) resync at the
/// next **keyframe** and decode normally from there. The accepted loss is the
/// bounded span from the failure point to that keyframe, so the assertion is the
/// *resync* (every PTS from the next keyframe onward is delivered exactly once),
/// NOT zero loss.
///
/// The resync is **keyframe-gated**: the failure point and everything up to the
/// next keyframe are P-frames, and a lenient mpeg4 SW decoder emits *concealed*
/// frames from those lone P-frames. The degrade-resync guard must **not** clear
/// on those — only the frame delivered after the real keyframe is fed counts. We
/// feed the stream in two phases to pin this down: up to (but excluding) the
/// resync keyframe the guard stays pending and no keyframe is seen; feeding the
/// keyframe onward clears it.
#[test]
fn post_commit_failure_degrades_and_resyncs_at_next_keyframe() {
  let (w, h) = (128u32, 96u32);
  // Three+ GOPs so a failure two into GOP-2 still has a GOP-3 keyframe ahead to
  // resync on. GOP of 6 over 24 frames gives keyframes at 0, 6, 12, 18, ...
  let clip = encode_synthetic_clip(w, h, 24, 6);

  let second_key = nth_keyframe(&clip, 2);
  let third_key = nth_keyframe(&clip, 3);
  // Fail two P-frames into GOP-2 (a genuine mid-GOP runtime failure). The
  // forwarded current packet (idx fail_at) is a P-frame a cold mpeg4 decoder
  // accepts without InvalidData, so the fallback commits and SW conceals.
  let fail_at = second_key + 2;
  assert!(
    fail_at < third_key && !clip.packets[fail_at].is_key(),
    "fail target must be a mid-GOP P-frame before the next keyframe"
  );

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    // Deliver every frame up to the failure (doom == fail: HW keeps delivering
    // 1:1 right until it fails), then fail post-commit on `fail_at`.
    Box::new(FakeHw::failing(
      w,
      h,
      fail_at,
      fail_at,
      FailShape::PostCommit,
    )),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");
  assert!(dec.is_hardware(), "must start on the HW seam");

  let mut pts_out: Vec<i64> = Vec::new();
  let mut dst = crate::empty_video_frame();
  let mut drain = |dec: &mut FfmpegVideoStreamDecoder, out: &mut Vec<i64>| loop {
    match dec.receive_frame(&mut dst) {
      Ok(()) => out.push(dst.pts().map(|t| t.pts()).unwrap_or(i64::MIN)),
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
        if errno == ffmpeg_next::error::EAGAIN =>
      {
        break;
      }
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
      Err(e) => panic!("receive_frame: {e:?}"),
    }
  };

  // Phase 1: feed packets [0, third_key) — the HW prefix, the post-commit
  // failure at `fail_at`, and the gap's P-frames up to (not including) the
  // resync keyframe. Even if mpeg4 conceals frames from those lone P-frames, the
  // KEYFRAME-GATED guard must stay pending and no keyframe must be recorded.
  for av_pkt in clip.packets.iter().take(third_key) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    drain(&mut dec, &mut pts_out);
  }
  // (1) flipped to software at the post-commit failure.
  assert!(
    dec.is_software(),
    "post-commit HW failure must trigger the SW fallback"
  );
  // (2) keyframe-gating: no keyframe fed across the gap yet, so the guard holds
  // even though concealed P-frame frames may already have been delivered.
  assert!(
    dec.degraded_resync_pending_for_test(),
    "no keyframe fed across the gap yet — the resync guard must still be pending \
     (a concealed P-frame must not clear it)"
  );
  assert!(
    !dec.degraded_keyframe_seen_for_test(),
    "no keyframe has crossed the gap, so the keyframe-seen anchor must be unset"
  );

  // Phase 2: feed the resync keyframe and the remainder; the frame SW delivers
  // after the keyframe clears the guard.
  for av_pkt in clip.packets.iter().skip(third_key) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    drain(&mut dec, &mut pts_out);
  }
  dec.send_eof().expect("send_eof");
  drain(&mut dec, &mut pts_out);

  // (3) the keyframe-anchored resync cleared the guard — no escalation at EOF.
  assert!(
    !dec.degraded_resync_pending_for_test(),
    "the keyframe-anchored resync must have cleared the guard before EOF"
  );

  // Every delivered frame carried a real PTS.
  assert!(
    !pts_out.contains(&i64::MIN),
    "no delivered frame may have a missing PTS: {pts_out:?}"
  );

  // Resync at the next keyframe — the load-bearing guarantee. Degrade-and-
  // continue ACCEPTS a bounded loss span [fail_at, third_key); whether a lenient
  // codec (mpeg4 here) also recovers some of it is NOT part of the contract, so
  // we assert the resync, never zero loss. Concretely, with the failure point
  // and the resync keyframe known:
  //   * no duplicates and no out-of-range PTS — the seam never corrupts output;
  //   * the HW-delivered prefix [0, fail_at) all surfaces (HW delivered it
  //     before failing);
  //   * the SW resync is real: every PTS from the next keyframe onward
  //     [third_key_pts, total) surfaces — SW opened cold, resynced at that
  //     keyframe, and decoded the remainder;
  //   * any frame NOT delivered lies only inside the bounded accepted gap
  //     [fail_at, third_key_pts) — nothing outside the gap is ever lost.
  let third_key_pts = clip.packets[third_key].pts().expect("keyframe has pts");
  let total = clip.packets.len() as i64;

  let unique: std::collections::HashSet<i64> = pts_out.iter().copied().collect();
  assert_eq!(
    unique.len(),
    pts_out.len(),
    "no duplicate PTS — the degrade path must not re-emit a frame: {pts_out:?}"
  );
  for &pts in &pts_out {
    assert!(
      (0..total).contains(&pts),
      "delivered PTS {pts} is outside the source range 0..{total}: {pts_out:?}"
    );
  }
  // HW-delivered prefix is fully present.
  for pts in 0..fail_at as i64 {
    assert!(
      unique.contains(&pts),
      "HW delivered PTS {pts} before failing; it must be present: {pts_out:?}"
    );
  }
  // SW resync from the next keyframe onward is fully present (the resync proof).
  for pts in third_key_pts..total {
    assert!(
      unique.contains(&pts),
      "SW must resync at the next keyframe and decode the remainder; PTS {pts} \
       (>= resync keyframe {third_key_pts}) is missing — no resync: {pts_out:?}"
    );
  }
  // Any loss is confined to the bounded accepted gap — nothing outside it.
  for pts in 0..total {
    if !unique.contains(&pts) {
      assert!(
        (fail_at as i64..third_key_pts).contains(&pts),
        "PTS {pts} was dropped but lies OUTSIDE the accepted [fail, keyframe) \
         gap [{fail_at}, {third_key_pts}); only the bounded gap may be lost: \
         {pts_out:?}"
      );
    }
  }
  // The accepted gap is bounded by ~one GOP, not the whole tail.
  assert!(
    (third_key_pts - fail_at as i64) <= 6,
    "the accepted gap must be bounded by ~one GOP; was {}",
    third_key_pts - fail_at as i64
  );
}

/// Sanity: with no injected failure the fake HW stays on the HW path for the
/// whole clip and delivers one frame per packet. Guards against the seam itself
/// dropping frames or spuriously falling back.
#[test]
fn fake_hw_without_failure_stays_on_hardware() {
  let (w, h) = (128u32, 96u32);
  let clip = encode_synthetic_clip(w, h, 12, 6);

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::never_failing(w, h)),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  let pts_out = drive(&mut dec, &clip);

  assert!(dec.is_hardware(), "no failure => stays on the HW seam");
  assert_eq!(
    pts_out.len(),
    clip.packets.len(),
    "HW path must deliver one frame per packet"
  );
}

// ---------------------------------------------------------------------------
//  Probe-era fallback: still lossless (the original pre-#12 path)
// ---------------------------------------------------------------------------

/// The probe-era path is unchanged by the degrade-and-continue simplification:
/// a HW failure **before the first frame** surfaces the decoder's buffered
/// history in `unconsumed_packets`, which the wrapper replays losslessly
/// through SW (then forwards the still-unconsumed current packet). No frame was
/// ever delivered on the HW path, so every source frame must come out exactly
/// once — a probe-era fallback loses nothing.
#[test]
fn probe_era_failure_replays_history_losslessly() {
  let (w, h) = (128u32, 96u32);
  let clip = encode_synthetic_clip(w, h, 16, 6);

  // Fail a few packets in WITHOUT delivering any frame first (doom_from_send =
  // 0 => nothing is delivered on HW; every accepted packet is buffered as
  // probe history). The failing packet is not accepted; the buffered history is
  // packets [0, fail_at).
  let fail_at = 5;
  assert!(fail_at < clip.packets.len(), "fail target in range");

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");
  assert!(dec.is_hardware(), "must start on the HW seam");

  let pts_out = drive(&mut dec, &clip);

  assert!(
    dec.is_software(),
    "probe-era HW failure must trigger the SW fallback"
  );
  // Lossless: the replayed history + forwarded current packet + the remaining
  // forwarded packets reconstruct the whole stream — every PTS exactly once.
  assert!(
    !pts_out.contains(&i64::MIN),
    "no delivered frame may have a missing PTS: {pts_out:?}"
  );
  let mut sorted = pts_out.clone();
  sorted.sort_unstable();
  let expected: Vec<i64> = (0..clip.packets.len() as i64).collect();
  assert_eq!(
    sorted, expected,
    "a probe-era fallback must lose no frames — every source PTS delivered \
     exactly once: {pts_out:?}"
  );
}

// ---------------------------------------------------------------------------
//  Transactional SW-open failure: stays on HW, surfaces FallbackFailed
// ---------------------------------------------------------------------------

/// A decoder whose stored `parameters` cannot open a SW decoder. An empty
/// `Parameters` has codec id `NONE`, so `open_sw_decoder` (`build_codec_context`
/// → `.decoder().video()`) fails — exactly the SW-open failure the transactional
/// rollback must survive.
fn unopenable_sw_decoder(hw: Box<dyn HwInner>) -> FfmpegVideoStreamDecoder {
  ffmpeg_next::init().expect("ffmpeg init");
  let params = ffmpeg_next::codec::Parameters::new();
  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  FfmpegVideoStreamDecoder::from_hw_inner_for_test(hw, params, tb).expect("build test decoder")
}

/// On a post-commit fallback whose SW decoder fails to OPEN, the transition is
/// transactional: the wrapper surfaces `FallbackFailed` (carrying the rescued
/// packets — empty here, as post-commit always is) and stays on the HW state.
/// It must NOT silently commit a broken SW decoder or lose the HW path.
#[test]
fn post_commit_sw_open_failure_stays_on_hw_transactionally() {
  let (w, h) = (64u32, 64u32);
  // Fail post-commit on the very first send. The stored `Parameters` are empty,
  // so `open_sw_decoder` fails and the fallback must roll back to HW.
  let mut dec = unopenable_sw_decoder(Box::new(FakeHw::failing(w, h, 0, 0, FailShape::PostCommit)));
  assert!(dec.is_hardware(), "must start on the HW seam");

  // Build a throwaway packet to send (content is irrelevant — HW fails before
  // touching it).
  let mut raw = Packet::new(16);
  raw.set_pts(Some(0));
  let vpkt = boundary::video_packet_from_ffmpeg(&raw).expect("packet has a buffer");

  let err = dec
    .send_packet(&vpkt)
    .expect_err("SW-open failure must surface an error");
  match err {
    VideoDecodeError::Decode(Error::FallbackFailed(_)) => {}
    other => panic!("expected FallbackFailed on SW-open failure, got {other:?}"),
  }
  assert!(
    dec.is_hardware(),
    "a failed fallback (SW could not open) must leave the decoder on its prior \
     HW state — transactional rollback, not a half-committed SW"
  );
}

// ---------------------------------------------------------------------------
//  Drain-error propagation: a non-transient SW decode error surfaces
// ---------------------------------------------------------------------------

/// Zero a packet's payload in place — enough to make the mpeg4 SW decoder
/// reject it with `InvalidData` ("header damaged") when it tries to decode it.
fn corrupt_packet_payload(pkt: &mut Packet) {
  if let Some(d) = pkt.data_mut() {
    for b in d.iter_mut() {
      *b = 0;
    }
  }
}

/// A non-transient SW decode error during the fallback replay drain must
/// SURFACE (as `FallbackFailed` carrying the replay packets), not be swallowed
/// and the fallback silently committed over corruption. Exercised via the
/// **probe-era** replay path (the only path that replays packets): we poison a
/// P-frame in the buffered history the SW decoder replays; when the drain
/// decodes it the SW decoder returns `InvalidData`, which the drain propagates.
///
/// Without the drain-error fix the drain treats `InvalidData` like EAGAIN/EOF
/// (`break`), swallowing it: the fallback "succeeds", masking the corruption.
#[test]
fn sw_replay_drain_surfaces_non_transient_decode_error() {
  let (w, h) = (128u32, 96u32);
  // Single long GOP so the whole buffered history (with the corrupt packet) is
  // replayed on the probe-era fallback.
  let mut clip = encode_synthetic_clip(w, h, 12, 100);
  let p1 = clip
    .packets
    .iter()
    .position(|p| !p.is_key())
    .expect("clip has P-frames");
  assert!(
    p1 + 2 < clip.packets.len(),
    "need packets after the corrupt one"
  );
  corrupt_packet_payload(&mut clip.packets[p1]);

  // Probe-era: deliver NO frames (doom_from_send = 0), accept-and-buffer every
  // packet as history, then fail probe-era a few packets after the corrupt one.
  // The buffered history the SW decoder replays is {keyframe, corrupt_P, P, ...}
  // → the drain surfaces InvalidData.
  let fail_at = p1 + 3;
  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  let mut dst = crate::empty_video_frame();
  let mut err = None;
  for av_pkt in &clip.packets {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    if let Err(e) = dec.send_packet(&vpkt) {
      err = Some(e);
      break;
    }
    loop {
      match dec.receive_frame(&mut dst) {
        Ok(()) => {}
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          break;
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
        Err(e) => {
          err = Some(e);
          break;
        }
      }
    }
    if err.is_some() {
      break;
    }
  }

  let err = err.expect("the corrupt replayed packet must surface an error, not be swallowed");
  match err {
    VideoDecodeError::Decode(Error::FallbackFailed(f)) => {
      assert!(
        !f.unconsumed_packets().is_empty(),
        "FallbackFailed must carry the replay packets for recovery"
      );
      assert!(
        matches!(f.source(), Error::Ffmpeg(ffmpeg_next::Error::InvalidData)),
        "the surfaced error must be the SW InvalidData decode failure; got {:?}",
        f.source()
      );
    }
    other => panic!("expected FallbackFailed surfacing InvalidData, got {other:?}"),
  }

  assert!(
    dec.is_hardware(),
    "a failed fallback must leave the decoder on its prior (HW) state, not \
     commit SW over swallowed corruption"
  );
}

/// The transactional commit boundary: SW **ACCEPTS every replayed packet**
/// (no EAGAIN backpressure, so the mid-replay drains never fire) and only then
/// returns `InvalidData` from `receive_frame`. The drain-before-commit must
/// catch that deferred error so it surfaces as `FallbackFailed` (rescued
/// packets retained) and the decoder stays HW — NOT as a plain decode error
/// after a half-done commit (frames appended + `state` flipped to `Sw` +
/// rescued packets dropped), which would break probe-era recovery on
/// non-seekable input.
///
/// This is the deferred-error counterpart to
/// `sw_replay_drain_surfaces_non_transient_decode_error`: there the corrupt
/// packet sits mid-history so a *subsequent send's* EAGAIN-drain decodes it
/// early; here the corrupt packet is the LAST in the buffered history, so no
/// per-send drain ever touches it — only the final drain-before-commit does.
/// Without that drain the fallback would commit and the `InvalidData` would
/// reach the caller plainly on the first post-commit `receive_frame`.
#[test]
fn sw_replay_deferred_error_surfaces_fallback_failed_at_commit() {
  let (w, h) = (128u32, 96u32);
  // Single long GOP so the whole prefix is one replayed history with no
  // intervening keyframe; corrupt the LAST P-frame of that prefix.
  let mut clip = encode_synthetic_clip(w, h, 12, 100);
  // `fail_at` is probe-era: the buffered history is packets [0, fail_at). Put
  // the corrupt packet at fail_at - 1 (the last replayed packet) so the only
  // decode of it happens in the final drain-before-commit.
  let fail_at = 5;
  assert!(
    fail_at >= 2 && fail_at < clip.packets.len(),
    "need a multi-packet history with room for a corrupt tail"
  );
  let corrupt_idx = fail_at - 1;
  assert!(
    !clip.packets[corrupt_idx].is_key(),
    "the corrupt last-history packet must be a P-frame (a corrupt keyframe \
     could fail SW's send_packet instead of receive_frame)"
  );
  corrupt_packet_payload(&mut clip.packets[corrupt_idx]);

  // Probe-era, deliver NO frames (doom_from_send = 0): every accepted packet is
  // buffered as history; fail probe-era at `fail_at`. History replayed through
  // SW is {keyframe, clean P.., corrupt_P} — the sends accept it all, and the
  // final drain decodes corrupt_P and surfaces InvalidData.
  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(w, h, 0, fail_at, FailShape::ProbeEra)),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  // Send exactly the history-then-failing packets; the failing send triggers
  // the fallback whose commit-time drain must surface the deferred error.
  let mut surfaced = None;
  let mut dst = crate::empty_video_frame();
  for av_pkt in clip.packets.iter().take(fail_at + 1) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    match dec.send_packet(&vpkt) {
      Ok(()) => {
        // Drain anything available (none expected pre-fallback — doom = 0).
        loop {
          match dec.receive_frame(&mut dst) {
            Ok(()) => {}
            Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
              if errno == ffmpeg_next::error::EAGAIN =>
            {
              break;
            }
            Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
            Err(e) => {
              surfaced = Some(e);
              break;
            }
          }
        }
      }
      Err(e) => {
        surfaced = Some(e);
        break;
      }
    }
    if surfaced.is_some() {
      break;
    }
  }

  let err = surfaced.expect(
    "the deferred InvalidData must surface at the fallback commit boundary, not be \
     committed over",
  );
  match err {
    VideoDecodeError::Decode(Error::FallbackFailed(f)) => {
      assert!(
        !f.unconsumed_packets().is_empty(),
        "FallbackFailed must retain the rescued replay packets for recovery"
      );
      assert!(
        matches!(f.source(), Error::Ffmpeg(ffmpeg_next::Error::InvalidData)),
        "the surfaced error must be the deferred SW InvalidData; got {:?}",
        f.source()
      );
    }
    other => panic!("expected FallbackFailed surfacing the deferred InvalidData, got {other:?}"),
  }
  assert!(
    dec.is_hardware(),
    "a deferred-error fallback caught at the commit boundary must leave the \
     decoder on its prior HW state — nothing committed"
  );
}

// ---------------------------------------------------------------------------
//  Failed EOF fallback: eof_sent is RESTORED (no half-mutation), stays HW
// ---------------------------------------------------------------------------

/// `send_eof` hits a post-commit HW failure whose SW decoder cannot open
/// (empty `Parameters`). The fallback returns `FallbackFailed`, so the decoder
/// stays HW — and `eof_sent` must be RESTORED to its prior value (`false`),
/// never left half-mutated `true`. A stale `eof_sent = true` would make a
/// *later* fallback inject EOF into the new SW decoder though this `send_eof`
/// errored.
#[test]
fn failed_eof_fallback_restores_eof_sent_and_stays_on_hw() {
  let (w, h) = (64u32, 64u32);
  // `FakeHwEofFails::send_eof` raises a post-commit `AllBackendsFailed`, driving
  // the send_eof fallback arm; the empty `Parameters` from `unopenable_sw_decoder`
  // make `open_sw_decoder` fail, so the fallback returns `FallbackFailed` and the
  // transaction must roll back (HW retained, `eof_sent` un-mutated).
  let mut dec = unopenable_sw_decoder(Box::new(FakeHwEofFails::new(w, h)));
  assert!(dec.is_hardware(), "must start on the HW seam");
  assert!(
    !dec.eof_sent_for_test(),
    "precondition: eof_sent starts false"
  );

  let err = dec
    .send_eof()
    .expect_err("a failed EOF fallback must surface an error");
  match err {
    VideoDecodeError::Decode(Error::FallbackFailed(_)) => {}
    other => panic!("expected FallbackFailed on the failed EOF fallback, got {other:?}"),
  }

  assert!(
    dec.is_hardware(),
    "a failed EOF fallback (SW could not open) must leave the decoder on its \
     prior HW state — transactional rollback"
  );
  assert!(
    !dec.eof_sent_for_test(),
    "eof_sent must be RESTORED to its prior value (false) after a failed EOF \
     fallback — a stale true would inject EOF into a later SW fallback"
  );

  // A subsequent operation must not see stale EOF: a normal send_eof on the
  // (still-HW, EOF-never-accepted) decoder behaves as a first EOF. Our seam's
  // send_eof keeps failing the same way, so this just re-confirms HW + the
  // rolled-back flag rather than silently succeeding off a stale `eof_sent`.
  let err2 = dec.send_eof().expect_err(
    "the still-HW decoder must re-attempt (and re-fail) EOF, not no-op off stale state",
  );
  assert!(
    matches!(err2, VideoDecodeError::Decode(Error::FallbackFailed(_))),
    "second send_eof must again drive the fallback (proving no stale-EOF short-circuit)"
  );
  assert!(
    !dec.eof_sent_for_test(),
    "still rolled back after the retry"
  );
}

// ---------------------------------------------------------------------------
//  Post-commit fallback that never resyncs before EOF: escalate, not silent
// ---------------------------------------------------------------------------

/// A post-commit fallback fires and the SW decoder reaches EOF without ever
/// producing a frame — no keyframe arrived across the gap, so the whole tail is
/// lost. The "bounded, logged gap" promise can't be kept (there is no resync),
/// so the loss must ESCALATE: a distinct `PostCommitNeverResynced` error at EOF,
/// NOT a silent empty tail surfaced as a clean end-of-stream.
///
/// Determinism note: a real (lenient) mpeg4 SW decoder will happily decode a
/// lone P-frame forwarded after a mid-stream fallback, *resyncing* and clearing
/// the pending flag — so "fed only P-frames to EOF" is not a reliable no-resync
/// trigger in a unit test (the resync keyframe being absent is an input
/// property, not something the test can force on a lenient decoder). The
/// unambiguous no-resync case is a **cold SW decoder fed no decodable input at
/// all**: we fail post-commit at `send_eof`, so the SW decoder opens cold,
/// receives only the re-forwarded EOF, and can categorically produce no frame.
/// `receive_frame` then returns EOF while the resync is still pending →
/// escalation. (`packets_lost` is 0 here: zero packets crossed to SW — the lost
/// tail was the HW-side frames the EOF-time failure stranded. The counter is
/// incremented for packets fed to SW across a gap entered from the
/// `send_packet` arm; this EOF-entry path forwards none.)
#[test]
fn post_commit_fallback_never_resyncing_escalates_at_eof() {
  let (w, h) = (128u32, 96u32);
  // A normal multi-GOP clip fully decoded on HW up to EOF; the EOF-time HW
  // failure then strands the tail and SW cannot resync from a cold EOF.
  let clip = encode_synthetic_clip(w, h, 12, 6);

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHwEofFails::new(w, h)),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  let mut dst = crate::empty_video_frame();
  let mut delivered = 0usize;
  let mut escalation = None;
  let mut drain = |dec: &mut FfmpegVideoStreamDecoder,
                   delivered: &mut usize,
                   escalation: &mut Option<VideoDecodeError>| {
    loop {
      match dec.receive_frame(&mut dst) {
        Ok(()) => *delivered += 1,
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          break;
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
        Err(e @ VideoDecodeError::PostCommitNeverResynced { .. }) => {
          *escalation = Some(e);
          break;
        }
        Err(e) => panic!("unexpected error draining frames: {e:?}"),
      }
    }
  };

  // HW decodes the whole stream 1:1 (no fallback yet).
  for av_pkt in &clip.packets {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    drain(&mut dec, &mut delivered, &mut escalation);
    assert!(
      escalation.is_none(),
      "no escalation while still on the HW path"
    );
  }
  assert!(dec.is_hardware(), "still HW until the EOF-time failure");
  assert_eq!(
    delivered,
    clip.packets.len(),
    "HW must deliver the whole stream before the EOF-time failure"
  );

  // EOF triggers the post-commit fallback; the cold SW decoder is fed only EOF.
  dec
    .send_eof()
    .expect("send_eof drives the fallback but itself succeeds");
  assert!(
    dec.is_software(),
    "the EOF-time failure fell back to software"
  );
  assert!(
    dec.degraded_resync_pending_for_test(),
    "post-commit fallback at EOF must enter degraded-resync mode (SW opened cold)"
  );

  // Draining the cold SW decoder hits EOF with the resync still pending →
  // escalation, not a silent empty tail.
  drain(&mut dec, &mut delivered, &mut escalation);

  let esc = escalation.expect(
    "a post-commit fallback whose SW decoder reaches EOF without resyncing must \
     ESCALATE, not silently swallow the tail as a clean end-of-stream",
  );
  let VideoDecodeError::PostCommitNeverResynced { packets_lost } = esc else {
    panic!("expected PostCommitNeverResynced, got {esc:?}");
  };
  assert_eq!(
    packets_lost, 0,
    "no packets crossed to SW on the EOF-entry path; the lost tail was HW-side"
  );
  assert!(
    dec.is_software(),
    "the decoder did fall back to software (it just never resynced)"
  );
  // The flag is cleared after escalating so a follow-up poll sees plain EOF
  // (not a repeated escalation).
  assert!(
    !dec.degraded_resync_pending_for_test(),
    "the degraded-resync flag must be cleared after the escalation fires"
  );
  let mut after = crate::empty_video_frame();
  match dec.receive_frame(&mut after) {
    Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => {}
    other => panic!("a poll after the escalation must be plain EOF, got {other:?}"),
  }
}

/// The gap counter via the `send_packet` arm: packets forwarded to SW while a
/// post-commit resync is still pending are tallied, and the tally — together
/// with the pending flag — is CLEARED the moment SW resyncs. This covers the
/// bounded-and-logged (resync happened) outcome's bookkeeping, the complement
/// of the escalate-at-EOF outcome.
#[test]
fn post_commit_gap_counter_tallies_then_clears_on_resync() {
  let (w, h) = (128u32, 96u32);
  // Keyframes at 0, 6, 12, 18. Fail two P-frames into GOP-2 so a GOP-3 keyframe
  // is still ahead to resync on.
  let clip = encode_synthetic_clip(w, h, 24, 6);
  let second_key = nth_keyframe(&clip, 2);
  let third_key = nth_keyframe(&clip, 3);
  let fail_at = second_key + 2;
  assert!(
    fail_at < third_key && !clip.packets[fail_at].is_key(),
    "fail target must be a mid-GOP P-frame before the next keyframe"
  );

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(
      w,
      h,
      fail_at,
      fail_at,
      FailShape::PostCommit,
    )),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  // Feed packets [0, fail_at]: the prefix decodes on HW (no drain needed — the
  // fake buffers them), and the send at `fail_at` triggers the post-commit
  // fallback, which forwards that one current packet to the freshly-opened SW
  // decoder. We do NOT drain here: a single forwarded packet won't trip SW
  // backpressure, and not draining keeps the gap open so the tally is
  // observable before any resync frame clears it.
  let mut dst = crate::empty_video_frame();
  for av_pkt in clip.packets.iter().take(fail_at + 1) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
  }
  assert!(
    dec.is_software(),
    "the mid-GOP failure fell back to software"
  );
  assert!(
    dec.degraded_resync_pending_for_test(),
    "the gap is still open (no resync frame drained yet)"
  );
  // Exactly the forwarded current packet crossed the gap from the send_packet
  // arm so far — the tally proves gap packets are counted.
  assert_eq!(
    dec.degraded_packets_since_fallback_for_test(),
    1,
    "the forwarded current packet must be tallied as crossing the gap"
  );

  // Drive to a KEYFRAME-ANCHORED resync. The forwarded current packet and the
  // gap P-frames are lone P-frames; mpeg4 will conceal frames from them, but the
  // keyframe-gated guard must NOT clear on those — only a frame delivered after
  // the resync keyframe (third_key) is fed counts. So we feed remaining packets,
  // draining as we go, and assert the guard stays pending until the keyframe is
  // reached, then clears once a frame is delivered after it. One poll per send:
  // `true` if a frame was delivered, `false` on EAGAIN/EOF.
  let mut try_poll = |dec: &mut FfmpegVideoStreamDecoder| -> bool {
    match dec.receive_frame(&mut dst) {
      Ok(()) => true,
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
        if errno == ffmpeg_next::error::EAGAIN =>
      {
        false
      }
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => false,
      Err(e) => panic!("unexpected drain error: {e:?}"),
    }
  };
  // First, fully drain whatever the already-forwarded P-frame yields. Any
  // concealed frame here must leave the guard pending (no keyframe fed yet).
  while try_poll(&mut dec) {}
  assert!(
    dec.degraded_resync_pending_for_test(),
    "a concealed frame from the forwarded P-frame must NOT clear the guard — no \
     keyframe has crossed the gap yet"
  );
  assert!(
    !dec.degraded_keyframe_seen_for_test(),
    "no keyframe fed yet, so the keyframe-seen anchor must be unset"
  );

  // Feed remaining packets up to (not including) the resync keyframe: still all
  // P-frames, so concealed frames may land but the guard must stay pending.
  // Drain fully each time so the keyframe send below never hits SW backpressure.
  for av_pkt in clip.packets[(fail_at + 1)..third_key].iter() {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    while try_poll(&mut dec) {}
    assert!(
      dec.degraded_resync_pending_for_test() && !dec.degraded_keyframe_seen_for_test(),
      "concealed P-frame frames before the keyframe must not clear the guard or \
       set the keyframe anchor"
    );
  }

  // Feed the resync keyframe. Sending it records the anchor immediately (the
  // keyframe crossed the gap) — observe that BEFORE draining, since the resync
  // frame's delivery clears the whole degraded state. The guard is still pending
  // here: the anchor is set, but no post-keyframe frame has been delivered yet.
  assert!(third_key < clip.packets.len(), "clip has a third keyframe");
  let key_vpkt =
    boundary::video_packet_from_ffmpeg(&clip.packets[third_key]).expect("packet has a buffer");
  dec.send_packet(&key_vpkt).expect("send_packet");
  assert!(
    dec.degraded_keyframe_seen_for_test(),
    "feeding the keyframe across the gap must record it as the resync anchor"
  );

  // Now drive (keyframe + remainder) draining until a post-keyframe frame lands
  // and clears the guard — the keyframe-anchored resync.
  let mut resynced = !dec.degraded_resync_pending_for_test();
  while !resynced && try_poll(&mut dec) {
    resynced = !dec.degraded_resync_pending_for_test();
  }
  for av_pkt in clip.packets[(third_key + 1)..].iter() {
    if resynced {
      break;
    }
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    while !resynced && try_poll(&mut dec) {
      resynced = !dec.degraded_resync_pending_for_test();
    }
  }
  assert!(
    resynced,
    "SW must resync once the keyframe is fed and produce a frame after it"
  );
  assert!(
    !dec.degraded_resync_pending_for_test(),
    "the keyframe-anchored resync must clear the pending flag"
  );
  assert_eq!(
    dec.degraded_packets_since_fallback_for_test(),
    0,
    "resync must reset the gap counter"
  );
}

// ---------------------------------------------------------------------------
//  Keyframe-gated resync (finding 2): a concealed P-frame must NOT clear it
// ---------------------------------------------------------------------------

/// **Finding-2 regression.** A post-commit fallback fires, then the SW decoder
/// emits *concealed* frames from lone P-frames **before any keyframe** arrives,
/// and EOF is reached with no keyframe ever fed. The resync guard is
/// **keyframe-gated**, so those concealed frames must NOT clear it: the loss
/// must still ESCALATE with `PostCommitNeverResynced` at EOF, exactly as if no
/// frame had been delivered. (Before the gate, the first concealed P-frame
/// cleared `degraded_resync_pending`, faking a resync that never happened and
/// silently swallowing the lost tail.)
///
/// Determinism: a cold mpeg4 SW decoder fed lone P-frames from a mid-GOP point
/// **does** emit concealed frames (verified), so this reliably exercises
/// "a frame was delivered but no keyframe was fed". We fail post-commit at
/// `second_key + 2` (a P-frame the cold decoder accepts without InvalidData),
/// forward it + the rest of GOP-2's P-frames, then send EOF — never feeding the
/// GOP-3 keyframe.
#[test]
fn post_commit_concealed_p_frame_does_not_clear_resync_escalates_at_eof() {
  let (w, h) = (128u32, 96u32);
  // Keyframes at 0, 6, 12, 18. Fail at second_key + 2 so the forwarded current
  // packet is a mid-GOP P-frame the cold mpeg4 decoder accepts and conceals.
  let clip = encode_synthetic_clip(w, h, 24, 6);
  let second_key = nth_keyframe(&clip, 2);
  let third_key = nth_keyframe(&clip, 3);
  let fail_at = second_key + 2;
  assert!(
    fail_at < third_key && !clip.packets[fail_at].is_key(),
    "fail target must be a mid-GOP P-frame before the next keyframe"
  );

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(
      w,
      h,
      fail_at,
      fail_at,
      FailShape::PostCommit,
    )),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");

  let mut dst = crate::empty_video_frame();
  let mut concealed_frames = 0usize;
  let mut escalation: Option<VideoDecodeError> = None;
  // Drain available frames; route a `PostCommitNeverResynced` to `escalation`.
  let mut drain = |dec: &mut FfmpegVideoStreamDecoder,
                   concealed: &mut usize,
                   escalation: &mut Option<VideoDecodeError>| loop {
    match dec.receive_frame(&mut dst) {
      Ok(()) => *concealed += 1,
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
        if errno == ffmpeg_next::error::EAGAIN =>
      {
        break;
      }
      Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
      Err(e @ VideoDecodeError::PostCommitNeverResynced { .. }) => {
        *escalation = Some(e);
        break;
      }
      Err(e) => panic!("unexpected drain error: {e:?}"),
    }
  };

  // Feed packets [0, third_key): the HW prefix, the post-commit failure at
  // `fail_at`, and the GOP-2 P-frames — but NEVER the GOP-3 keyframe. Each drain
  // may deliver a concealed frame; none may clear the keyframe-gated guard.
  for av_pkt in clip.packets.iter().take(third_key) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    drain(&mut dec, &mut concealed_frames, &mut escalation);
    assert!(escalation.is_none(), "no escalation before EOF");
    if dec.is_software() {
      // Once degraded, the guard must stay pending and unanchored — no keyframe
      // has crossed the gap, only (possibly concealed) P-frame frames.
      assert!(
        dec.degraded_resync_pending_for_test(),
        "a concealed P-frame must not clear the keyframe-gated resync guard"
      );
      assert!(
        !dec.degraded_keyframe_seen_for_test(),
        "no keyframe was fed, so the keyframe-seen anchor must stay unset"
      );
    }
  }
  assert!(
    dec.is_software(),
    "the post-commit failure fell back to software"
  );
  assert!(
    concealed_frames > 0,
    "the cold mpeg4 SW decoder must have concealed at least one frame from the \
     lone P-frames (otherwise this test does not exercise the 'frame delivered \
     but no keyframe' path)"
  );
  assert!(
    dec.degraded_resync_pending_for_test(),
    "after feeding only P-frames the guard must still be pending — the concealed \
     frames did NOT count as a resync"
  );

  // EOF with no keyframe ever fed: the guard is still pending → escalate, not a
  // silent clean end-of-stream.
  dec.send_eof().expect("send_eof on the SW path");
  drain(&mut dec, &mut concealed_frames, &mut escalation);
  let esc = escalation.expect(
    "concealed P-frames must NOT have cleared the guard, so reaching EOF without a \
     keyframe must ESCALATE with PostCommitNeverResynced",
  );
  let VideoDecodeError::PostCommitNeverResynced { packets_lost } = esc else {
    panic!("expected PostCommitNeverResynced, got {esc:?}");
  };
  assert!(
    packets_lost >= 1,
    "every forwarded gap packet (current P-frame + the GOP-2 tail) must be \
     tallied as lost; got {packets_lost}"
  );
  assert!(
    !dec.degraded_resync_pending_for_test(),
    "the guard is cleared after the escalation fires"
  );
}

// ---------------------------------------------------------------------------
//  Post-commit retains ZERO replay frames (finding 1 dissolution)
// ---------------------------------------------------------------------------

/// **Finding-1 dissolution.** The post-commit path retains and reconstructs no
/// replay frames at all — it opens SW cold and forwards only the current packet
/// (or EOF). So the drained-replay-frame queue (`sw_replay_frames`), whose
/// later per-frame *conversion* finding 1 was about, is never populated on the
/// post-commit path: there is no deferred conversion that could reopen the
/// recovery hole. We assert the queue is empty right after a post-commit
/// fallback fires and stays empty as the stream is driven — there is simply
/// nothing to convert-after-commit.
#[test]
fn post_commit_retains_no_replay_frames() {
  let (w, h) = (128u32, 96u32);
  let clip = encode_synthetic_clip(w, h, 24, 6);
  let second_key = nth_keyframe(&clip, 2);
  let third_key = nth_keyframe(&clip, 3);
  let fail_at = second_key + 2;
  assert!(
    fail_at < third_key && !clip.packets[fail_at].is_key(),
    "fail target must be a mid-GOP P-frame before the next keyframe"
  );

  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let mut dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(
    Box::new(FakeHw::failing(
      w,
      h,
      fail_at,
      fail_at,
      FailShape::PostCommit,
    )),
    clip.parameters.clone(),
    tb,
  )
  .expect("build test decoder");
  assert!(
    dec.sw_replay_frames_is_empty_for_test(),
    "no replay frames before any fallback"
  );

  // Feed packets [0, fail_at] WITHOUT draining: the send at `fail_at` fires the
  // post-commit fallback. If the post-commit path drained frames into the replay
  // queue (the removed terminal-drain behaviour), they would sit there now.
  for av_pkt in clip.packets.iter().take(fail_at + 1) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    assert!(
      dec.sw_replay_frames_is_empty_for_test(),
      "the post-commit path must retain ZERO replay frames — nothing is drained \
       into the replay queue, so there is no deferred conversion (finding 1)"
    );
  }
  assert!(
    dec.is_software(),
    "the mid-GOP failure fell back to software"
  );
  assert!(
    dec.degraded_resync_pending_for_test(),
    "post-commit fallback entered degraded mode (sanity)"
  );

  // Drive the rest of the stream; the replay queue must remain empty throughout
  // — the SW decoder delivers directly from itself, never from a replay buffer.
  let mut dst = crate::empty_video_frame();
  for av_pkt in clip.packets.iter().skip(fail_at + 1) {
    let vpkt = boundary::video_packet_from_ffmpeg(av_pkt).expect("packet has a buffer");
    dec.send_packet(&vpkt).expect("send_packet");
    loop {
      match dec.receive_frame(&mut dst) {
        Ok(()) => {}
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          break;
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
        Err(e) => panic!("unexpected drain error: {e:?}"),
      }
    }
    assert!(
      dec.sw_replay_frames_is_empty_for_test(),
      "the post-commit path never populates the replay queue"
    );
  }
}

// ---------------------------------------------------------------------------
//  Placeholder seam smoke check
// ---------------------------------------------------------------------------

/// The inert seam builds a decoder on the HW path without driving anything —
/// guards `from_hw_inner_for_test` + the trimmed struct against regressions.
#[test]
fn inert_seam_builds_on_hardware() {
  ffmpeg_next::init().expect("ffmpeg init");
  let params = ffmpeg_next::codec::Parameters::new();
  let tb = Timebase::new(1, NonZeroU32::new(25).expect("nonzero"));
  let dec = FfmpegVideoStreamDecoder::from_hw_inner_for_test(Box::new(FakeHw::inert()), params, tb)
    .expect("build test decoder");
  assert!(dec.is_hardware(), "inert seam starts on the HW path");
  assert!(!dec.is_software());
}

// ---------------------------------------------------------------------------
//  Deferred real-fixture integration test
// ---------------------------------------------------------------------------

/// Real-hardware counterpart to
/// [`post_commit_failure_degrades_and_resyncs_at_next_keyframe`]: drive an
/// actual Sony FX3 H.264 **High 4:2:2 10-bit** clip through the real
/// VideoToolbox path and observe whether the post-commit degrade-and-continue
/// fallback survives a *real* H.264 codec (the synthetic tests use a lenient
/// mpeg4 SW decoder; this resolves whether that leniency masks a real defect —
/// see findit-studio/mediadecode#12).
///
/// This is an **instrumented experiment**, not a green-checkmark assertion. It
/// captures (a) the starting backend, (b) the HW→SW transition point, (c) the
/// per-frame PTS delivered and the gap at the fallback boundary, and (d)
/// whether the cold SW decoder resynced at the next keyframe and decoded the
/// remainder — or aborted on a pre-keyframe P-frame / never saw the keyframe.
/// All of it is printed under `--nocapture`. The hard assertions at the end
/// encode the **observed** real-codec behaviour on this fixture.
///
/// Gated on `MEDIADECODE_FX3_SAMPLE` (absolute path to the fixture); skips
/// cleanly when unset so `cargo test` stays green without it. Run with:
///
/// ```sh
/// MEDIADECODE_FX3_SAMPLE=/path/to/12_sony_fx3_xavc.mp4 \
///   cargo test -p mediadecode-ffmpeg --all-features \
///   fx3_high_422_10bit -- --ignored --nocapture
/// ```
#[test]
#[ignore = "requires a Sony FX3 H.264 High 4:2:2 10-bit fixture (user-provided); \
            set MEDIADECODE_FX3_SAMPLE to its path"]
fn fx3_high_422_10bit_falls_back_to_software_and_decodes_whole_stream() {
  use ffmpeg_next::{format, media};

  let Some(path) = std::env::var_os("MEDIADECODE_FX3_SAMPLE") else {
    eprintln!(
      "skipping: set MEDIADECODE_FX3_SAMPLE to the Sony FX3 H.264 422-10bit fixture path to run \
       this experiment"
    );
    return;
  };

  ffmpeg_next::init().expect("ffmpeg init");

  let mut input = format::input(&path).expect("open FX3 input");
  let stream = input
    .streams()
    .best(media::Type::Video)
    .expect("video stream");
  let stream_index = stream.index();
  // SAFETY: `stream.parameters()` exposes a live `*const AVCodecParameters`
  // for the duration of the borrow; reading the geometry fields is sound.
  let (expected_w, expected_h) = unsafe {
    let p = stream.parameters();
    ((*p.as_ptr()).width as u32, (*p.as_ptr()).height as u32)
  };
  // A nominal time base for frame labelling — the experiment only inspects the
  // coverage/ordering of the resulting PTS, not its real-time scale.
  let tb = Timebase::new(1, NonZeroU32::new(24).expect("nonzero"));

  let mut dec = match FfmpegVideoStreamDecoder::open(stream.parameters(), tb) {
    Ok(d) => d,
    Err(Error::AllBackendsFailed(p)) => {
      // No HW backend opened at all → the wrapper went straight to SW at
      // open-time (probe-era), never exercising the post-commit path. Nothing
      // to observe; record and skip rather than false-fail.
      eprintln!(
        "skipping: no hardware backend available at open ({} attempts) — the post-commit \
         degrade path needs a HW backend that COMMITS then fails at runtime",
        p.attempts().len()
      );
      return;
    }
    Err(e) => panic!("open FX3 decoder: {e:?}"),
  };

  let mut obs = Fx3Observation::new(dec.is_hardware());
  eprintln!(
    "FX3 experiment: {expected_w}x{expected_h}; started_on_hw={} (is_software={})",
    obs.started_on_hw,
    dec.is_software()
  );

  let mut dst = crate::empty_video_frame();

  'feed: for (s, packet) in input.packets() {
    if s.index() != stream_index {
      continue;
    }
    let is_key = packet.is_key();
    let pkt_pts = packet.pts();
    let Some(vpkt) = boundary::video_packet_from_ffmpeg(&packet) else {
      continue; // empty packet (no buffer) — skip
    };

    // send_packet, draining on EAGAIN.
    let mut attempts = 0u32;
    loop {
      match dec.send_packet(&vpkt) {
        Ok(()) => break,
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          if let Err(err) = obs.drain(&mut dec, &mut dst) {
            obs.abort = Some(format!("during send #{} EAGAIN-drain: {err}", obs.send_idx));
            break 'feed;
          }
          attempts += 1;
          assert!(
            attempts <= 64,
            "send_packet stuck on EAGAIN at send #{}",
            obs.send_idx
          );
        }
        Err(e) => {
          // A non-transient error surfacing from `send_packet` itself — capture
          // the variant. This is where a forwarded current packet that the cold
          // SW rejects would land (Codex finding 1's send-arm shape).
          obs.abort = Some(format!(
            "send_packet #{} (key={is_key}, pts={pkt_pts:?}) errored: {e:?}",
            obs.send_idx
          ));
          break 'feed;
        }
      }
    }
    if let Err(err) = obs.drain(&mut dec, &mut dst) {
      obs.abort = Some(format!(
        "after send #{} (key={is_key}): {err}",
        obs.send_idx
      ));
      break 'feed;
    }
    obs.send_idx += 1;
  }

  // EOF + final drain (only if we did not already abort mid-feed).
  if obs.abort.is_none() {
    match dec.send_eof() {
      Ok(()) => {
        if let Err(err) = obs.drain(&mut dec, &mut dst) {
          obs.abort = Some(format!("during post-EOF drain: {err}"));
        }
      }
      Err(VideoDecodeError::PostCommitNeverResynced { packets_lost }) => {
        obs.escalated_never_resynced = Some(packets_lost);
      }
      Err(e) => obs.abort = Some(format!("send_eof errored: {e:?}")),
    }
  }

  // ----- Report -----------------------------------------------------------
  let ended_on_sw = dec.is_software();
  let unique: std::collections::HashSet<i64> = obs.pts_out.iter().copied().collect();
  eprintln!("FX3 experiment RESULT:");
  eprintln!("  started_on_hw        = {}", obs.started_on_hw);
  eprintln!(
    "  transitioned_to_sw   = {} (at send #{:?})",
    obs.transitioned_to_sw, obs.transition_send_idx
  );
  eprintln!("  ended_on_sw          = {ended_on_sw}");
  eprintln!(
    "  frames_delivered     = {} (unique pts = {})",
    obs.pts_out.len(),
    unique.len()
  );
  eprintln!("  delivered_pts        = {:?}", obs.pts_out);
  eprintln!(
    "  resync_pending@end   = {}",
    dec.degraded_resync_pending_for_test()
  );
  eprintln!(
    "  never_resynced_esc   = {:?}",
    obs.escalated_never_resynced
  );
  eprintln!("  abort                = {:?}", obs.abort);

  // ----- Assertions on the OBSERVED behaviour -----------------------------
  // (1) The fixture must commit on HW first — otherwise this is not the
  //     post-commit path and the experiment is inconclusive (skip-shaped).
  assert!(
    obs.started_on_hw,
    "expected to start on the VideoToolbox HW path; if it opened straight to SW the post-commit \
     path was never exercised on this run"
  );

  // (2) A real HW runtime failure must have driven a transparent mid-stream
  //     HW->SW transition (the core #12 fix behaviour).
  assert!(
    obs.transitioned_to_sw && ended_on_sw,
    "expected a transparent mid-stream HW->SW fallback on the real FX3 clip (VideoToolbox cannot \
     decode H.264 High 4:2:2 10-bit at runtime); observed transition={}, ended_on_sw={ended_on_sw}, \
     abort={:?}",
    obs.transitioned_to_sw,
    obs.abort
  );

  // (3) The drive must not have ABORTED on a hard error before EOF. A
  //     pre-keyframe P-frame InvalidData (Codex finding 1) or a missed
  //     keyframe surfacing as a hard error would land here.
  assert!(
    obs.abort.is_none(),
    "the degrade-and-continue path aborted before EOF on the real H.264 codec: {:?} — this would \
     be Codex R7's finding reproducing on a real (non-lenient) codec",
    obs.abort
  );

  // (4) The fallback must have RESYNCED at the next keyframe and decoded the
  //     remainder — i.e. it did NOT escalate `PostCommitNeverResynced`, and
  //     the resync guard is clear at EOF. A bounded gap at the failure
  //     boundary is acceptable; never reaching a keyframe is the failure.
  assert!(
    obs.escalated_never_resynced.is_none(),
    "the cold SW decoder never resynced at a keyframe before EOF (PostCommitNeverResynced, {:?} \
     packets lost) — the whole tail was dropped; Codex R7's finding 2 (HW swallowed the keyframe / \
     cold SW never saw it) reproduces on real H.264",
    obs.escalated_never_resynced
  );
  assert!(
    !dec.degraded_resync_pending_for_test(),
    "a post-commit resync was still pending at EOF — SW never proved a keyframe-anchored resync"
  );

  // (5) Having resynced, SW must have delivered a non-trivial set of frames
  //     from the remainder, every one a real PTS, no duplicates.
  assert!(
    !obs.pts_out.is_empty(),
    "no frames were delivered at all — neither HW prefix nor SW remainder"
  );
  assert!(
    !obs.pts_out.contains(&i64::MIN),
    "every delivered frame must carry a real PTS: {:?}",
    obs.pts_out
  );
  assert_eq!(
    unique.len(),
    obs.pts_out.len(),
    "the degrade path must not re-emit a frame (no duplicate PTS): {:?}",
    obs.pts_out
  );
}

/// Instrumentation accumulator for the FX3 experiment: the observed backend
/// trajectory (HW start, the HW→SW transition point), the delivered PTS, and
/// any terminal error / escalation. Bundled into one value so the drive loop's
/// drain step is a single method call instead of threading seven `&mut`s.
struct Fx3Observation {
  /// Whether the decoder opened on the HW path (the precondition for
  /// exercising the post-commit degrade path at all).
  started_on_hw: bool,
  /// Set once the SW path is first observed active mid-drive.
  transitioned_to_sw: bool,
  /// `send_packet` index at which the HW→SW transition was first observed.
  transition_send_idx: Option<usize>,
  /// 0-based index of the current `send_packet`, advanced by the drive loop.
  send_idx: usize,
  /// PTS of every delivered frame, in delivery order (`i64::MIN` marks a hole).
  pts_out: Vec<i64>,
  /// `Debug` of the terminal error if the drive aborted before EOF.
  abort: Option<String>,
  /// `packets_lost` if the fallback escalated `PostCommitNeverResynced`.
  escalated_never_resynced: Option<u64>,
}

impl Fx3Observation {
  fn new(started_on_hw: bool) -> Self {
    Self {
      started_on_hw,
      transitioned_to_sw: false,
      transition_send_idx: None,
      send_idx: 0,
      pts_out: Vec::new(),
      abort: None,
      escalated_never_resynced: None,
    }
  }

  /// Note the HW→SW transition the first time the SW path is observed active
  /// (which can be before the cold SW produces any frame — it withholds output
  /// until the resync keyframe).
  fn note_transition(&mut self, dec: &FfmpegVideoStreamDecoder, frame_pending: bool) {
    if !self.transitioned_to_sw && dec.is_software() {
      self.transitioned_to_sw = true;
      self.transition_send_idx = Some(self.send_idx);
      let detail = if frame_pending {
        format!("frames delivered so far: {}", self.pts_out.len())
      } else {
        "no frame yet — cold SW awaiting resync keyframe".to_string()
      };
      eprintln!(
        "  -> HW->SW transition observed at/after send #{} ({detail})",
        self.send_idx
      );
    }
  }

  /// Drain every ready frame, recording delivered PTS and any escalation.
  /// Returns `Err(Debug)` on a non-transient decode error — the decisive
  /// observation, since the most-feared shape (Codex finding 1) is the cold SW
  /// decoder returning `InvalidData` / missing-reference on a pre-keyframe
  /// P-frame.
  fn drain(
    &mut self,
    dec: &mut FfmpegVideoStreamDecoder,
    dst: &mut VideoFrame<mediadecode::PixelFormat, VideoFrameExtra, FfmpegBuffer>,
  ) -> Result<(), String> {
    loop {
      match dec.receive_frame(dst) {
        Ok(()) => {
          self.note_transition(dec, true);
          let pts = VideoFrame::pts(dst).map(|t| t.pts()).unwrap_or(i64::MIN);
          self.pts_out.push(pts);
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Other { errno })))
          if errno == ffmpeg_next::error::EAGAIN =>
        {
          self.note_transition(dec, false);
          break;
        }
        Err(VideoDecodeError::Decode(Error::Ffmpeg(ffmpeg_next::Error::Eof))) => break,
        Err(VideoDecodeError::PostCommitNeverResynced { packets_lost }) => {
          self.escalated_never_resynced = Some(packets_lost);
          eprintln!(
            "  -> PostCommitNeverResynced at EOF: {packets_lost} packets fed to SW produced no \
             frame (no keyframe crossed the gap)"
          );
          break;
        }
        Err(e) => return Err(format!("{e:?}")),
      }
    }
    Ok(())
  }
}