video: drop pictures a decoder cannot reconstruct at a start, join or resync

MPEG-2, H.264, HEVC and VC-1 drop pictures whose references are missing
at a title start, clip join, gap resync or a trimmed clip mark; the
M2TS sink rides the clip-join timeline. An image extent's sub-unit tail
is judged by the decrypt stage instead of refused (E9030).
Wall-clock-sensitive tests hardened; monthly grouped dependabot.
This commit is contained in:
Matthew Jackson
2026-10-02 20:24:26 -07:00
parent f855d604ae
commit f745a6f2e6
35 changed files with 2081 additions and 391 deletions
+13 -32
View File
@@ -1,52 +1,33 @@
# Version updates are monthly and grouped into one PR per ecosystem.
# Security updates are separate and run whenever an advisory is published.
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that freemkv's release-orchestrate.yml
# moves to each tag. A bot commit on it would put work there that no tag
# contains, which is exactly the state that aborted the 1.6.2 cascade at the
# last step -- so pointing Dependabot at main would recreate that failure on a
# schedule.
updates:
- package-ecosystem: cargo
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
interval: monthly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate.
groups:
minor-and-patch:
update-types:
- minor
- patch
all:
patterns:
- '*'
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release-orchestrate.yml in the release commit. A bot bump would fight
# the release cascade and could pin a tag that does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
- package-ecosystem: github-actions
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
interval: monthly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
all:
patterns:
- '*'
ignore:
# Versioned by the Rust release it installs; CI picks the toolchain via
# `toolchain:` on @master/@stable. Dependabot proposes Rust versions that
# do not exist. Toolchain bumps are a deliberate all-repo change.
- dependency-name: dtolnay/rust-toolchain
# The org's own reusable workflows/actions. @v1 and @v2 are independent
# moving tags (v2 is only the comment guard), not semver releases: a
# "bump" from v1 to v2 would move a caller onto an OLDER guard.
- dependency-name: "freemkv/.github*"
- dependency-name: freemkv/.github*
+5
View File
@@ -28,6 +28,8 @@
### Output-format changes
- **MPEG-2 video starts on a decodable picture:** pictures before the first I-picture, and the leading B-pictures of an open GOP whose forward reference is not in the output (at the start of a title, after a `broken_link` GOP, or where the timeline jumps at a join), are left out instead of showing as a green or corrupt frame. Every kept frame keeps its PTS, so audio sync is unchanged; a title that opens on a closed GOP is unchanged.
- **H.264, HEVC and VC-1 video start on a decodable picture, and every codec resumes cleanly:** pictures before the first random-access picture are left out, and so are leading pictures that predict from a picture the output does not hold: at the start of a title, at a join (the decode clock or PTS moves at the random-access picture), at a VC-1 `BROKEN_LINK`, and at the keyframe the resync gate resumes on after a gap (MPEG-2 too). These are H.264/VC-1 open-GOP leading B-pictures and HEVC RASL pictures (also after a stream-start CRA, which a conforming decoder discards anyway); an MVC dependent-view unit goes with its base picture, and a CRA at any join is written as BLA (before, only after a PTS step back of over 3 s). Where a clip's IN or OUT mark trims a picture another one predicts from, that one goes too, up to the next keyframe (and its leading pictures). Every kept frame keeps its PTS, so audio sync is unchanged; these are not counted as lost data. A closed-GOP or IDR-only title is unchanged.
- **MKV DefaultDuration of MPEG-2 video** follows the kept frames' measured period when that is another standard rate: a soft-telecined NTSC film title (DVD) declares 23.976 fps instead of the sequence's 29.97, so the frame count agrees with the duration. Video at its sequence rate is unchanged.
- **LPCM** (BD and DVD) is decoded to interleaved big-endian PCM in WAVE_FORMAT_EXTENSIBLE channel order (BD pad channel removed, LFE/surround remapped, DVD 20/24-bit groups unpacked) at the source depth: 16-bit stays 16-bit, 20- and 24-bit sources output 24-bit; MKV `BitDepth` matches, and M2TS/MPG output keeps the depth. BD 20-bit packets are kept (24-bit container); reserved header codes are dropped. On M2TS output LPCM is repacked to the BD layout; LPCM BD cannot carry (e.g. 44.1 kHz) is dropped and reported.
- **M2TS:** reordered video (H.264, HEVC, MPEG-2, VC-1) now carries a DTS (H.222.0 2.7.5); PTS round to the nearest 90 kHz tick (a PTS can be 1 tick later than 1.7.7), the first frame lands one second above zero, PCR never decreases with B-frames; output always declares `bd-ts` in its FMKV header. PGS `.sup` ticks use the same rounding.
@@ -96,6 +98,9 @@
### Fixed
- A program-stream PES that opens inside one picture times the next picture commencing in it (ISO/IEC 13818-1 §2.4.3.7), not the one it opens in. A program stream packing pictures back to back gave each picture its successor's PTS, so MPEG-2 GOPs re-locked a frame off and an open GOP's leading B-pictures were dropped as after a join. DVD and HD DVD titles, which open a PES at each timed picture, are unchanged.
- `m2ts://` output of a multi-clip title rides the clip-join timeline like the MKV, MP4 and MPG sinks: the PTS no longer restarts at each join, and reordered video keeps its DTS after the first join instead of falling back to PTS only.
- An image title whose extent ends off the 3-sector AACS unit grid (an HD DVD `.EVO`) no longer fails E9030 at that extent's end: the sub-unit tail is read, passed when clear and refused (E7013) when it is part of an encrypted unit.
- Every AACS format takes one encryption decision in `KeyRing::acquire`: content its own detector reads in the clear needs no key and asks no source, whatever key files the disc declares (an HD DVD copy in the clear declaring several keys, and an FMTS disc whose forensic segments are also clear, no longer ask for or refuse on keys); encrypted content is keyed by the format's rules and refused (E7022 / E7032) when no source has the key.
- A zero read batch (`MuxOptions::default()` carries `batch_sectors` 0) is refused with `Error::MuxBatchSectorsZero` (E9085) on every path (live, session, ISO, URL) instead of spinning until Stop.
- A stopped or failed read can no longer finalise a truncated MKV / M2TS as a complete title; `extract` after a drive Stop leaves the file `.partial` instead of finalising zeros.
+49 -30
View File
@@ -438,26 +438,28 @@ fn wait_ready_stop_during_poll_and_start_unit() {
}
}
/// LD11a (GUARD, T5): transport failures broken by a drive answer every 0.5 × the
/// dead-bus budget, for 4 budgets, never count as a dead bus.
/// LD11a (GUARD, T5): transport failures broken by a drive answer every few polls,
/// for more than the dead-bus budget, never count as a dead bus.
#[test]
fn dead_bus_budget_resets_on_any_answer() {
let budget = MS(100);
let t0 = Instant::now();
let mut last = Instant::now();
let budget = Duration::from_secs(1);
// Polls are at least 10 ms apart, so 150 polls outlast the budget.
let mut n = 0u32;
let t = Script::new(move |_, d| {
if t0.elapsed() >= budget * 4 {
n += 1;
if n >= 150 {
return good(d);
}
if last.elapsed() >= budget / 2 {
last = Instant::now();
if n.is_multiple_of(5) {
return Err(not_ready(0x04, 0x01));
}
Err(fault())
});
let mut d = Drive::from_transport(Box::new(t));
let t0 = Instant::now();
let r = d.wait_ready_with(timing(Duration::from_secs(60), budget));
assert!(r.is_ok(), "{r:?}");
assert!(t0.elapsed() > budget, "the run outlasted the budget");
}
/// LD11b / G5 (GUARD): an unbroken run of transport failures for the budget is a
@@ -475,29 +477,29 @@ fn dead_bus_unbroken_run_fails() {
assert!(t0.elapsed() < Duration::from_secs(1));
}
/// LD12a: 04/01 with a progress indication rising every 0.5 × window for 4 windows,
/// LD12a: 04/01 with a progress indication rising every 0.25 × window for 3 windows,
/// then ready → `Ok`. Corroborated by SS-1: the field is "a percent complete indication".
#[test]
fn wait_ready_rides_out_progressing_spin_up() {
let window = MS(200);
let window = Duration::from_secs(1);
let t0 = Instant::now();
let (t, cell) = Script::with_progress(move |_, d| {
if t0.elapsed() >= window * 4 {
let cell: Arc<Mutex<Option<u16>>> = Arc::default();
let c2 = cell.clone();
let t = Script(
Box::new(move |_, d| {
let at = t0.elapsed();
if at >= window * 3 {
return good(d);
}
let step = (at.as_millis() / (window.as_millis() / 4)) as u16;
*c2.lock().unwrap() = Some(1000 * (step + 1));
Err(not_ready(0x04, 0x01))
});
let t0b = Instant::now();
let mover = std::thread::spawn(move || {
while t0b.elapsed() < window * 4 {
let step = (t0b.elapsed().as_millis() / (window.as_millis() / 2)) as u16;
*cell.lock().unwrap() = Some(1000 * (step + 1));
std::thread::sleep(MS(5));
}
});
}),
None,
cell,
);
let mut d = Drive::from_transport(Box::new(t));
let r = d.wait_ready_with(timing(window, MS(5_000)));
mover.join().unwrap();
assert!(r.is_ok(), "a progressing spin-up is not a stall: {r:?}");
assert!(t0.elapsed() >= window * 2, "ran past two windows");
}
@@ -526,19 +528,28 @@ fn wait_ready_gives_up_after_60s_without_progress() {
/// (The stricter reading of "the answer changes", §2.11; the user may overrule it.)
#[test]
fn wait_ready_new_answer_is_progress_flapping_is_not() {
let window = MS(200);
let t0 = Instant::now();
let window = Duration::from_secs(2);
let fresh = [0x00u8, 0x01, 0x04, 0x07, 0x08, 0x09, 0x0A, 0x11, 0x22];
// A new answer a quarter-window of wall time after the last one: 8 answers outlast
// the window, and each lands a window-minus-quarter before it would expire, however
// late a slow runner wakes the polls.
let mut step = 0usize;
let mut last = Instant::now();
let t = Script::new(move |_, d| {
let step = (t0.elapsed().as_millis() / (window.as_millis() / 2)) as usize;
if last.elapsed() >= window / 4 {
step += 1;
last = Instant::now();
}
match fresh.get(step) {
Some(&q) if step < 8 => Err(not_ready(0x04, q)),
_ => good(d),
}
});
let mut d = Drive::from_transport(Box::new(t));
let t0 = Instant::now();
let r = d.wait_ready_with(timing(window, MS(5_000)));
assert!(r.is_ok(), "new answers re-arm the window: {r:?}");
assert!(t0.elapsed() > window, "the run outlasted one window");
let mut n = 0u32;
let t = Script::new(move |_, _| {
@@ -552,17 +563,21 @@ fn wait_ready_new_answer_is_progress_flapping_is_not() {
let t0 = Instant::now();
let r = d.wait_ready_with(timing(window, MS(5_000)));
assert!(matches!(r, Err(Error::DeviceNotReady { .. })), "{r:?}");
assert!(t0.elapsed() < window + Duration::from_secs(1));
assert!(t0.elapsed() < window + Duration::from_secs(5));
}
/// LD12d (GUARD): a Stop at 0.75 × window, and mid-progress, ends the wait at once.
/// LD12d (GUARD): a Stop mid-wait, with and without progress, ends the wait at once.
/// The Stop lands after a few polls, long before the window could expire.
#[test]
fn wait_ready_stop_always_interrupts() {
let window = MS(400);
let window = Duration::from_secs(60);
for progressing in [false, true] {
let h = Halt::new();
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let p2 = polls.clone();
let mut q = 0u8;
let t = Script::new(move |_, _| {
p2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if progressing {
q = q.wrapping_add(1) % 0x30;
}
@@ -571,14 +586,18 @@ fn wait_ready_stop_always_interrupts() {
let mut d = Drive::from_transport_with(Box::new(t), &h);
let h2 = h.clone();
let stopper = std::thread::spawn(move || {
std::thread::sleep(window * 3 / 4);
let deadline = Instant::now() + Duration::from_secs(30);
while polls.load(std::sync::atomic::Ordering::SeqCst) < 5 {
assert!(Instant::now() < deadline, "the wait never polled");
std::thread::sleep(MS(1));
}
h2.cancel();
Instant::now()
});
let r = d.wait_ready_with(timing(window, MS(5_000)));
let at = stopper.join().unwrap();
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
assert!(at.elapsed() < Duration::from_secs(1));
assert!(at.elapsed() < Duration::from_secs(5));
}
}
+11 -9
View File
@@ -5,8 +5,8 @@ use std::sync::{Arc, Barrier};
// Test windows are scaled: a 60 s production window is 100 ms here (stop design §5.0).
const WINDOW: Duration = Duration::from_millis(100);
// Headroom on wall-clock upper bounds for a loaded CI runner.
const SLACK: Duration = Duration::from_millis(500);
// Headroom on wall-clock upper bounds for a loaded CI runner: 10× a 200 ms sleep overshoot.
const SLACK: Duration = Duration::from_secs(2);
fn catch<T>(f: impl FnOnce() -> T) -> std::thread::Result<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
@@ -345,23 +345,25 @@ fn join_within_finished_cancelled_and_expired() {
));
}
/// LT5a (HR1): slow but progressing — a bump every 0.2 × window for 4 windows —
/// never expires.
/// LT5a (HR1): slow but progressing — a bump every 0.2 × window for 2.5 windows —
/// never expires. The window is 1 s so a 200 ms stall of the polling thread, between a
/// bump and the next poll, still lands inside it.
#[test]
fn stall_timer_rearms_on_progress() {
let window = Duration::from_secs(1);
let p = Liveness::new();
let mut t = StallTimer::new(WINDOW, &p);
let mut t = StallTimer::new(window, &p);
let start = Instant::now();
let mut next_bump = start + WINDOW / 5;
while start.elapsed() < WINDOW * 4 {
let mut next_bump = start + window / 5;
while start.elapsed() < window * 5 / 2 {
if Instant::now() >= next_bump {
p.bump();
next_bump += WINDOW / 5;
next_bump += window / 5;
}
assert_ne!(t.poll(&p), Stall::Expired, "at {:?}", start.elapsed());
std::thread::sleep(Duration::from_millis(5));
}
assert!(p.get() >= 12);
assert!(p.get() >= 6);
}
/// LT5b (HR1): no progress → `StalledFor` until the window, then `Expired` within
+9 -8
View File
@@ -8,8 +8,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Instant;
const WINDOW: Duration = Duration::from_millis(200);
const SLACK: Duration = Duration::from_secs(1);
// A 1 s window and 2 s upper bounds keep a 200 ms sleep overshoot inside them.
const WINDOW: Duration = Duration::from_secs(1);
const SLACK: Duration = Duration::from_secs(2);
fn artifact() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
@@ -37,8 +38,8 @@ fn sidecar_is_named_after_the_final_artifact() {
);
}
/// LP14a (T10, stall pair a): the holder appends to the `.partial` every 0.5 × window
/// for 4 windows, then releases: the waiter is never timed out and takes the lock.
/// LP14a (T10, stall pair a): the holder appends to the `.partial` every 0.2 × window
/// for over 3 windows, then releases: the waiter is never timed out and takes the lock.
#[test]
fn artifact_lock_waits_while_holder_progresses() {
let (_dir, out) = artifact();
@@ -46,8 +47,8 @@ fn artifact_lock_waits_while_holder_progresses() {
let partial = with_suffix(&out, ".partial");
let writer = thread::spawn(move || {
let mut f = File::create(&partial).unwrap();
for _ in 0..8 {
thread::sleep(WINDOW / 2);
for _ in 0..16 {
thread::sleep(WINDOW / 5);
f.write_all(b"sector").unwrap();
}
drop(holder);
@@ -155,8 +156,8 @@ fn artifact_lock_watches_extra_paths() {
let holder = take(&out, WINDOW).expect("first taker");
let m2 = mapfile.clone();
let writer = thread::spawn(move || {
for i in 0..8u8 {
thread::sleep(WINDOW / 2);
for i in 0..16u8 {
thread::sleep(WINDOW / 5);
std::fs::write(&m2, vec![i; usize::from(i) + 1]).unwrap();
}
drop(holder);
+22 -14
View File
@@ -231,28 +231,35 @@ mod tests {
fn bounded_syscall_halt_leaks_worker_without_blocking() {
let halt = Halt::new();
let h2 = halt.clone();
thread::spawn(move || {
let canceller = thread::spawn(move || {
thread::sleep(Duration::from_millis(30));
let at = Instant::now();
h2.cancel();
at
});
let alive = Arc::new(());
let held = alive.clone();
let t = Instant::now();
let r = bounded_syscall(Some(&halt), Duration::from_secs(10), move || {
thread::sleep(Duration::from_millis(200));
let release = Arc::new(AtomicBool::new(false));
let rel = release.clone();
let r = bounded_syscall(Some(&halt), Duration::from_secs(60), move || {
// Blocked until the test releases it: the wait cannot end by the op returning.
while !rel.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(5));
}
drop(held);
1u8
});
let returned = Instant::now();
let cancelled_at = canceller.join().unwrap();
assert!(matches!(r, Err(BoundedError::Halted)));
assert!(
t.elapsed() < Duration::from_millis(500),
"{:?}",
t.elapsed()
);
let woke = returned.saturating_duration_since(cancelled_at);
assert!(woke < Duration::from_secs(1), "{woke:?}");
assert!(Arc::strong_count(&alive) > 1, "the worker is still blocked");
release.store(true, Ordering::SeqCst);
let t = Instant::now();
while Arc::strong_count(&alive) > 1 {
assert!(
t.elapsed() < Duration::from_secs(2),
t.elapsed() < Duration::from_secs(30),
"the leaked worker wedged"
);
thread::sleep(Duration::from_millis(5));
@@ -274,24 +281,25 @@ mod tests {
/// progress is waited for; the same call with no progress times out at the window.
#[test]
fn stall_bound_rearms_on_progress_and_expires_without() {
let w = Duration::from_millis(100);
let w = Duration::from_secs(1);
let p = Liveness::new();
let mut timer = StallTimer::new(w, &p);
let bump = p.clone();
let r = bounded_syscall_stall(None, &p, &mut timer, &mut || bump.bump(), || {
thread::sleep(Duration::from_millis(400));
thread::sleep(Duration::from_millis(2500));
3u8
});
assert!(matches!(r, Ok(3)));
let mut timer = StallTimer::new(w, &p);
let t = Instant::now();
let r = bounded_syscall_stall(None, &p, &mut timer, &mut || {}, || {
thread::sleep(Duration::from_secs(2));
thread::sleep(Duration::from_secs(10));
0u8
});
assert!(matches!(r, Err(BoundedError::Timeout)));
assert!(t.elapsed() >= w, "fired before the window");
assert!(
t.elapsed() < Duration::from_millis(1100),
t.elapsed() < w + Duration::from_secs(2),
"{:?}",
t.elapsed()
);
+8 -10
View File
@@ -1,6 +1,7 @@
//! Stop design §5.1 LP13a–h, LP19 and §5.9 SP10–SP13, G19: the durable flush (T12) on
//! a scaled clock through [`FakeFlushOps`] (a 60 s window runs as [`W`]); the real
//! syscalls run on the runner's tmpdir. Wall bounds keep ≥ 5× margin.
//! syscalls run on the runner's tmpdir. The windows are 1 s and the upper bounds 2 s, so a
//! 200 ms sleep overshoot stays inside them.
use super::*;
use crate::io::WritebackFile;
@@ -9,8 +10,8 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize};
use std::time::Instant;
const W: Duration = Duration::from_millis(200);
const SLACK: Duration = Duration::from_secs(1);
const W: Duration = Duration::from_secs(1);
const SLACK: Duration = Duration::from_secs(2);
const K: u64 = 1024;
// Blocks callers while closed; `open` releases them.
@@ -319,7 +320,7 @@ fn stop_pending_close_ignores_mountstats() {
.flush()
.expect_err("no own progress for a window after the Stop");
assert!(is_sync_timeout(&e), "{e}");
assert!(t.elapsed() <= W + SLACK / 4, "bounded: {:?}", t.elapsed());
assert!(t.elapsed() <= W + SLACK / 2, "bounded: {:?}", t.elapsed());
}
/// LP13e (§2.10 item 2): the writer blocks once written − flushed exceeds 2 × C, and
@@ -367,7 +368,7 @@ fn chunk_halves_on_slow_flush() {
let ops = Arc::new(FakeFlushOps {
chunk_delay: Some(Box::new(|i| {
if i >= 2 {
Duration::from_millis(400)
Duration::from_millis(1600)
} else {
Duration::ZERO
}
@@ -375,7 +376,7 @@ fn chunk_halves_on_slow_flush() {
..FakeFlushOps::default()
});
let t = FlushTiming {
slow_chunk: Duration::from_millis(200),
slow_chunk: Duration::from_millis(800),
chunk_min: K,
chunk_max: 4 * K,
..timing(Duration::from_secs(30))
@@ -557,10 +558,7 @@ fn flusher_stall_during_writing_latches_e9056() {
let t = Instant::now();
let e = w.write(b"x").expect_err("sticky");
assert!(is_sync_timeout(&e), "{e}");
assert!(
t.elapsed() < Duration::from_millis(100),
"a latched error does not wait"
);
assert!(t.elapsed() < W * 3 / 4, "a latched error does not wait");
ops.chunk_gate.release();
}
+9 -12
View File
@@ -1696,25 +1696,22 @@ mod tests {
}
assert!(in_close.load(Ordering::SeqCst), "consumer reached close()");
// Finish the close only AFTER the first grace window has expired, so the
// caller genuinely reaches the abandon decision with a close in flight.
let rel = release.clone();
thread::spawn(move || {
// Release inside the second grace window. Old 600ms/300ms-grace intervals
// left no margin and flaked on loaded runners; scaled up so windows end
// at ~1.0-1.25s and ~2.0-2.25s, releasing at 1.6s for ~350ms slack.
thread::sleep(Duration::from_millis(1600));
rel.store(true, Ordering::SeqCst);
});
// The caller's own poll releases the close 0.3 grace into the second window: after
// the abandon decision, well before the CLOSING grace ends, on no other thread.
let grace = Duration::from_secs(1);
let started = Instant::now();
let rel = release.clone();
let res = finish_with_grace(
handle,
&state,
&Liveness::new(),
grace,
Error::Halted,
&mut || {},
&mut || {
if started.elapsed() >= grace * 13 / 10 {
rel.store(true, Ordering::SeqCst);
}
},
);
assert!(
matches!(res, Ok(42)),
+24 -22
View File
@@ -1,14 +1,17 @@
//! Stop design §5.1 "Pipeline, IO and threads": the join and grace rules (T7, T8) on a
//! scaled clock — a 600 s window runs as [`WINDOW`], the 5 s grace as [`GRACE`].
//! Stall pairs per §5.0: (a) progress at 0.5 × window for ≥ 4 windows is not a
//! timeout; (b) a stall fires within window (+ grace) + 1 s. Wall bounds keep ≥ 5× margin.
//! Stall pairs per §5.0: (a) progress at 0.2 × window for over 3 windows is not a
//! timeout; (b) a stall fires within window (+ grace) + [`SLACK`]. The windows are 1 s so
//! a 200 ms sleep overshoot between two progress bumps stays inside them.
use super::*;
use std::sync::atomic::AtomicUsize;
const WINDOW: Duration = Duration::from_millis(200);
const GRACE: Duration = Duration::from_millis(200);
const SLACK: Duration = Duration::from_secs(1);
const WINDOW: Duration = Duration::from_secs(1);
const GRACE: Duration = Duration::from_secs(1);
const SLACK: Duration = Duration::from_secs(2);
// One progress bump per STEP while a close runs.
const STEP: Duration = Duration::from_millis(200);
struct Sum(u64);
@@ -107,7 +110,7 @@ fn scripted(stall: Duration, steps: u32) -> (Pipeline<u64, u32>, Arc<AtomicBool>
progress: progress.clone(),
in_close: in_close.clone(),
stall,
step: GRACE / 2,
step: STEP,
steps,
};
let pipe = Pipeline::spawn_named_with_progress("t-closing", 4, sink, progress).unwrap();
@@ -137,7 +140,7 @@ fn halted_running_consumer_abandoned_after_grace() {
/// `close()` past the 5 s (scaled) grace while it bumps the consumer's progress.
#[test]
fn closing_before_cancel_is_done() {
let (pipe, in_close) = scripted(Duration::ZERO, 8);
let (pipe, in_close) = scripted(Duration::ZERO, 12);
let halt = Halt::new();
let h2 = halt.clone();
let t = Instant::now();
@@ -146,7 +149,7 @@ fn closing_before_cancel_is_done() {
halt.cancel();
let r = caller.join().unwrap();
assert!(
matches!(r, Ok(8)),
matches!(r, Ok(12)),
"a committed close is never abandoned: {r:?}"
);
assert!(
@@ -155,13 +158,13 @@ fn closing_before_cancel_is_done() {
);
}
/// LP4a (T7, stall pair a): one item applied per 0.25 × window for 4 windows is
/// LP4a (T7, stall pair a): one item applied per 0.2 × window for over 3 windows is
/// progress, so the join waits and returns `Ok` — 600 s is a stall window, not a total.
#[test]
fn join_rearms_on_consumer_progress() {
let count = Arc::new(AtomicUsize::new(0));
let sink = SlowSinkFor {
delay: WINDOW / 4,
delay: WINDOW / 5,
count: count.clone(),
};
let pipe = Pipeline::spawn(16, sink).unwrap();
@@ -280,16 +283,18 @@ fn send_with_halt_full_channel_cancel() {
let h2 = halt.clone();
let canceller = thread::spawn(move || {
thread::sleep(Duration::from_millis(50));
let at = Instant::now();
h2.cancel();
at
});
let t = Instant::now();
let r = pipe.send_with_halt(7, &halt, Duration::from_secs(10));
let took = t.elapsed();
canceller.join().unwrap();
let returned = Instant::now();
let cancelled_at = canceller.join().unwrap();
let took = returned.saturating_duration_since(cancelled_at);
release.store(true, Ordering::SeqCst);
let _ = pipe.finish();
assert_eq!(r, Err(7), "the refused item comes back");
assert!(took < Duration::from_millis(50) + SLACK / 2, "{took:?}");
assert!(took < SLACK / 2, "{took:?}");
}
/// G7: a non-halted pipeline that finishes normally returns `Ok` with its output
@@ -303,11 +308,7 @@ fn non_halted_happy_path_returns_at_once() {
let t = Instant::now();
let r = pipe.finish_with_halt(None);
assert!(matches!(r, Ok(10)), "{r:?}");
assert!(
t.elapsed() < Duration::from_millis(500),
"{:?}",
t.elapsed()
);
assert!(t.elapsed() < Duration::from_secs(5), "{:?}", t.elapsed());
}
/// The consumer bumps its progress per item applied and at `close()` entry and
@@ -376,7 +377,7 @@ struct PartialRun {
pipe: Pipeline<u64, ()>,
}
// A pipeline over a `PartialFile` sink whose closes take `steps` × GRACE / 2.
// A pipeline over a `PartialFile` sink whose closes take `steps` × STEP.
fn partial_run(steps: u32) -> PartialRun {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("Movie.mkv");
@@ -388,7 +389,7 @@ fn partial_run(steps: u32) -> PartialRun {
partial: partial.clone(),
out: out.clone(),
progress: progress.clone(),
step: GRACE / 2,
step: STEP,
steps,
done: done.clone(),
};
@@ -426,7 +427,8 @@ fn closing_after_cancel_keeps_partial() {
/// cannot produce a final-named file.
#[test]
fn closing_after_cancel_gets_one_grace_and_never_renames() {
let run = partial_run(8);
// The close outlasts both graces (join, then the one CLOSING grace) by a full second.
let run = partial_run(20);
let halt = Halt::new();
halt.cancel();
let t = Instant::now();
+1 -1
View File
@@ -877,7 +877,7 @@ mod tests {
}
fn slow_wait(_fd: RawFd, _off: u64, _len: u64) -> i32 {
std::thread::sleep(Duration::from_millis(300));
std::thread::sleep(Duration::from_secs(2));
0
}
+4 -4
View File
@@ -276,7 +276,7 @@ mod tests {
fn wait_room_requests_unrequested_bytes() {
let file = tempfile::tempfile().unwrap();
let timing = FlushTiming {
stall: Duration::from_millis(400),
stall: Duration::from_secs(5),
slow_chunk: Duration::from_secs(10),
chunk_min: 100,
chunk_max: 100,
@@ -300,7 +300,7 @@ mod tests {
fn wait_room_requests_bytes_unrequested_while_blocked() {
let file = tempfile::tempfile().unwrap();
let timing = FlushTiming {
stall: Duration::from_millis(400),
stall: Duration::from_secs(5),
slow_chunk: Duration::from_secs(10),
chunk_min: 100,
chunk_max: 100,
@@ -352,7 +352,7 @@ mod tests {
fn a_chunk_error_is_latched_and_reported_as_itself() {
let file = tempfile::tempfile().unwrap();
let timing = FlushTiming {
stall: Duration::from_millis(400),
stall: Duration::from_secs(5),
slow_chunk: Duration::from_secs(10),
chunk_min: 100,
chunk_max: 100,
@@ -370,7 +370,7 @@ mod tests {
let e = f.drain(100, None, None).expect_err("the chunk failed");
assert_eq!(e.raw_os_error(), Some(28), "{e}");
assert!(
start.elapsed() < Duration::from_millis(300),
start.elapsed() < Duration::from_millis(2500),
"waited a stall"
);
assert_eq!(f.error().and_then(|e| e.raw_os_error()), Some(28));
+1 -1
View File
@@ -734,7 +734,7 @@ mod tests {
assert_eq!(w.flush_progress().bytes_total(), 405);
assert_eq!(fresh.bytes_total(), 405, "writes count on the new counters");
let start = std::time::Instant::now();
while fresh.bytes_durable() == 0 && start.elapsed() < std::time::Duration::from_secs(2) {
while fresh.bytes_durable() == 0 && start.elapsed() < std::time::Duration::from_secs(30) {
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
+77
View File
@@ -1014,6 +1014,83 @@ fn hddvd_in_the_clear_needs_no_key_whatever_it_declares() {
}
}
// Drain a keyed image mux of `title` over `src`: the stream's end, or its error.
fn drain_keyed_image<S: SectorSource + Send + 'static>(
src: S,
title: DiscTitle,
set: &KeyRing,
) -> std::io::Result<()> {
use crate::pes::PesSource;
let _serial = crate::sector::prefetched::holder_test_lock();
let policy = crate::sector::read_stage::ReadPolicy::Image { batch: 30 };
let ctx = crate::ctx::Ctx::default();
let mut stream =
crate::mux::resolve::build_keyed_pipeline(src, title, set, policy, &ctx).unwrap();
loop {
match stream.read() {
Ok(Some(_)) => continue,
Ok(None) => return Ok(()),
Err(e) => return Err(e),
}
}
}
/// An HD DVD `.EVO` need not end on the 3-sector unit grid: a clear title whose extent ends
/// one sector past a whole unit reads that tail and finishes, not E9030.
#[test]
fn hddvd_clear_title_reads_a_sub_unit_extent_tail() {
let mut clip = vec![0u8; 33 * 2048];
for sector in clip.chunks_mut(2048) {
sector[..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
sector[4..].fill(0x44);
}
let files = [
BdFile::new("HVDVD_TS/FEATURE.EVO", 33, None).with_clip(clip),
BdFile::new("BDMV/index.bdmv", 1, None),
];
let uk_ro = unit_key_ro(AacsVersion::V10, &[[0xEE; 16]; 64], &[1]);
let img = encrypted_bd_image(&files, &uk_ro);
let mut disc = disc_over(&img, &uk_ro, &[&[0]], DiscFormat::HdDvd);
let start = img.files[0].0;
disc.titles[0].extents = vec![Extent {
start_lba: start,
sector_count: 31,
}];
let fx = Fx { img, disc };
let calls = Calls::default();
let set = resolve(&fx, KeyScope::Titles(vec![0]), &[Spec::keydb(&[], &calls)]).unwrap();
let src = CountingSource::new(MemSource::new(fx.img.image.clone()));
let log = src.log();
drain_keyed_image(src, fx.disc.titles[0].clone(), &set)
.unwrap_or_else(|e| panic!("a clear sub-unit tail refused: {e}"));
assert!(
log.touched(start + 30, start + 31),
"the tail sector is read"
);
assert!(
!log.touched(start + 31, start + 33),
"nothing past the extent"
);
}
/// A sub-unit extent tail that is the head of an encrypted unit cannot be decrypted: the
/// mux refuses it (E7013) rather than pass its ciphertext on as clear.
#[test]
fn an_encrypted_sub_unit_extent_tail_is_refused() {
let mut fx = fixture(&[stream(1, 10, Some(K1))], 1, &[&[0]]);
let calls = Calls::default();
let set = resolve(
&fx,
KeyScope::Titles(vec![0]),
&[Spec::keydb(&[K1], &calls)],
)
.unwrap();
fx.disc.titles[0].extents[0].sector_count = 29;
let err = drain_keyed_image(fx.source(), fx.disc.titles[0].clone(), &set)
.expect_err("an encrypted fragment must not pass as clear");
assert_eq!(crate::error_code(&err), Some(E7013), "{err}");
}
/// An HD DVD set's unit spans stay in LBA order across fragmented and interleaved pieces:
/// `span_at` bisects them.
#[test]
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) struct PlayItem {
pub in_time: u32,
/// Out-time in 45kHz ticks
pub out_time: u32,
/// Connection condition (1=seamless, 5/6=non-seamless). Parsed for
/// Connection condition (1=non-seamless, 5/6=seamless). Parsed for
/// completeness; no production reader yet.
#[allow(dead_code)]
pub connection_condition: u8,
+50 -6
View File
@@ -129,10 +129,13 @@ enum Mode {
}
/// A timing/source mark taken at the absolute stream offset of a fragment that
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
/// whose byte range contains it.
/// carried it, so it survives `buf.drain(..)`. Its source goes to the AU whose
/// byte range contains `off`; its PTS/DTS to the first AU that commences within
/// `[off, end)` (ISO/IEC 13818-1 §2.4.3.7), else to the AU containing `off`.
struct Mark {
off: u64,
/// Absolute offset one past the fragment's last byte.
end: u64,
pts: Option<i64>,
dts: Option<i64>,
source: Option<SourcePos>,
@@ -276,6 +279,7 @@ impl AuAssembler {
if pts.is_some() || dts.is_some() || source.is_some() {
self.marks.push_back(Mark {
off,
end: off + data.len() as u64,
pts,
dts,
source,
@@ -355,10 +359,15 @@ impl AuAssembler {
// PTS, so reading only the front mark would drop a field.
let (mut pts, mut dts, mut source) = (None, None, None);
while self.marks.front().is_some_and(|m| m.off < end_abs) {
let m = self.marks.front_mut().unwrap();
source = source.or(m.source.take());
// A fragment opened inside this AU that runs past it times the next AU.
if m.off > self.base && m.end > end_abs {
break;
}
let m = self.marks.pop_front().unwrap();
pts = pts.or(m.pts);
dts = dts.or(m.dts);
source = source.or(m.source);
}
// A backstop discard is a gap in its own right, independent of any
// upstream signal: bytes were thrown away, so this AU does not
@@ -489,10 +498,10 @@ impl AuAssembler {
None
}
// Retire every mark before `off`: the STREAM-START case, where bytes ahead of the first AU
// boundary predate sync and have no prior AU to be discontinuous from.
// Retire every mark ending at or before `off`: the STREAM-START case, where bytes ahead of
// the first AU boundary predate sync. A fragment running past `off` still times that AU.
fn drop_marks_before(&mut self, off: u64) {
while self.marks.front().is_some_and(|m| m.off < off) {
while self.marks.front().is_some_and(|m| m.end <= off) {
self.marks.pop_front();
}
while self.disc_marks.front().is_some_and(|&o| o < off) {
@@ -792,6 +801,41 @@ mod tests {
assert_eq!(tail[0].pts, Some(9376));
}
#[test]
fn a_pts_fragment_opened_inside_a_picture_times_the_picture_commencing_in_it() {
// Pictures packed back to back: each PES opens 5 bytes into the previous
// picture and its PTS is the next picture's. Read as the previous one's, every
// later picture took its successor's PTS.
let pics: Vec<Vec<u8>> = (0..3u8).map(|i| bdu(MP2_PICTURE, 0x10 + i, 20)).collect();
let stream = pics.concat();
let mut a = AuAssembler::mpeg2();
let mut out = a.push(&stream[..5], Some(100), None, None, false);
out.extend(a.push(&stream[5..29], Some(200), None, None, false));
out.extend(a.push(&stream[29..53], Some(300), None, None, false));
out.extend(a.push(&stream[53..], None, None, None, false));
out.extend(a.flush());
let pts: Vec<_> = out.iter().map(|au| au.pts).collect();
assert_eq!(pts, [Some(100), Some(200), Some(300)]);
assert_eq!(
out.iter().map(|au| au.data.clone()).collect::<Vec<_>>(),
pics
);
}
#[test]
fn a_pre_sync_fragment_times_the_first_access_unit_commencing_in_it() {
let mut a = AuAssembler::mpeg2();
let mut frag = vec![0xFF; 6];
frag.extend(bdu(MP2_PICTURE, 0x11, 10));
let mut out = a.push(&frag, Some(700), None, None, false);
out.extend(a.push(&bdu(MP2_PICTURE, 0x22, 10), Some(800), None, None, false));
out.extend(a.flush());
assert_eq!(
out.iter().map(|au| au.pts).collect::<Vec<_>>(),
[Some(700), Some(800)]
);
}
#[test]
fn mpeg2_slice_codes_are_not_au_boundaries() {
// Slice start codes (0x01..=0xAF) inside a picture must not split the AU.
+6
View File
@@ -226,6 +226,12 @@ impl PictureInfo {
}
}
/// Whether a later picture may predict from this one. An MPEG-2 B-picture never is a
/// reference; other codecs' B-pictures may be (H.264/HEVC), or are not decoded that far.
pub(crate) fn may_be_referenced(&self) -> bool {
!(self.coding_type == CodingType::B && matches!(self.detail, CodingDetail::Mpeg2(_)))
}
/// I-picture ⇒ cue/keyframe point. Convenience over `coding_type()`.
pub fn keyframe(&self) -> bool {
self.coding_type == CodingType::I
+198
View File
@@ -0,0 +1,198 @@
//! Which pictures of a one-picture-per-PES video stream (H.264, HEVC, VC-1) a decoder can
//! reconstruct from the pictures this output holds. MPEG-2 decides per GOP in its own parser.
//!
//! Pictures are decided in decode order against the anchors the output holds: none at the
//! stream start, at a join (the decode clock or PTS moves at a random-access picture), at a
//! broken link, or after a gap (from the keyframe the `ResyncGate` resumes on; until then
//! the gate drops and counts). A picture that needs a missing anchor is dropped and its
//! discontinuity flag carries to the next emitted frame. Dropped pictures keep their display
//! slots, so every kept frame keeps its PTS.
use super::Frame;
/// What a picture needs from the pictures before it in decode order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Need {
/// Not a picture, or one decodable from its own random-access picture: kept.
Nothing,
/// The frame's keyframe. `closed`: its leading pictures need no earlier picture;
/// `broken`: the anchor before it is not the one its leading pictures were coded against.
Rap { closed: bool, broken: bool },
/// Intra picture that is not a random-access point: kept, and an anchor.
Intra,
/// Predicted anchor: needs the last anchor, then is one.
Anchor,
/// Leading picture: needs the anchor before its random-access picture unless that is closed.
Leading,
/// Leading when displayed before its random-access picture, else trailing.
ByPts,
/// Trailing non-anchor picture: needs one anchor.
Trailing,
}
// Most leading pictures assumed where only PTS tells a join (no DTS): no stream bounds how
// many pictures display before a random-access picture and decode after it.
const MAX_LEADING: i64 = 16;
// Frame period assumed before one is measured (24 fps).
const FALLBACK_PERIOD_NS: i64 = 1_000_000_000 / 24;
// PTS steps below this are not a frame period (none is under 1/120 s).
const MIN_PERIOD_NS: i64 = 5_000_000;
/// Per-stream decodability state; see the module docs.
pub(crate) struct Decodable {
/// Anchors the output holds, saturating at 2.
held: u8,
/// Whether the current random-access picture is closed.
rap_closed: bool,
/// PTS (unwrapped ns) of the current random-access picture.
rap_pts: Option<i64>,
/// A discontinuity was seen; the held anchors reset at the next keyframe.
gap: bool,
/// A dropped picture's discontinuity flag, carried to the next emitted frame.
carry_discontinuity: bool,
/// Highest PTS seen (unwrapped ns): the display frontier a random-access picture continues.
high: Option<i64>,
/// Last PTS seen (unwrapped ns), for the frame period.
last: Option<i64>,
/// Smallest PTS step seen between consecutive pictures (ns).
period: Option<i64>,
/// Decode time (unwrapped ns: DTS, else PTS) of the last picture.
last_decode: Option<i64>,
/// Accumulated 33-bit wrap offset (ns).
wrap: i64,
/// Whether a random-access picture always displays after every earlier picture (HEVC
/// IRAP, VC-1 entry point), so any backward step at one is a join.
strict: bool,
}
// 2^33 90 kHz ticks in ns: the PTS wrap.
const WRAP_NS: i64 = (1i64 << 33) * 100_000 / 9;
impl Decodable {
pub(crate) fn new(strict: bool) -> Self {
Self {
held: 0,
rap_closed: false,
rap_pts: None,
gap: false,
carry_discontinuity: false,
high: None,
last: None,
period: None,
last_decode: None,
wrap: 0,
strict,
}
}
fn unwrap(&self, pts: i64) -> i64 {
let u = pts + self.wrap;
match self.high {
Some(h) if h - u > WRAP_NS / 2 => u + WRAP_NS,
_ => u,
}
}
/// Whether a random-access picture at `pts` / `dts` (ns) starts a join. With a DTS (it
/// has leading pictures only then), the decode clock moved by other than ~one frame from
/// the last picture; else its PTS does not continue the display frontier within a run of
/// leading pictures, or (strict) steps back.
pub(crate) fn joined(&self, pts: Option<i64>, dts: Option<i64>) -> bool {
if let (Some(d), Some(last)) = (dts, self.last_decode) {
let p = self.period.unwrap_or(FALLBACK_PERIOD_NS);
// A 2:3 pulldown frame decodes 1.5 periods after the one before it.
let step = self.unwrap(d) - last;
return step < p / 2 || step > p * 7 / 4;
}
let (Some(pts), Some(high)) = (pts, self.high) else {
return false;
};
// A forward move is judged only once a frame period is measured.
let window = |p: i64| (MAX_LEADING + 1) * p + p / 2;
let step = self.unwrap(pts) - high;
let forward = self.period.is_some_and(|p| step > window(p));
let back = if self.strict {
step < 0
} else {
step < -window(self.period.unwrap_or(FALLBACK_PERIOD_NS))
};
forward || back
}
// Move the frontier, decode clock and measured period to `pts` / `dts`; a join re-bases
// the frontier.
fn observe(&mut self, pts: i64, dts: Option<i64>, joined: bool) {
let u = self.unwrap(pts);
self.wrap = u - pts;
if let Some(last) = self.last {
let d = (u - last).abs();
if !joined && d >= MIN_PERIOD_NS && self.period.is_none_or(|p| d < p) {
self.period = Some(d);
}
}
self.last = Some(u);
self.last_decode = Some(dts.map_or(u, |d| self.unwrap(d)));
self.high = Some(match self.high {
Some(h) if !joined => h.max(u),
_ => u,
});
}
/// Decide `frame` (decode order); `None` drops it. `pts` / `dts` are its PES timestamps,
/// as carried.
pub(crate) fn admit(
&mut self,
mut frame: Frame,
need: Need,
pts: Option<i64>,
dts: Option<i64>,
) -> Option<Frame> {
let rap = matches!(need, Need::Rap { .. });
let joined = rap && self.joined(pts, dts);
if let Some(p) = pts {
self.observe(p, dts, joined);
}
if frame.discontinuity {
self.gap = true;
}
if self.gap && frame.keyframe {
self.gap = false;
self.held = 0;
}
let ok = match need {
Need::Nothing => true,
Need::Rap { closed, broken } => {
if joined || broken {
self.held = 0;
}
self.rap_closed = closed;
self.rap_pts = pts.map(|p| self.unwrap(p));
true
}
Need::Intra => true,
Need::Anchor | Need::Trailing => self.held >= 1,
Need::Leading => self.held >= self.leading_need(),
Need::ByPts => {
let leading =
matches!((pts, self.rap_pts), (Some(p), Some(r)) if self.unwrap(p) < r);
self.held >= if leading { self.leading_need() } else { 1 }
}
};
if ok && matches!(need, Need::Rap { .. } | Need::Intra | Need::Anchor) {
self.held = (self.held + 1).min(2);
}
if ok {
frame.discontinuity |= std::mem::take(&mut self.carry_discontinuity);
Some(frame)
} else {
self.carry_discontinuity |= frame.discontinuity;
None
}
}
fn leading_need(&self) -> u8 {
if self.rap_closed { 1 } else { 2 }
}
}
+257 -20
View File
@@ -6,6 +6,7 @@
//! Each PES packet = one access unit = one frame.
use super::coding::{CodingType, PictureInfo};
use super::decodable::{Decodable, Need};
use super::startcode::{BitReader, find_start_code, skip_start_code};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
@@ -54,6 +55,11 @@ pub struct H264Parser {
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
/// view's avcC/param-set stripping is unchanged (separate parser instance).
mvc_passthrough: bool,
/// Which pictures decode from the pictures this output holds (not the MVC dependent
/// view, whose access units follow their base picture's fate).
decodable: Decodable,
/// What each frame held by `reorder` needs, in decode order.
needs: std::collections::VecDeque<Need>,
}
impl Default for H264Parser {
@@ -72,6 +78,8 @@ impl H264Parser {
cur_pps: None,
reorder: None,
mvc_passthrough: false,
decodable: Decodable::new(false),
needs: std::collections::VecDeque::new(),
}
}
@@ -80,6 +88,8 @@ impl H264Parser {
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
// Reconstructed PTS are approximate: only a step past the leading window is a join.
self.decodable = Decodable::new(false);
}
self
}
@@ -91,13 +101,39 @@ impl H264Parser {
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
/// Route a finished frame through the PTS reorderer when enabled, then keep it
/// only if it decodes from what this output holds.
fn finish(
&mut self,
explicit: Option<i64>,
dts: Option<i64>,
frame: Frame,
need: Need,
) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
Some(r) => {
self.needs.push_back(need);
let out = r.push(explicit, frame);
self.decide(out)
}
None => self
.decodable
.admit(frame, need, explicit, dts)
.into_iter()
.collect(),
}
}
// Decide frames the reorderer released (decode order, display PTS assigned).
fn decide(&mut self, frames: Vec<Frame>) -> Vec<Frame> {
frames
.into_iter()
.filter_map(|f| {
let need = self.needs.pop_front().unwrap_or(Need::Nothing);
let pts = Some(f.pts_ns);
self.decodable.admit(f, need, pts, None)
})
.collect()
}
}
@@ -198,6 +234,7 @@ impl CodecParser for H264Parser {
// player reorders). Use PTS not DTS: DTS presents B-frames in decode
// order (visible judder) and breaks PTS-based seeking; fall back only if absent.
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let dts = pes.pts.and(pes.dts).map(pts_to_ns);
let pts_ns = explicit_pts.unwrap_or(0);
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
@@ -285,9 +322,20 @@ impl CodecParser for H264Parser {
// Open-GOP anchor heuristic (recovery_point SEI §D.2.8 is authoritative, not read):
// promote when every slice of the first picture is intra, a following P field
// allowed, base view only. Else the resync gate can drop frames to EOF.
let idr = keyframe;
if saw_vcl && all_vcl_intra && !mvc {
keyframe = true;
}
// A B displayed before its random-access picture is a leading picture; an IDR has none.
let need = match coding_type {
_ if mvc || !saw_vcl => Need::Nothing,
_ if keyframe => Need::Rap {
closed: idr,
broken: false,
},
Some(CodingType::B) => Need::ByPts,
_ => Need::Anchor,
};
// Every keyframe is self-contained: re-assert active SPS/PPS in-band so a
// decoder that dropped the set recovers, and a stale avcC re-apply can't
@@ -329,12 +377,15 @@ impl CodecParser for H264Parser {
data: frame_data,
duration_ns: None,
};
self.finish(explicit_pts, frame)
self.finish(explicit_pts, dts, frame, need)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
Some(r) => {
let out = r.flush();
self.decide(out)
}
None => Vec::new(),
}
}
@@ -908,6 +959,192 @@ mod tests {
);
}
/// A parser that has emitted an IDR at PTS 0, so an inter picture after it decodes.
fn primed() -> H264Parser {
let mut p = H264Parser::new();
let idr = p.parse(&make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(0)));
assert!(idr[0].keyframe);
p
}
// --- Pictures a decoder cannot reconstruct from the stream's own data ---
/// One non-IDR picture AU: `hdr` = NAL header byte, `body` = slice-header byte
/// (0x88 I, 0x98 P, 0x9C B), at `frame` 24 fps frame periods (90 kHz).
fn pic_au(hdr: u8, body: u8, frame: i64) -> PesPacket {
make_pes(vec![0x00, 0x00, 0x01, hdr, body, 0x00], Some(frame * 3750))
}
/// An open GOP in decode order `I3 B1 B2 P6 B4 B5` (display frame numbers),
/// offset by `base` frames. B1/B2 precede the I in display order: leading
/// pictures predicted from the anchor BEFORE this GOP.
fn open_gop(base: i64) -> Vec<PesPacket> {
vec![
pic_au(0x61, 0x88, base + 3),
pic_au(0x01, 0x9C, base + 1),
pic_au(0x01, 0x9C, base + 2),
pic_au(0x41, 0x98, base + 6),
pic_au(0x01, 0x9C, base + 4),
pic_au(0x01, 0x9C, base + 5),
]
}
/// Emitted frames' display frame numbers.
fn frame_nos(frames: &[Frame]) -> Vec<i64> {
frames
.iter()
.map(|f| (f.pts_ns * 24 + 500_000_000) / 1_000_000_000)
.collect()
}
#[test]
fn an_open_gop_at_the_stream_start_drops_its_leading_b_pictures() {
let mut p = H264Parser::new();
let f: Vec<Frame> = open_gop(0).iter().flat_map(|x| p.parse(x)).collect();
assert_eq!(
frame_nos(&f),
[3, 6, 4, 5],
"B1/B2 reference a picture the output does not hold"
);
}
#[test]
fn a_join_onto_an_open_gop_drops_its_leading_b_pictures() {
// Clip 1 ends on a complete open GOP; clip 2's PTS restarts far away, so
// its leading B1/B2 reference a picture of the clip the playlist skipped.
let mut p = H264Parser::new();
let mut pes = vec![make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(0))];
pes.extend(open_gop(0).into_iter().skip(3));
pes.extend(open_gop(10_000));
let f: Vec<Frame> = pes.iter().flat_map(|x| p.parse(x)).collect();
assert_eq!(
frame_nos(&f)[4..],
[10_003, 10_006, 10_004, 10_005],
"clip 2's leading Bs reference clip 1's tail, not their own source"
);
}
#[test]
fn pictures_before_the_first_random_access_picture_are_dropped() {
let mut p = H264Parser::new();
let mut pes = vec![pic_au(0x41, 0x98, 0), pic_au(0x01, 0x9C, 1)];
pes.push(make_pes(
vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00],
Some(2 * 3750),
));
let f: Vec<Frame> = pes.iter().flat_map(|x| p.parse(x)).collect();
assert!(f[0].keyframe, "the first emitted picture is the IDR");
}
#[test]
fn a_gap_resync_onto_an_open_gop_emits_no_leading_b_pictures() {
// IDR, then a P that follows lost data (and the B predicted from it), then
// an open GOP continuing the same clock: its B1/B2 reference the dropped P.
let mut p = H264Parser::new();
let mut pes = vec![make_pes(
vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00],
Some(7 * 3750),
)];
let mut lost = pic_au(0x41, 0x98, 9);
lost.discontinuity = true;
pes.push(lost);
pes.push(pic_au(0x01, 0x9C, 8));
pes.extend(open_gop(10));
let mut gate = crate::mux::resync::ResyncGate::new();
let f: Vec<Frame> = pes
.iter()
.flat_map(|x| p.parse(x))
.filter(|f| gate.admit(true, f.discontinuity, f.keyframe))
.collect();
assert_eq!(
frame_nos(&f),
[7, 13, 16, 14, 15],
"B1/B2 reference the P the gate dropped"
);
}
#[test]
fn a_gap_leaves_its_loss_to_the_gate() {
// The P after the gap and the B predicted from it decode from the stream's own
// anchors, so the parser passes them: the gate drops and counts them as loss.
let mut p = H264Parser::new();
let mut lost = pic_au(0x41, 0x98, 9);
lost.discontinuity = true;
let pes = [
make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(7 * 3750)),
lost,
pic_au(0x01, 0x9C, 8),
];
let f: Vec<Frame> = pes.iter().flat_map(|x| p.parse(x)).collect();
assert_eq!(frame_nos(&f), [7, 9, 8]);
let mut gate = crate::mux::resync::ResyncGate::new();
for x in &f {
gate.admit(true, x.discontinuity, x.keyframe);
}
assert_eq!(gate.dropped_total(), 2);
}
#[test]
fn a_continuous_open_gop_keeps_its_leading_b_pictures() {
// The leading B7/B8 predict from P6, which the output holds.
let mut p = H264Parser::new();
let mut pes = vec![make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(0))];
pes.extend(open_gop(0).into_iter().skip(3));
pes.extend(open_gop(6));
let f: Vec<Frame> = pes.iter().flat_map(|x| p.parse(x)).collect();
assert_eq!(frame_nos(&f), [0, 6, 4, 5, 9, 7, 8, 12, 10, 11]);
}
/// `pic_au` carrying a DTS (`dts` frames) when it differs from its PTS.
fn pic_au_dts(hdr: u8, body: u8, frame: i64, dts: i64) -> PesPacket {
let mut p = pic_au(hdr, body, frame);
p.dts = (dts != frame).then_some(dts * 3750);
p
}
// Decode order IDR P B B, then an open GOP I B B P whose I decodes `skip` frames late.
fn decode_clock_run(skip: i64) -> Vec<i64> {
let s = skip;
let pes = [
pic_au_dts(0x65, 0x88, 1, 0),
pic_au_dts(0x41, 0x98, 4, 1),
pic_au_dts(0x01, 0x9C, 2, 2),
pic_au_dts(0x01, 0x9C, 3, 3),
pic_au_dts(0x61, 0x88, 7 + s, 4 + s),
pic_au_dts(0x01, 0x9C, 5 + s, 5 + s),
pic_au_dts(0x01, 0x9C, 6 + s, 6 + s),
pic_au_dts(0x41, 0x98, 10 + s, 7 + s),
];
let mut p = H264Parser::new();
frame_nos(&pes.iter().flat_map(|x| p.parse(x)).collect::<Vec<_>>())
}
#[test]
fn the_decode_clock_tells_a_join_of_a_frame_or_two() {
assert_eq!(decode_clock_run(0), [1, 4, 2, 3, 7, 5, 6, 10]);
// Within the PTS-only window, but the decode clock skipped: another clip.
assert_eq!(decode_clock_run(2), [1, 4, 2, 3, 9, 12]);
}
#[test]
fn a_closed_gop_title_keeps_every_picture() {
let idr =
|frame: i64| make_pes(vec![0x00, 0x00, 0x01, 0x65, 0x88, 0x00], Some(frame * 3750));
let pes = [
idr(0),
pic_au(0x41, 0x98, 3),
pic_au(0x01, 0x9C, 1),
pic_au(0x01, 0x9C, 2),
idr(4),
pic_au(0x41, 0x98, 7),
pic_au(0x01, 0x9C, 5),
pic_au(0x01, 0x9C, 6),
];
let mut p = H264Parser::new();
let f: Vec<Frame> = pes.iter().flat_map(|x| p.parse(x)).collect();
assert_eq!(frame_nos(&f), [0, 3, 1, 2, 4, 7, 5, 6]);
}
// The escaped slice header must be unescaped at the parse call site: a second slice with
// first_mb_in_slice=65535 (00 00 03 escape) is intra only when read unescaped.
#[test]
@@ -945,7 +1182,7 @@ mod tests {
// Same NAL type 1, but slice_type = 0 (P): ue "1", so with first_mb = 0
// the byte is "1" + "1" padded = 0b1100_0000 = 0xC0.
let au = vec![0x00, 0x00, 0x01, 0x61, 0xC0, 0x00];
let mut parser = H264Parser::new();
let mut parser = primed();
let frames = parser.parse(&make_pes(au, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
@@ -971,7 +1208,7 @@ mod tests {
0x00, 0x00, 0x01, 0x61, 0x58, // P slice, first_mb 1 (same picture)
0x00,
];
let mut parser = H264Parser::new();
let mut parser = primed();
let frames = parser.parse(&make_pes(au, Some(0)));
assert_eq!(frames.len(), 1, "one PES access unit → one frame");
assert_eq!(
@@ -1363,7 +1600,7 @@ mod tests {
// 0x88='1 0001000'->I (7); 0x98='1 00110..'->P (5); 0x9C='1 00111..'->B (6).
let src = crate::pes::SourcePos::at_byte(8192);
let parse = |nal_type: u8, body: u8| {
let mut p = H264Parser::new();
let mut p = primed();
let mut pe = make_pes(h264_nal(nal_type, &[body]), Some(0));
pe.source = Some(src);
p.parse(&pe)
@@ -1573,7 +1810,7 @@ mod tests {
#[test]
fn parse_non_idr() {
let mut parser = H264Parser::new();
let mut parser = primed();
// PES with non-IDR slice (type 1 = 0x61 or 0x41)
let mut data = Vec::new();
@@ -1592,7 +1829,7 @@ mod tests {
#[test]
fn length_prefix_conversion() {
let mut parser = H264Parser::new();
let mut parser = primed();
// PES with a single non-IDR NAL
let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes
@@ -1683,7 +1920,7 @@ mod tests {
#[test]
fn pts_conversion() {
let mut parser = H264Parser::new();
let mut parser = primed();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
@@ -1711,7 +1948,7 @@ mod tests {
#[test]
fn pts_preferred_over_dts() {
let mut parser = H264Parser::new();
let mut parser = primed();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
@@ -1952,7 +2189,7 @@ mod tests {
// One real NAL at the end so the iterator yields something.
data.extend_from_slice(&[0x41, 0xAA, 0xBB]);
let mut parser = H264Parser::new();
let mut parser = primed();
let frames = parser.parse(&make_pes(data, Some(0)));
// Exactly one populated frame; the empty NALs are skipped without
// overflowing.
@@ -2068,7 +2305,7 @@ mod tests {
fn four_byte_start_code_parsed() {
// A 4-byte start code (00 00 00 01) must be skipped correctly so the NAL
// body begins at the right offset (skip_start_code returns pos+4).
let mut parser = H264Parser::new();
let mut parser = primed();
let data = vec![0x00, 0x00, 0x00, 0x01, 0x41, 0xAA, 0xBB];
let f = parser.parse(&make_pes(data, Some(0)));
assert_eq!(f.len(), 1);
@@ -2083,7 +2320,7 @@ mod tests {
// The byte(s) before a following 4-byte start code (00 00 00 01) are
// leading zeros of that start code, not RBSP, and must be stripped from
// the current NAL — NAL 1 must not absorb the extra 00.
let mut parser = H264Parser::new();
let mut parser = primed();
let mut data = vec![0x00, 0x00, 0x01, 0x41, 0xAA]; // NAL1 = 0x41 0xAA
data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x41, 0xBB]); // 4-byte SC
let f = parser.parse(&make_pes(data, Some(0)));
@@ -2098,7 +2335,7 @@ mod tests {
#[test]
fn aud_dropped_but_following_slice_kept() {
// AUD (type 9) is dropped from frame data; a following slice survives.
let mut parser = H264Parser::new();
let mut parser = primed();
let mut data = vec![0x00, 0x00, 0x01, 0x09, 0xF0]; // AUD
data.extend_from_slice(&[0x00, 0x00, 0x01, 0x41, 0xAA, 0xBB]); // slice
let f = parser.parse(&make_pes(data, Some(0)));
@@ -2126,7 +2363,7 @@ mod tests {
#[test]
fn dts_fallback_when_pts_absent() {
// PTS absent → DTS is used (or().map). pts.or(dts) per the comment.
let mut parser = H264Parser::new();
let mut parser = primed();
let pes = PesPacket {
source: None,
pid: 0x1011,
@@ -2142,7 +2379,7 @@ mod tests {
#[test]
fn no_pts_no_dts_defaults_zero() {
let mut parser = H264Parser::new();
let mut parser = primed();
let pes = PesPacket {
source: None,
pid: 0x1011,
+242 -99
View File
@@ -5,6 +5,7 @@
//! Each PES packet = one access unit = one frame.
use super::coding::{CodingType, PictureInfo};
use super::decodable::{Decodable, Need};
use super::startcode::{BitReader, find_start_code, skip_start_code};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
@@ -26,6 +27,15 @@ const SEI_CONTENT_LIGHT_LEVEL_INFO: u32 = 144;
// Dolby Vision RPU NALs (type 62) pass through: only VPS/SPS/PPS/AUD are filtered.
// IRAP types (keyframes): BLA, IDR, CRA
const NAL_BLA_W_LP: u8 = 16;
const NAL_BLA_N_LP: u8 = 18;
const NAL_IDR_W_RADL: u8 = 19;
const NAL_IDR_N_LP: u8 = 20;
// Leading pictures (H.265 Table 7-1): RADL decode from their IRAP alone, RASL also
// reference pictures before it.
const NAL_RADL_N: u8 = 6;
const NAL_RADL_R: u8 = 7;
const NAL_RASL_N: u8 = 8;
const NAL_RASL_R: u8 = 9;
const NAL_RSV_IRAP_VCL23: u8 = 23;
// CRA_NUT (Clean Random Access). A CRA at a splice carries RASL pictures
// referencing frames before the splice, gone after concatenation ("Could not
@@ -115,17 +125,14 @@ pub struct HevcParser {
/// ids are 0..=63 (H.265 §7.4.3.3); an out-of-range id is ignored.
pps_num_extra: [Option<u32>; HEVC_MAX_PPS_COUNT],
// Splice-aware CRA→BLA rewrite for a non-seamless BD clip boundary (first
// CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by PTS-backstep
// auto-detect in `parse`, or by the public `mark_clip_boundary` hook (no in-tree caller).
// CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by the public
// `mark_clip_boundary` hook (no in-tree caller); a CRA at a detected join is rewritten too.
pending_clip_boundary: bool,
// Highest PES PTS seen, on a monotonic 64-bit timeline (33-bit PTS unwrapped
// across 2^33 wraps — see `pts_wrap_offset`). Auto-detects a non-seamless clip
// boundary when the caller never plumbs one in (see `BACKSTEP_TICKS`).
high_pts: Option<i64>,
// Accumulated 2^33-tick offset unwrapping raw PES PTS onto the monotonic
// `high_pts` timeline. Without it, a 33-bit PTS wrap (~26.5h) looks like a
// backward clip reset and false-arms the CRA→BLA rewrite, corrupting valid RASL.
pts_wrap_offset: i64,
/// Which pictures decode from the pictures this output holds; also detects a join (the
/// PTS timeline moves at an IRAP).
decodable: Decodable,
/// What each frame held by `reorder` needs, in decode order.
needs: std::collections::VecDeque<Need>,
// HDR10 static metadata from prefix/suffix SEI: mastering display (137) and
// content light level (144), captured independently and sticky (first wins).
// `hdr10()` combines both only when present; SDR streams stay `None`, never fabricated.
@@ -158,18 +165,6 @@ struct ContentLightLevel {
max_pic_average_light_level: u16,
}
// A backward PES-PTS step over this (90kHz ticks) marks a non-seamless BD clip
// boundary: each .m2ts clip has its own PTS base, resetting far more than any
// B-frame reorder window. Mirrors `DISCONTINUITY_BACKSTEP_NS` in `mux/timeline.rs`.
const BACKSTEP_TICKS: i64 = 270_000;
// Enforced, not just described: 90kHz ticks -> ns is × 100_000 / 9, so this must
// equal `DISCONTINUITY_BACKSTEP_NS` exactly. Changing either constant alone fails the build.
const _: () = assert!(
BACKSTEP_TICKS * 100_000 / 9 == crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS,
"HEVC BACKSTEP_TICKS must mirror mux::timeline::DISCONTINUITY_BACKSTEP_NS"
);
// Bytes reserved at the front of every assembled access unit so the keyframe
// param-set re-assert (VPS+SPS+PPS, typically well under 1 KiB on BD/UHD
// streams) can splice in without reallocating. Oversized sets just reallocate once.
@@ -193,11 +188,6 @@ thread_local! {
static GUARD_DROPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// 33-bit 90kHz PTS wraps at 2^33 ticks (~26.5h). A backward step of ~2^33 is a
// wraparound (unwrap, add 2^33) not a clip reset (arbitrary sub-2^33 backward
// step); accepting steps within `PTS_WRAP_PERIOD`/2 of a full period separates the two cases.
const PTS_WRAP_PERIOD: i64 = 1 << 33;
impl Default for HevcParser {
fn default() -> Self {
Self::new()
@@ -216,8 +206,8 @@ impl HevcParser {
cur_pps: None,
pps_num_extra: [None; HEVC_MAX_PPS_COUNT],
pending_clip_boundary: false,
high_pts: None,
pts_wrap_offset: 0,
decodable: Decodable::new(true),
needs: std::collections::VecDeque::new(),
sei_mastering: None,
sei_content_light: None,
reorder: None,
@@ -229,17 +219,45 @@ impl HevcParser {
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
// Reconstructed PTS are approximate: only a step past the leading window is a join.
self.decodable = Decodable::new(false);
}
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
/// Route a finished frame through the PTS reorderer when enabled, then keep it
/// only if it decodes from what this output holds.
fn finish(
&mut self,
explicit: Option<i64>,
dts: Option<i64>,
frame: Frame,
need: Need,
) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
Some(r) => {
self.needs.push_back(need);
let out = r.push(explicit, frame);
self.decide(out)
}
None => self
.decodable
.admit(frame, need, explicit, dts)
.into_iter()
.collect(),
}
}
// Decide frames the reorderer released (decode order, display PTS assigned).
fn decide(&mut self, frames: Vec<Frame>) -> Vec<Frame> {
frames
.into_iter()
.filter_map(|f| {
let need = self.needs.pop_front().unwrap_or(Need::Nothing);
let pts = Some(f.pts_ns);
self.decodable.admit(f, need, pts, None)
})
.collect()
}
// Combines mastering-display + content-light SEI into Hdr10Metadata, or `None` until BOTH
@@ -304,12 +322,12 @@ impl HevcParser {
}
/// Mark that the NEXT IRAP this parser sees begins a NON-SEAMLESS BD clip
/// join (MPLS `connection_condition` 0x05 or 0x06). The first CRA at/after
/// this point is rewritten CRA_NUT (21) → BLA_W_LP (16) so a linear decoder
/// sets NoRaslOutput and discards the now-dangling RASL leading pictures.
/// join (MPLS `connection_condition` 0x01 on a PlayItem after the first). The first
/// CRA at/after this point is rewritten CRA_NUT (21) → BLA_W_LP (16) so a linear
/// decoder sets NoRaslOutput, and its RASL leading pictures are dropped.
///
/// MUST be called ONLY for connection_condition 0x05/0x06 — never for 0x01 (seamless/first
/// item) or within a single-clip title.
/// MUST NOT be called for connection_condition 0x05/0x06 (seamless), for the first
/// PlayItem, or within a single-clip title.
pub fn mark_clip_boundary(&mut self) {
self.pending_clip_boundary = true;
}
@@ -414,34 +432,17 @@ impl CodecParser for HevcParser {
// player reorders). Use PTS not DTS: DTS presents B-frames in decode
// order (visible judder) and breaks PTS-based seeking; fall back only if absent.
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let dts = pes.pts.and(pes.dts).map(pts_to_ns);
let pts_ns = explicit_pts.unwrap_or(0);
// Auto-detect a non-seamless clip boundary: mpls connection_condition
// isn't plumbed through the (threaded) mux pipeline, so a PTS backstep
// beyond `BACKSTEP_TICKS` on the unwrapped/high-water timeline arms the rewrite.
if let Some(raw_pts) = pes.pts {
// Unwrap onto the monotonic timeline: if the offset-adjusted value
// dropped ~2^33 below the high-water, the counter wrapped — add
// another period and re-check, rather than treat it as a clip reset.
let mut unwrapped = raw_pts + self.pts_wrap_offset;
if let Some(high) = self.high_pts
&& high - unwrapped > PTS_WRAP_PERIOD / 2
{
self.pts_wrap_offset += PTS_WRAP_PERIOD;
unwrapped += PTS_WRAP_PERIOD;
}
match self.high_pts {
Some(high) if unwrapped < high - BACKSTEP_TICKS => {
self.pending_clip_boundary = true;
self.high_pts = Some(unwrapped);
}
Some(high) => self.high_pts = Some(high.max(unwrapped)),
None => self.high_pts = Some(unwrapped),
}
}
// A join (mpls connection_condition isn't plumbed through the mux pipeline): an
// IRAP whose PTS does not continue the timeline. Its RASL reference another clip.
let joined = self.decodable.joined(explicit_pts, dts);
let data = &pes.data;
let mut keyframe = false;
// NAL type of the AU's IRAP slice, else its first coded slice: what it needs to decode.
let mut vcl_type: Option<u8> = None;
// Set once the first CRA of a non-seamless join is seen in this AU.
let mut bla_au = false;
// Picture coding type, MEASURED from the first coded slice's header.
@@ -479,6 +480,9 @@ impl CodecParser for HevcParser {
// Measure coding type from the first coded slice (VCL NAL
// 0..=31), only once the active PPS is known so the bit
// offset to `slice_type` is exact; else decline (`None`), never guess.
if nal_type <= NAL_VCL_MAX && !keyframe {
vcl_type.get_or_insert(nal_type);
}
if coding_type.is_none() && nal_type <= NAL_VCL_MAX {
// Resolve num_extra from the PPS the slice REFERENCES (by
// its slice_pic_parameter_set_id), not the last-active PPS.
@@ -531,10 +535,11 @@ impl CodecParser for HevcParser {
NAL_AUD => {}
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
keyframe = true;
// Splice-aware CRA→BLA: the first CRA_NUT after a
// non-seamless boundary becomes BLA_W_LP so NoRaslOutput
// drops RASL. Any IRAP clears the flag; only CRA is rewritten.
if self.pending_clip_boundary && t == NAL_CRA_NUT {
vcl_type = Some(t);
// Splice-aware CRA→BLA: a CRA at a join or a marked boundary
// becomes BLA_W_LP so NoRaslOutput drops RASL. Any IRAP clears
// the mark; only CRA is rewritten.
if (self.pending_clip_boundary || joined) && t == NAL_CRA_NUT {
bla_au = true;
}
self.pending_clip_boundary = false;
@@ -630,6 +635,27 @@ impl CodecParser for HevcParser {
return Vec::new();
}
// RASL reference pictures before their IRAP (dropped unless the output holds them;
// always after a BLA); RADL only their IRAP.
let need = match vcl_type {
None => Need::Nothing,
Some(NAL_BLA_W_LP..=NAL_BLA_N_LP) => Need::Rap {
closed: false,
broken: true,
},
Some(NAL_IDR_W_RADL | NAL_IDR_N_LP) => Need::Rap {
closed: true,
broken: false,
},
Some(NAL_CRA_NUT..=NAL_RSV_IRAP_VCL23) => Need::Rap {
closed: false,
broken: bla_au,
},
Some(NAL_RADL_N | NAL_RADL_R) => Need::Nothing,
Some(NAL_RASL_N | NAL_RASL_R) => Need::Leading,
Some(_) => Need::Anchor,
};
// HDR10 static metadata is stamped onto every frame's PictureInfo once
// both SEI messages are seen, riding the deferred-muxer path (reads it
// from the first coded picture before the track header). `None` for SDR tracks.
@@ -650,12 +676,15 @@ impl CodecParser for HevcParser {
data: frame_data,
duration_ns: None,
};
self.finish(explicit_pts, frame)
self.finish(explicit_pts, dts, frame, need)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
Some(r) => {
let out = r.flush();
self.decide(out)
}
None => Vec::new(),
}
}
@@ -1636,7 +1665,7 @@ mod tests {
};
let mut data = nal(NAL_PPS, pps_body(3));
data.extend_from_slice(&nal(1, slice_body));
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
let frames = primed().parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].coding.expect("PictureInfo").coding_type(),
@@ -1649,7 +1678,7 @@ mod tests {
// decides the offset.
let mut data0 = nal(NAL_PPS, pps_body(0));
data0.extend_from_slice(&nal(1, slice_body));
let frames0 = HevcParser::new().parse(&make_pes(data0, Some(0)));
let frames0 = primed().parse(&make_pes(data0, Some(0)));
assert_eq!(
frames0[0].coding.expect("PictureInfo").coding_type(),
CodingType::B,
@@ -1693,7 +1722,7 @@ mod tests {
data.extend_from_slice(&nal_bytes(NAL_PPS, &pps1));
data.extend_from_slice(&nal_bytes(1, &[slice]));
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
let frames = primed().parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].coding.expect("PictureInfo").coding_type(),
@@ -1715,7 +1744,7 @@ mod tests {
let slice64 = [0b1000_0001, 0b0000_0101, 0b1100_0000];
let mut data = nal_bytes(NAL_PPS, &pps64);
data.extend_from_slice(&nal_bytes(1, &slice64));
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
let frames = primed().parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].coding.map(|c| c.coding_type()),
@@ -1730,13 +1759,21 @@ mod tests {
assert_eq!(hevc_pps_id(&[0x44, 0x01, pps63[0], pps63[1]]), Some(63));
let mut data = nal_bytes(NAL_PPS, &pps63);
data.extend_from_slice(&nal_bytes(1, &slice63));
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
let frames = primed().parse(&make_pes(data, Some(0)));
assert_eq!(
frames[0].coding.map(|c| c.coding_type()),
Some(CodingType::I)
);
}
/// A parser that has emitted an IDR at PTS 0, so an inter picture after it decodes.
fn primed() -> HevcParser {
let mut p = HevcParser::new();
let idr = p.parse(&make_pes(nal_bytes(19, &[0x80]), Some(0)));
assert!(idr[0].keyframe);
p
}
/// Build an Annex-B NAL (start code + 2-byte header + body bytes).
fn nal_bytes(nal_type: u8, body: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01];
@@ -1759,7 +1796,7 @@ mod tests {
};
let src = crate::pes::SourcePos::at_byte(16384);
let run = |slice_body: u8| {
let mut p = HevcParser::new();
let mut p = primed();
let mut data = nal(NAL_PPS, 0xC0); // active PPS first (sets num_extra)
data.extend_from_slice(&nal(1, slice_body)); // then the coded slice
let mut pe = make_pes(data, Some(0));
@@ -1793,7 +1830,7 @@ mod tests {
// No PPS seen → num_extra is unknown, so slice_type is NOT guessed; the
// coding stays None (honestly absent) rather than risk a wrong offset.
let mut p = HevcParser::new();
let mut p = primed();
let bare = p.parse(&make_pes(nal(1, 0xD8), Some(0)));
assert!(
bare[0].coding.is_none(),
@@ -2352,6 +2389,113 @@ mod tests {
);
}
/// Whether `frames` hand a decoder a RASL picture it must decode: one that
/// follows a CRA (not a BLA, whose RASL a decoder discards) in decode order.
fn emits_decodable_rasl_after_cra(frames: &[Frame]) -> bool {
let mut last_irap = None;
for f in frames {
let t = nal_type_of(&nals_of(&f.data)[0]);
match t {
16..=21 => last_irap = Some(t),
8 | 9 if last_irap == Some(NAL_CRA_NUT) => return true,
_ => {}
}
}
false
}
#[test]
fn a_forward_pts_join_onto_a_cra_does_not_hand_its_rasl_to_the_decoder() {
// Clip 1: CRA + trailing pictures at ~0 s. Clip 2 starts 100 s later in the
// source clock (a PlayItem that skips part of a clip, or an STC running ahead)
// with a CRA whose RASL reference a picture of the skipped material.
let mut p = HevcParser::new();
let mut frames = Vec::new();
frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(0))));
frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(3750))));
frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(7500))));
let t = 100 * 90_000;
let clip2 = frames.len();
frames.extend(p.parse(&make_pes(cra_au(&[0x02]), Some(t))));
frames.extend(p.parse(&make_pes(nal_bytes(9, &[0xE0]), Some(t - 7500))));
frames.extend(p.parse(&make_pes(nal_bytes(8, &[0xE0]), Some(t - 3750))));
assert!(
!emits_decodable_rasl_after_cra(&frames[clip2..]),
"clip 2's RASL reference a picture this output does not hold"
);
}
#[test]
fn rasl_after_a_seamless_join_cra_stay_decodable() {
// Characterises the seamless case: PTS continuous across the join, so the
// RASL reference the previous clip's tail, which the output holds.
let mut p = HevcParser::new();
let mut frames = Vec::new();
frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(0))));
frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(3750))));
frames.extend(p.parse(&make_pes(cra_au(&[0x02]), Some(4 * 3750))));
frames.extend(p.parse(&make_pes(nal_bytes(9, &[0xE0]), Some(2 * 3750))));
assert!(
emits_decodable_rasl_after_cra(&frames),
"a seamless join keeps its CRA and RASL"
);
}
#[test]
fn a_gap_resync_onto_a_cra_does_not_hand_its_rasl_to_the_decoder() {
// IDR, a TRAIL_R that follows lost data, then a CRA (same clock) whose RASL
// reference the dropped TRAIL_R. The CRA is not first in the bitstream, so
// a decoder decodes the RASL.
let mut p = HevcParser::new();
let mut lost = make_pes(nal_bytes(1, &[0xD0]), Some(3750));
lost.discontinuity = true;
let pes = [
make_pes(nal_bytes(19, &[0x80]), Some(0)),
lost,
make_pes(cra_au(&[0x02]), Some(4 * 3750)),
make_pes(nal_bytes(9, &[0xE0]), Some(2 * 3750)),
make_pes(nal_bytes(8, &[0xE0]), Some(3 * 3750)),
];
let mut gate = crate::mux::resync::ResyncGate::new();
let frames: Vec<Frame> = pes
.iter()
.flat_map(|x| p.parse(x))
.filter(|f| gate.admit(true, f.discontinuity, f.keyframe))
.collect();
assert!(
!emits_decodable_rasl_after_cra(&frames),
"the RASL reference the picture the gate dropped"
);
}
#[test]
fn pictures_before_the_first_irap_are_dropped() {
let mut p = HevcParser::new();
let mut frames = Vec::new();
frames.extend(p.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(0))));
frames.extend(p.parse(&make_pes(cra_au(&[0x01]), Some(3750))));
assert!(frames[0].keyframe, "the first emitted picture is the IRAP");
}
#[test]
fn a_cra_at_the_stream_start_drops_its_rasl_and_keeps_its_radl() {
// A conforming decoder discards these RASL itself; some hardware decoders do not.
let mut p = HevcParser::new();
let pes = [
make_pes(cra_au(&[0x01]), Some(3 * 3750)),
make_pes(nal_bytes(9, &[0xE0]), Some(0)),
make_pes(nal_bytes(8, &[0xE0]), Some(3750)),
make_pes(nal_bytes(7, &[0xE0]), Some(2 * 3750)),
make_pes(nal_bytes(1, &[0xD0]), Some(4 * 3750)),
];
let types: Vec<u8> = pes
.iter()
.flat_map(|x| p.parse(x))
.map(|f| nal_type_of(&nals_of(&f.data)[0]))
.collect();
assert_eq!(types, [NAL_CRA_NUT, 7, 1]);
}
/// Test 2: a CRA with NO boundary marker is left unchanged (CRA stays CRA).
#[test]
fn cra_without_boundary_unchanged() {
@@ -2370,21 +2514,20 @@ mod tests {
#[test]
fn cra_at_auto_detected_pts_backstep_rewritten_to_bla() {
let mut parser = HevcParser::new();
// Clip 1: a CRA then a few trailing frames advancing the PTS watermark.
// PTS in 90 kHz ticks: 0, then ~1 h into the clip.
let one_hour = 90_000i64 * 3600;
parser.parse(&make_pes(cra_au(&[0x01]), Some(0)));
parser.parse(&make_pes(cra_au(&[0x02]), Some(one_hour)));
// In-clip B-frame dip: PTS steps back a few frames (< BACKSTEP_TICKS).
// Must NOT be mistaken for a clip boundary — this CRA stays CRA.
let dip = parser.parse(&make_pes(cra_au(&[0x03]), Some(one_hour - 3 * 3750)));
// Clip 1, ~1 h in: a CRA, then a picture decoded ahead and a B-frame dip (PTS
// stepping back below it) — reorder, not a boundary — then the next CRA continuing.
let t = 90_000i64 * 3600;
parser.parse(&make_pes(cra_au(&[0x01]), Some(t)));
parser.parse(&make_pes(nal_bytes(1, &[0xD0]), Some(t + 2 * 3750)));
parser.parse(&make_pes(nal_bytes(1, &[0xC0]), Some(t + 3750)));
let next = parser.parse(&make_pes(cra_au(&[0x03]), Some(t + 4 * 3750)));
assert_eq!(
nal_type_of(&nals_of(&dip[0].data)[0]),
nal_type_of(&nals_of(&next[0].data)[0]),
NAL_CRA_NUT,
"a sub-threshold B-frame PTS dip must not trigger the rewrite"
"a B-frame PTS dip must not trigger the rewrite"
);
// Clip 2 splice: PES PTS resets to a new clip base far below the
// watermark (> BACKSTEP_TICKS backward). The opening CRA is rewritten.
// watermark. The opening CRA is rewritten.
let splice = parser.parse(&make_pes(cra_au(&[0x04]), Some(0)));
assert_eq!(
nal_type_of(&nals_of(&splice[0].data)[0]),
@@ -2392,7 +2535,7 @@ mod tests {
"the splice CRA after a backward PTS reset must become BLA_W_LP"
);
// One-shot: the NEXT clip-2 CRA (PTS advancing again) stays CRA.
let next = parser.parse(&make_pes(cra_au(&[0x05]), Some(90_000)));
let next = parser.parse(&make_pes(cra_au(&[0x05]), Some(3750)));
assert_eq!(
nal_type_of(&nals_of(&next[0].data)[0]),
NAL_CRA_NUT,
@@ -2407,13 +2550,13 @@ mod tests {
let mut parser = HevcParser::new();
let period = 1i64 << 33;
// Single clip, PTS climbing toward the 33-bit wrap. Start just below 2^33.
let near_wrap = period - 90_000; // ~1 s before the wrap point
let near_wrap = period - 2 * 3750; // two frames before the wrap point
parser.parse(&make_pes(cra_au(&[0x01]), Some(near_wrap)));
parser.parse(&make_pes(cra_au(&[0x02]), Some(near_wrap + 3750)));
// The counter wraps: raw PTS resets to a small value, but this is the
// SAME continuous clip, one frame later. A naive raw comparison sees a
// ~2^33 backward step and false-arms the boundary.
let wrapped = parser.parse(&make_pes(cra_au(&[0x03]), Some(7500)));
let wrapped = parser.parse(&make_pes(cra_au(&[0x03]), Some(0)));
assert_eq!(
nal_type_of(&nals_of(&wrapped[0].data)[0]),
NAL_CRA_NUT,
@@ -2421,7 +2564,7 @@ mod tests {
);
// Continue past the wrap: PTS keeps climbing from the new low base; still
// one continuous clip, the CRA after must remain CRA.
let after = parser.parse(&make_pes(cra_au(&[0x04]), Some(11250)));
let after = parser.parse(&make_pes(cra_au(&[0x04]), Some(3750)));
assert_eq!(
nal_type_of(&nals_of(&after[0].data)[0]),
NAL_CRA_NUT,
@@ -2585,7 +2728,7 @@ mod tests {
#[test]
fn parse_trailing_not_keyframe() {
let mut parser = HevcParser::new();
let mut parser = primed();
// TRAIL_R = type 1
let mut data = Vec::new();
@@ -2605,7 +2748,7 @@ mod tests {
#[test]
fn parse_tsa_not_keyframe() {
let mut parser = HevcParser::new();
let mut parser = primed();
// TSA_N = type 2
let mut data = Vec::new();
@@ -2712,7 +2855,7 @@ mod tests {
// it.
#[test]
fn redefined_pps_emitted_inline() {
let mut parser = HevcParser::new();
let mut parser = primed();
let pps = |body: u8| {
let mut v = vec![0x00, 0x00, 0x01];
v.extend_from_slice(&hevc_nal_header(34)); // PPS
@@ -2783,7 +2926,7 @@ mod tests {
// `00 00 01 00 00 01 <real NAL>`: two adjacent start codes leave the
// in-between NAL empty after the trailing-zero strip. It must be skipped,
// not written as a bare 0x00000000 length prefix (malformed to a decoder).
let mut parser = HevcParser::new();
let mut parser = primed();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]); // start code, empty NAL
data.extend_from_slice(&[0x00, 0x00, 0x01]); // next start code
@@ -2829,7 +2972,7 @@ mod tests {
#[test]
fn pts_conversion() {
let mut parser = HevcParser::new();
let mut parser = primed();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
@@ -2848,7 +2991,7 @@ mod tests {
#[test]
fn pts_preferred_over_dts() {
let mut parser = HevcParser::new();
let mut parser = primed();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
@@ -3807,7 +3950,7 @@ mod tests {
#[test]
fn type_15_just_below_irap_not_keyframe() {
// Type 15 (RASL_R) is one below the IRAP range and must NOT be a keyframe.
let mut parser = HevcParser::new();
let mut parser = primed();
let mut data = vec![0x00, 0x00, 0x01];
data.extend_from_slice(&hevc_nal_header(15));
data.extend_from_slice(&[0x10, 0x20]);
@@ -3819,7 +3962,7 @@ mod tests {
#[test]
fn type_24_just_above_irap_not_keyframe() {
// Type 24 (RSV_VCL24) is one above the IRAP range (..=23) → not keyframe.
let mut parser = HevcParser::new();
let mut parser = primed();
let mut data = vec![0x00, 0x00, 0x01];
data.extend_from_slice(&hevc_nal_header(24));
data.extend_from_slice(&[0x10, 0x20]);
@@ -3845,7 +3988,7 @@ mod tests {
#[test]
fn hevc_dts_fallback_when_pts_absent() {
let mut parser = HevcParser::new();
let mut parser = primed();
let pes = PesPacket {
source: None,
pid: 0x1011,
+3
View File
@@ -15,6 +15,8 @@ mod audio_frames;
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
pub mod coding;
pub(crate) mod crc;
/// Which pictures decode from the pictures the output holds (start, join, gap).
pub(crate) mod decodable;
pub(crate) mod dropgate;
/// DTS / DTS-HD elementary-stream parser.
pub mod dts;
@@ -455,6 +457,7 @@ mod provenance_guard {
const NON_PARSER_MODULES: &[&str] = &[
"coding",
"crc",
"decodable",
"dropgate",
"mp2_channels",
"pesbuf",
+354 -12
View File
@@ -102,6 +102,30 @@ pub struct Mpeg2Parser {
/// Top-parity of an unpaired first field picture; the next opposite-parity
/// field is its second field and inherits the pair's (first field's) order.
pending_first_field: Option<bool>,
/// Anchor frames (I/P) a decoder holds from this stream's emitted pictures,
/// saturating at 2. Zero at the stream start, at a join (the timeline origin
/// moves) and at a `broken_link` GOP; a picture needing more is dropped.
refs: u8,
/// `closed_gop` of the GOP being decided.
gop_closed: bool,
/// `temporal_reference` of the current GOP's first anchor; a B before it in
/// display order is a leading picture.
gop_anchor_tr: Option<u64>,
/// Whether the last first-field or frame picture was kept; its second field follows it.
last_kept: bool,
/// A dropped picture's discontinuity flag, carried to the next emitted frame.
carry_discontinuity: bool,
/// A discontinuity was seen; `refs` resets at the I-picture the `ResyncGate` resumes on.
gap: bool,
}
/// GOP header flags (ISO/IEC 13818-2 §6.3.8).
#[derive(Clone, Copy)]
struct GopFlags {
/// The GOP's leading B-pictures use backward prediction only.
closed: bool,
/// The anchor before this GOP is not the one its leading B-pictures were coded against.
broken_link: bool,
}
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
@@ -114,6 +138,10 @@ struct BufferedPicture {
info: PictureInfo,
/// This picture's own PES PTS (ns), if its access unit carried one.
explicit_pts: Option<i64>,
/// Flags of the GOP this picture's access unit opens, if it opens one.
gop: Option<GopFlags>,
/// The second field of a field pair; decodable exactly when its first field is.
second_field: bool,
/// The emitted frame (PTS + duration filled in at GOP flush).
frame: Frame,
}
@@ -137,6 +165,12 @@ impl Mpeg2Parser {
emitted_fields: 0,
origin_pts_ns: None,
pending_first_field: None,
refs: 0,
gop_closed: false,
gop_anchor_tr: None,
last_kept: false,
carry_discontinuity: false,
gap: false,
}
}
@@ -183,8 +217,20 @@ impl Mpeg2Parser {
}
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
// resetting temporal_reference to 0.
let gop_boundary = find_code(&data, 0, GOP_CODE).is_some()
|| find_code(&data, 0, SEQ_HEADER_CODE).is_some();
let gop_header = find_code(&data, 0, GOP_CODE);
let gop_boundary = gop_header.is_some() || find_code(&data, 0, SEQ_HEADER_CODE).is_some();
// closed_gop and broken_link: bits 6 and 5 of the GOP header's fourth byte.
// A sequence header with no GOP header starts a GOP read as open.
let gop = gop_boundary.then(|| {
let b = gop_header
.and_then(|g| data.get(g + 7))
.copied()
.unwrap_or(0);
GopFlags {
closed: b & 0x40 != 0,
broken_link: b & 0x20 != 0,
}
});
// picture_coding_type: the full 3-bit value (bits 5-3 of data[pic+5]).
// 0 when the picture header is truncated (no coding type available).
let raw_coding_type = if pic + 5 < end {
@@ -207,6 +253,7 @@ impl Mpeg2Parser {
if gop_boundary {
self.pending_first_field = None;
}
let mut second_field = false;
if frame_picture {
self.pending_first_field = None;
} else if let Some(first_top) = self.pending_first_field.take()
@@ -214,6 +261,7 @@ impl Mpeg2Parser {
{
// Second field of a pair: report the first field's order.
tff = first_top;
second_field = true;
} else {
self.pending_first_field = Some(tff);
}
@@ -240,6 +288,8 @@ impl Mpeg2Parser {
tr,
info,
explicit_pts: au.pts,
gop,
second_field,
frame: Frame {
pts_ns: 0,
keyframe,
@@ -273,10 +323,11 @@ impl Mpeg2Parser {
if field_period <= 0 {
// No sequence header / frame rate yet (malformed lead-in): emit in
// decode order off each AU's own PES PTS, with no field timing.
for bp in self.gop_buf.drain(..) {
let keep = self.decodable(false);
for (bp, keep) in std::mem::take(&mut self.gop_buf).into_iter().zip(keep) {
let mut f = bp.frame;
f.pts_ns = bp.explicit_pts.unwrap_or(0);
out.push(f);
self.emit(f, keep, out);
}
return;
}
@@ -292,21 +343,87 @@ impl Mpeg2Parser {
}
let gop_fields = running;
let base = self.emitted_fields;
// (Re-)lock the timeline origin to the GOP's PES PTS.
// (Re-)lock the timeline origin to the GOP's PES PTS. An origin that moves
// by more than half a field is a join: no earlier picture is a reference.
let mut joined = false;
for &i in &order {
if let Some(p) = self.gop_buf[i].explicit_pts {
self.origin_pts_ns = Some(p - field_period * (base + cum_before[i]) as i64);
let origin = p - field_period * (base + cum_before[i]) as i64;
joined = self
.origin_pts_ns
.is_some_and(|prev| (origin - prev).abs() > field_period / 2);
self.origin_pts_ns = Some(origin);
break;
}
}
let origin = self.origin_pts_ns.unwrap_or(0);
for (i, mut bp) in self.gop_buf.drain(..).enumerate() {
let keep = self.decodable(joined);
let gop = std::mem::take(&mut self.gop_buf);
for ((i, mut bp), keep) in gop.into_iter().enumerate().zip(keep) {
bp.frame.pts_ns = origin + field_period * (base + cum_before[i]) as i64;
bp.frame.duration_ns = Some(bp.info.nb_fields() as u64 * field_period as u64);
out.push(bp.frame);
self.emit(bp.frame, keep, out);
}
// Dropped pictures keep their display slots, so every kept frame keeps its PTS.
self.emitted_fields += gop_fields;
}
// Decide, in decode order, which buffered pictures decode from references this
// stream emitted: an I always; a P after an anchor; a B with both anchors, or
// with one when it is not a leading picture or its GOP is closed.
fn decodable(&mut self, joined: bool) -> Vec<bool> {
if joined {
self.refs = 0;
}
let mut keep = Vec::with_capacity(self.gop_buf.len());
for bp in &self.gop_buf {
if let Some(g) = bp.gop {
self.gop_closed = g.closed;
self.gop_anchor_tr = None;
if g.broken_link {
self.refs = 0;
}
}
// After a gap the gate drops (and counts) up to the next I, which holds none before it.
self.gap |= bp.frame.discontinuity;
if self.gap && bp.frame.keyframe {
self.gap = false;
self.refs = 0;
}
if bp.second_field {
keep.push(self.last_kept);
continue;
}
let ok = match bp.info.coding_type() {
CodingType::I => true,
CodingType::P => self.refs >= 1,
CodingType::B => {
let leading =
!self.gop_closed && self.gop_anchor_tr.is_none_or(|anchor| bp.tr < anchor);
self.refs >= if leading { 2 } else { 1 }
}
};
if bp.info.coding_type() != CodingType::B {
self.gop_anchor_tr.get_or_insert(bp.tr);
if ok {
self.refs = (self.refs + 1).min(2);
}
}
self.last_kept = ok;
keep.push(ok);
}
keep
}
// Emit a kept frame; a dropped one passes its discontinuity flag on.
fn emit(&mut self, mut frame: Frame, keep: bool, out: &mut Vec<Frame>) {
if keep {
frame.discontinuity |= std::mem::take(&mut self.carry_discontinuity);
out.push(frame);
} else {
self.carry_discontinuity |= frame.discontinuity;
}
}
}
impl CodecParser for Mpeg2Parser {
@@ -795,6 +912,231 @@ mod tests {
frames
}
/// Parse an opening I-picture, then `pes`; returns only `pes`'s frames. A
/// stream emits nothing before its first I-picture.
fn parse_after_i(parser: &mut Mpeg2Parser, pes: &PesPacket) -> Vec<Frame> {
let mut i = make_picture_header(PICTURE_TYPE_I);
i.extend_from_slice(&[0xEE; 8]);
let mut frames = parser.parse(&make_pes(i, None));
frames.extend(parse_then_flush(parser, pes));
assert!(frames[0].keyframe, "the opening I-picture");
frames.split_off(1)
}
// --- Pictures a decoder cannot reconstruct from the stream's own data ---
/// A GOP header with the given `closed_gop` / `broken_link` flags.
fn gop_flags(closed: bool, broken_link: bool) -> Vec<u8> {
let b7 = (u8::from(closed) << 6) | (u8::from(broken_link) << 5);
vec![0x00, 0x00, 0x01, GOP_CODE, 0x00, 0x00, 0x00, b7]
}
/// One frame picture as its own PES: `prefix` headers, a picture header,
/// a payload.
fn pic_pes(prefix: Vec<u8>, ct: u8, tr: u16, pts: Option<i64>) -> PesPacket {
let mut au = prefix;
au.extend_from_slice(&make_picture_header_tr(ct, tr));
au.extend_from_slice(&[0xA5; 12]);
make_pes(au, pts)
}
/// A 25 fps GOP in decode order `I2 B0 B1 P5 B3 B4`, the I at `pts` (90 kHz).
fn gop_ibbp(head: Vec<u8>, pts: Option<i64>) -> Vec<PesPacket> {
vec![
pic_pes(head, 1, 2, pts),
pic_pes(Vec::new(), 3, 0, None),
pic_pes(Vec::new(), 3, 1, None),
pic_pes(Vec::new(), 2, 5, None),
pic_pes(Vec::new(), 3, 3, None),
pic_pes(Vec::new(), 3, 4, None),
]
}
fn run(pes: impl IntoIterator<Item = PesPacket>) -> Vec<Frame> {
let mut p = Mpeg2Parser::new();
let mut frames: Vec<Frame> = pes.into_iter().flat_map(|x| p.parse(&x)).collect();
frames.extend(p.flush());
frames
}
fn pts_ms(frames: &[Frame]) -> Vec<i64> {
frames.iter().map(|f| f.pts_ns / 1_000_000).collect()
}
fn seq_gop(closed: bool, broken_link: bool) -> Vec<u8> {
let mut h = make_seq_header(720, 576, 3, 3);
h.extend_from_slice(&gop_flags(closed, broken_link));
h
}
#[test]
fn an_open_gop_at_the_stream_start_drops_its_leading_b_pictures() {
// B0 and B1 reference the anchor before this GOP, which the stream does
// not hold. The second open GOP's leading Bs reference P5, which it does.
let mut pes = gop_ibbp(seq_gop(false, false), Some(90_000));
pes.extend(gop_ibbp(gop_flags(false, false), None));
let f = run(pes);
assert!(f[0].keyframe, "the first emitted picture is the I-picture");
assert_eq!(
pts_ms(&f),
vec![1000, 1120, 1040, 1080, 1240, 1160, 1200, 1360, 1280, 1320],
"I2 keeps its PES PTS; every kept frame keeps its display slot"
);
assert_eq!(f[0].duration_ns, Some(40_000_000));
}
#[test]
fn a_closed_gop_at_the_stream_start_keeps_every_picture() {
let f = run(gop_ibbp(seq_gop(true, false), Some(90_000)));
assert_eq!(pts_ms(&f), vec![1000, 920, 960, 1120, 1040, 1080]);
}
#[test]
fn pictures_before_the_first_i_picture_are_dropped() {
let pes = vec![
pic_pes(make_seq_header(720, 576, 3, 3), 2, 1, Some(90_000)),
pic_pes(Vec::new(), 3, 0, None),
];
let mut all = pes;
all.extend(gop_ibbp(gop_flags(true, false), None));
let f = run(all);
assert_eq!(f.len(), 6, "only the closed GOP is emitted");
assert!(f[0].keyframe);
assert_eq!(
f[0].pts_ns, 1_120_000_000,
"the I keeps its slot after the dropped two"
);
}
#[test]
fn a_broken_link_drops_the_leading_b_pictures_mid_stream() {
let mut pes = gop_ibbp(seq_gop(true, false), Some(90_000));
pes.extend(gop_ibbp(gop_flags(false, true), None));
let f = run(pes);
assert_eq!(f.len(), 10);
assert_eq!(pts_ms(&f)[6..], [1240, 1360, 1280, 1320]);
}
#[test]
fn a_join_drops_the_leading_b_pictures_of_an_open_gop() {
// The second GOP's PTS does not continue the first: a different clip, so
// its leading Bs' forward reference is not the stream's last anchor.
let mut pes = gop_ibbp(seq_gop(true, false), Some(90_000));
pes.extend(gop_ibbp(gop_flags(false, false), Some(10 * 90_000)));
let f = run(pes);
assert_eq!(pts_ms(&f)[6..], [10_000, 10_120, 10_040, 10_080]);
}
#[test]
fn pes_opened_mid_picture_is_not_a_join() {
// A muxer packing pictures back to back opens each PES
// inside the previous picture, timed for the picture commencing in it. Read as
// the previous picture's PTS, the open GOP's origin moved a frame: a false join.
let mut pics = vec![
pic_pes(seq_gop(true, false), 1, 0, None),
pic_pes(Vec::new(), 2, 3, None),
pic_pes(Vec::new(), 3, 1, None),
pic_pes(Vec::new(), 3, 2, None),
];
pics.extend(gop_ibbp(gop_flags(false, false), None));
// Display slot of each picture, in decode order.
let slots: Vec<i64> = [0, 3, 1, 2, 6, 4, 5, 9, 7, 8]
.iter()
.map(|&s| 90_000 + s * 3_600)
.collect();
let starts: Vec<usize> = pics
.iter()
.scan(0, |at, p| Some(std::mem::replace(at, *at + p.data.len())))
.collect();
let es: Vec<u8> = pics.into_iter().flat_map(|p| p.data).collect();
// PES j opens 5 bytes into picture j - 1, so picture j commences inside it.
let mut cuts: Vec<usize> = std::iter::once(0)
.chain(starts.iter().map(|s| s + 5))
.collect();
cuts.push(es.len());
let pes = cuts
.windows(2)
.enumerate()
.map(|(j, w)| make_pes(es[w[0]..w[1]].to_vec(), slots.get(j).copied()));
let f = run(pes);
assert_eq!(
pts_ms(&f),
slots.iter().map(|s| s / 90).collect::<Vec<_>>(),
"every picture kept, each in its display slot"
);
}
#[test]
fn a_dropped_picture_passes_its_discontinuity_to_the_next_emitted_frame() {
let mut pes = gop_ibbp(seq_gop(false, false), Some(90_000));
pes[1].discontinuity = true;
let f = run(pes);
assert_eq!(f.len(), 4);
assert!(!f[0].discontinuity, "the I precedes the dropped B");
assert!(f[1].discontinuity, "P5 is the next emitted frame");
}
#[test]
fn a_field_coded_open_gop_drops_both_fields_of_each_leading_b() {
// I/P field pair = one anchor frame, so the leading B field pairs still lack
// their forward reference.
let field = |head: Vec<u8>, ct: u8, tr: u16, e2: u8, pts: Option<i64>| {
let mut au = head;
au.extend_from_slice(&make_picture_header_tr(ct, tr));
let mut ext = pic_coding_ext(0, 0, 0, false);
ext[6] = e2;
au.extend_from_slice(&ext);
make_pes(au, pts)
};
let f = run(vec![
field(seq_gop(false, false), 1, 1, 0x01, Some(90_000)),
field(Vec::new(), 2, 1, 0x02, None),
field(Vec::new(), 3, 0, 0x01, None),
field(Vec::new(), 3, 0, 0x02, None),
field(Vec::new(), 2, 2, 0x01, None),
field(Vec::new(), 2, 2, 0x02, None),
]);
let types: Vec<_> = f.iter().map(|x| x.coding.unwrap().coding_type()).collect();
assert_eq!(
types,
[CodingType::I, CodingType::P, CodingType::P, CodingType::P]
);
}
#[test]
fn a_gap_resync_onto_an_open_gop_emits_no_leading_b_pictures() {
// GOP 1 (closed) loses data before P5: P5 and the Bs after it reference
// the lost picture. GOP 2 is open and PTS-continuous (no join): its leading
// B0/B1 reference GOP 1's P5, which the gate dropped.
let mut pes = gop_ibbp(seq_gop(true, false), Some(90_000));
pes[3].discontinuity = true;
pes.extend(gop_ibbp(gop_flags(false, false), None));
let mut gate = crate::mux::resync::ResyncGate::new();
let out: Vec<Frame> = run(pes)
.into_iter()
.filter(|f| gate.admit(true, f.discontinuity, f.keyframe))
.collect();
let types: Vec<_> = out
.iter()
.map(|f| f.coding.unwrap().coding_type())
.collect();
// GOP 1: I2 B0 B1 kept; P5 B3 B4 dropped. GOP 2: I2 kept, B0 B1 lack P5
// (dropped), P5 B3 B4 decode from GOP 2's own anchors.
assert_eq!(
types,
[
CodingType::I,
CodingType::B,
CodingType::B,
CodingType::I,
CodingType::P,
CodingType::B,
CodingType::B,
],
"no emitted picture may reference one the gate dropped"
);
}
// --- Sequence header parsing ---
#[test]
@@ -850,7 +1192,7 @@ mod tests {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(2); // P-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
let frames = parse_after_i(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "P-frame should not be keyframe");
}
@@ -860,7 +1202,7 @@ mod tests {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(3); // B-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(90000)));
let frames = parse_after_i(&mut parser, &make_pes(data, Some(90000)));
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "B-frame should not be keyframe");
}
@@ -1302,7 +1644,7 @@ mod tests {
let mut data = make_seq_header(720, 480, 3, 4);
data.extend_from_slice(&make_picture_header(2)); // P-frame
data.extend_from_slice(&[0xFF; 16]);
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
let frames = parse_after_i(&mut parser, &make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert!(
!frames[0].keyframe,
@@ -1588,7 +1930,7 @@ mod tests {
let mut parser = Mpeg2Parser::new();
let mut data = make_picture_header(ct);
data.extend_from_slice(&[0xFF; 8]);
let f = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
let f = parse_after_i(&mut parser, &make_pes(data, Some(0)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].keyframe, is_kf, "picture_coding_type {ct}");
}
+242 -7
View File
@@ -7,6 +7,7 @@
//! (0x0F) in the PES, per the BD VC-1 convention (see `parse`).
use super::coding::{CodingType, PictureInfo};
use super::decodable::{Decodable, Need};
use super::startcode::{BitReader, find_start_code};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
@@ -74,6 +75,47 @@ fn vc1_frame_coding_type(frame_rbsp: &[u8], seq_header: Option<&[u8]>) -> Option
vc1_progressive_ptype(&mut BitReader::new(frame_rbsp))
}
/// What an advanced-profile picture predicts from, for decodability: the PTYPE VLC, or for an
/// interlaced sequence FCM then PTYPE or a field pair's FPTYPE (SMPTE 421M §7.1.1.4; FCM and
/// FPTYPE as ffmpeg `vc1.c` reads them).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Vc1Pic {
/// I, or an I/I or I/P field pair.
Intra,
/// P, skipped, or a P/I or P/P field pair.
Predicted,
/// B, or a field pair holding a B field.
Bi,
/// BI (intra, never a reference), or a BI/BI field pair.
IntraB,
}
fn vc1_picture(frame_rbsp: &[u8], seq_header: Option<&[u8]>) -> Option<Vc1Pic> {
let interlace = parse_vc1_interlace(seq_header?)?;
let mut br = BitReader::new(frame_rbsp);
// FCM: 0 progressive, 10 frame-interlace, 11 field-interlace.
if interlace && br.read_bit()? == 1 && br.read_bit()? == 1 {
// FPTYPE: bit 2 = B/BI pair, bit 1 = first field P (or BI), bit 0 = second field.
return Some(match br.read_bits(3)? {
0 | 1 => Vc1Pic::Intra,
2 | 3 => Vc1Pic::Predicted,
7 => Vc1Pic::IntraB,
_ => Vc1Pic::Bi,
});
}
// PTYPE: 0 P, 10 B, 110 I, 1110 BI, 1111 skipped (P).
let mut ones = 0;
while ones < 4 && br.read_bit()? == 1 {
ones += 1;
}
Some(match ones {
1 => Vc1Pic::Bi,
2 => Vc1Pic::Intra,
3 => Vc1Pic::IntraB,
_ => Vc1Pic::Predicted,
})
}
pub struct Vc1Parser {
// First-seen seq_header + entry_point seed MKV codecPrivate. A redefined
// body must be emitted IN-BAND at each occurrence, and at every keyframe
@@ -91,6 +133,12 @@ pub struct Vc1Parser {
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
/// Which pictures decode from the pictures this output holds.
decodable: Decodable,
/// What each frame held by `reorder` needs, in decode order.
needs: std::collections::VecDeque<Need>,
/// Whether an anchor followed the last entry-point picture: a B before one is leading.
anchor_since_entry: bool,
}
impl Default for Vc1Parser {
@@ -109,6 +157,9 @@ impl Vc1Parser {
width: 1920,
height: 1080,
reorder: None,
decodable: Decodable::new(true),
needs: std::collections::VecDeque::new(),
anchor_since_entry: false,
}
}
@@ -117,17 +168,45 @@ impl Vc1Parser {
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
if enabled {
self.reorder = Some(super::reorder::SparsePtsReorder::new());
// Reconstructed PTS are approximate: only a step past the leading window is a join.
self.decodable = Decodable::new(false);
}
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
/// Route a finished frame through the PTS reorderer when enabled, then keep it
/// only if it decodes from what this output holds.
fn finish(
&mut self,
explicit: Option<i64>,
dts: Option<i64>,
frame: Frame,
need: Need,
) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.push(explicit, frame),
None => vec![frame],
Some(r) => {
self.needs.push_back(need);
let out = r.push(explicit, frame);
self.decide(out)
}
None => self
.decodable
.admit(frame, need, explicit, dts)
.into_iter()
.collect(),
}
}
// Decide frames the reorderer released (decode order, display PTS assigned).
fn decide(&mut self, frames: Vec<Frame>) -> Vec<Frame> {
frames
.into_iter()
.filter_map(|f| {
let need = self.needs.pop_front().unwrap_or(Need::Nothing);
let pts = Some(f.pts_ns);
self.decodable.admit(f, need, pts, None)
})
.collect()
}
}
@@ -163,9 +242,12 @@ impl CodecParser for Vc1Parser {
// DTS presents B-frames in decode order (judder, broken seeking).
// Fall back to DTS only if PTS is absent.
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
let dts = pes.pts.and(pes.dts).map(pts_to_ns);
let ts_ns = explicit_pts.unwrap_or(0);
let mut has_seq_header = false;
let mut has_entry_point = false;
// BROKEN_LINK and CLOSED_ENTRY: the entry-point header's first two bits (§6.2.1).
let mut entry_flags: Option<(bool, bool)> = None;
let mut frame_start: Option<usize> = None;
// Track whether this AU carried a redefined (in-band) copy of each
// header type, in separate temporaries so the keyframe prefix can be
@@ -200,6 +282,8 @@ impl CodecParser for Vc1Parser {
}
SC_ENTRY_POINT => {
let end = find_start_code(data, pos + 4).unwrap_or(data.len());
let b = data.get(pos + 4).copied().unwrap_or(0);
entry_flags = Some((b & 0x80 != 0, b & 0x40 != 0));
if let Some(v) = handle_header(
&mut self.entry_point,
&mut self.cur_entry_point,
@@ -285,6 +369,30 @@ impl CodecParser for Vc1Parser {
vc1_frame_coding_type(data.get(fs + 4..)?, self.cur_seq_header.as_deref())
});
// An entry-point picture opens a segment; a B before the segment's next anchor is
// leading (with CLOSED_ENTRY 0 it may predict from the anchor before the entry point).
let picture = frame_start
.and_then(|fs| vc1_picture(data.get(fs + 4..)?, self.cur_seq_header.as_deref()));
let need = match picture {
None => Need::Nothing,
Some(_) if keyframe || entry_flags.is_some() => {
self.anchor_since_entry = false;
let (broken, closed) = entry_flags.unwrap_or((false, false));
Need::Rap { closed, broken }
}
Some(Vc1Pic::Intra) => {
self.anchor_since_entry = true;
Need::Intra
}
Some(Vc1Pic::Predicted) => {
self.anchor_since_entry = true;
Need::Anchor
}
Some(Vc1Pic::Bi) if self.anchor_since_entry => Need::Trailing,
Some(Vc1Pic::Bi) => Need::Leading,
Some(Vc1Pic::IntraB) => Need::Nothing,
};
let frame = Frame {
// Coding-type only: VC-1 field order is not decoded here, so
// field_order() stays None — honestly absent, never guessed.
@@ -298,12 +406,15 @@ impl CodecParser for Vc1Parser {
data: frame_data,
duration_ns: None,
};
self.finish(explicit_pts, frame)
self.finish(explicit_pts, dts, frame, need)
}
fn flush(&mut self) -> Vec<Frame> {
match self.reorder.as_mut() {
Some(r) => r.flush(),
Some(r) => {
let out = r.flush();
self.decide(out)
}
None => Vec::new(),
}
}
@@ -558,6 +669,130 @@ mod tests {
data
}
// --- Pictures a decoder cannot reconstruct from the stream's own data ---
/// A progressive advanced-profile entry-point I-picture AU (sequence header,
/// entry-point header with the given BROKEN_LINK / CLOSED_ENTRY, frame)
/// followed, in decode order, by B B P (SMPTE 421M §6.2.1: with CLOSED_ENTRY
/// 0 the Bs after the entry-point I may predict from the anchor before it).
fn entry_gop(broken_link: bool, closed_entry: bool) -> Vec<Vec<u8>> {
let seq = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xC0, 0, 0, 0, 0, 0];
let ep0 = (u8::from(broken_link) << 7) | (u8::from(closed_entry) << 6);
let ep = [0x00, 0x00, 0x01, SC_ENTRY_POINT, ep0, 0x00];
let pic = |ptype: u8| vec![0x00, 0x00, 0x01, SC_FRAME, ptype];
vec![
[&seq[..], &ep[..], &pic(0xC0)].concat(),
pic(0x80),
pic(0x80),
pic(0x00),
]
}
fn coding_types(aus: Vec<Vec<u8>>) -> Vec<CodingType> {
let mut p = Vc1Parser::new();
aus.into_iter()
.flat_map(|au| p.parse(&make_pes(au, Some(0))))
.map(|f| f.coding.unwrap().coding_type())
.collect()
}
#[test]
fn an_open_entry_point_at_the_stream_start_drops_its_leading_b_pictures() {
assert_eq!(
coding_types(entry_gop(false, false)),
[CodingType::I, CodingType::P]
);
}
#[test]
fn interlaced_pictures_are_judged_from_fcm_and_fptype() {
// INTERLACE = 1. FCM '11' + FPTYPE: '000' I/I, '011' P/P, '100' B/B, '111' BI/BI;
// FCM '10' (frame interlace) + PTYPE '110' I; FCM '0' + PTYPE '10' B.
let seq_int = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xC0, 0, 0, 0, 0, 0x40];
let pic = |b: u8| vc1_picture(&[b], Some(&seq_int));
assert_eq!(pic(0b1100_0000), Some(Vc1Pic::Intra));
assert_eq!(pic(0b1101_1000), Some(Vc1Pic::Predicted));
assert_eq!(pic(0b1110_0000), Some(Vc1Pic::Bi));
assert_eq!(pic(0b1111_1000), Some(Vc1Pic::IntraB));
assert_eq!(pic(0b1011_0000), Some(Vc1Pic::Intra));
assert_eq!(pic(0b0100_0000), Some(Vc1Pic::Bi));
}
#[test]
fn an_interlaced_open_entry_point_at_the_stream_start_drops_its_leading_b_field_pairs() {
let seq = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xC0, 0, 0, 0, 0, 0x40];
let ep = [0x00, 0x00, 0x01, SC_ENTRY_POINT, 0x00, 0x00];
let pic = |b: u8| vec![0x00, 0x00, 0x01, SC_FRAME, b];
let aus = [
[&seq[..], &ep[..], &pic(0b1100_1000)].concat(), // I/P field pair
pic(0b1110_0000), // B/B, leading
pic(0b1101_1000), // P/P
pic(0b1110_0000), // B/B, trailing
];
let mut p = Vc1Parser::new();
let n: Vec<usize> = aus
.into_iter()
.map(|au| p.parse(&make_pes(au, Some(0))).len())
.collect();
assert_eq!(n, [1, 0, 1, 1]);
}
#[test]
fn a_closed_entry_point_at_the_stream_start_keeps_every_picture() {
assert_eq!(
coding_types(entry_gop(false, true)),
[CodingType::I, CodingType::B, CodingType::B, CodingType::P]
);
}
#[test]
fn a_broken_link_drops_the_leading_b_pictures_mid_stream() {
let mut aus = entry_gop(false, true);
aus.extend(entry_gop(true, false));
assert_eq!(
coding_types(aus),
[
CodingType::I,
CodingType::B,
CodingType::B,
CodingType::P,
CodingType::I,
CodingType::P
]
);
}
#[test]
fn a_gap_resync_onto_an_open_entry_point_emits_no_leading_b_pictures() {
let mut aus = entry_gop(false, true);
aus.extend(entry_gop(false, false));
let mut p = Vc1Parser::new();
let mut gate = crate::mux::resync::ResyncGate::new();
let types: Vec<CodingType> = aus
.into_iter()
.enumerate()
.flat_map(|(i, au)| {
let mut pes = make_pes(au, Some(0));
// The first entry segment's P follows lost data.
pes.discontinuity = i == 3;
p.parse(&pes)
})
.filter(|f| gate.admit(true, f.discontinuity, f.keyframe))
.map(|f| f.coding.unwrap().coding_type())
.collect();
assert_eq!(
types,
[
CodingType::I,
CodingType::B,
CodingType::B,
CodingType::I,
CodingType::P
],
"the second entry's Bs reference the P the gate dropped"
);
}
// --- sequence header detection ---
#[test]
+5 -1
View File
@@ -901,12 +901,16 @@ impl PesSink for DemuxSink {
let is_video = self.video_tracks.contains(&frame.track);
// See `MkvMuxer::write_frame`: `None` is material outside the
// playlist's clip marks and is dropped rather than emitted.
let Some(pts) = self.timeline.map(
let Some(pts) = self.timeline.map_picture(
frame.pts,
drives,
frame.track,
is_video,
frame.source.map(|s| s.byte),
is_video.then_some(crate::mux::timeline::SeamPic {
keyframe: frame.keyframe,
coding: frame.coding,
}),
) else {
return Ok(());
};
+16 -6
View File
@@ -3175,7 +3175,8 @@ mod tests {
}
}
fn finish_flushing(steps: u64, gap: Duration, tail: Duration) -> Vec<(u64, u64)> {
// The forwarded `BytesDurable` calls, and how long the finish took.
fn finish_flushing(steps: u64, gap: Duration, tail: Duration) -> (Vec<(u64, u64)>, Duration) {
let flush = FlushProgress::new(crate::halt::Liveness::new());
let sink = FlushingClose {
flush: flush.clone(),
@@ -3187,18 +3188,21 @@ mod tests {
let pipe = Pipeline::spawn_named_with_progress("t-flush", 4, sink, progress).unwrap();
let spy = Arc::new(FlushSpy::default());
let ctx = crate::ctx::Ctx::default().with_events(spy.clone());
let t = std::time::Instant::now();
finish_pumped(pipe, &Halt::new(), false, &flush, &ctx).unwrap();
let took = t.elapsed();
drop(ctx);
let spy = Arc::try_unwrap(spy).ok().expect("the only handle");
spy.0.into_inner().unwrap()
(spy.0.into_inner().unwrap(), took)
}
/// LP20 (§4.5): while the driver waits on a closing consumer, each increase of the
/// flusher's bytes produces one `BytesDurable`, and none while it is static.
#[test]
fn finish_with_halt_forwards_flush_progress() {
let calls = finish_flushing(4, Duration::from_millis(300), Duration::from_millis(600));
let want: Vec<(u64, u64)> = (1..=4).map(|i| (i * 1000, 4000)).collect();
// Each gap is 4× the rate limit, so no increase is coalesced into its neighbour.
let (calls, _) = finish_flushing(3, FLUSH_PROGRESS_EVERY * 4, Duration::from_millis(600));
let want: Vec<(u64, u64)> = (1..=3).map(|i| (i * 1000, 3000)).collect();
assert_eq!(
calls, want,
"one call per increase, none during the idle tail"
@@ -3209,8 +3213,14 @@ mod tests {
/// and the last value still arrives.
#[test]
fn flush_progress_is_rate_limited_to_4hz() {
let calls = finish_flushing(20, Duration::from_millis(5), Duration::from_millis(600));
assert!(!calls.is_empty() && calls.len() <= 3, "{calls:?}");
let (calls, took) =
finish_flushing(20, Duration::from_millis(5), Duration::from_millis(600));
// One call at the start of each rate-limit period, plus the final value.
let periods = (took.as_millis() / FLUSH_PROGRESS_EVERY.as_millis()) as usize;
assert!(
!calls.is_empty() && calls.len() <= periods + 2 && calls.len() < 20,
"{calls:?} in {took:?}"
);
assert_eq!(calls.last(), Some(&(20_000, 20_000)));
}
+119 -5
View File
@@ -111,6 +111,13 @@ pub struct M2tsStream {
route: Vec<Option<(usize, Repack)>>,
/// MPEG-2 multichannel extension tracks: no PMT descriptor binds them to their base.
excluded: super::ps::UnstoredExtensions,
/// Clip-join PTS correction shared with the other sinks: a multi-clip playlist's
/// source PTS resets or jumps at each join, and one TS carries one timeline.
timeline: super::timeline::TimelineContinuity,
/// The input track (base-view video) that drives the timeline's epochs.
ref_video: Option<usize>,
/// Frames the timeline placed (the denominator for its drop count).
frames_mapped: u64,
}
impl M2tsStream {
@@ -230,11 +237,26 @@ impl M2tsStream {
muxer.set_codec_private(i, data.clone())?;
}
}
let ref_video = title
.streams
.iter()
.enumerate()
.find(|&(i, s)| {
matches!(s, DiscStream::Video(v) if !v.is_mvc_dependent())
&& matches!(route.get(i), Some(Some(_)))
})
.map(|(i, _)| i);
Ok(Self {
disc_title: title.clone(),
muxer,
route,
excluded,
timeline: super::timeline::TimelineContinuity::with_clips(
&title.clips,
title.content_format,
),
ref_video,
frames_mapped: 0,
})
}
}
@@ -244,7 +266,31 @@ impl crate::pes::PesSink for M2tsStream {
if self.excluded.drop_frame(frame.track) {
return Ok(());
}
match self.route.get(frame.track).copied() {
let route = self.route.get(frame.track).copied();
let mut pts = frame.pts;
if let Some(Some(_)) = route {
// Onto the continuous timeline first; `None` is material outside the clip marks.
let is_video = matches!(
self.disc_title.streams.get(frame.track),
Some(DiscStream::Video(_))
);
let Some(mapped) = self.timeline.map_picture(
frame.pts,
Some(frame.track) == self.ref_video,
frame.track,
is_video,
frame.source.map(|s| s.byte),
is_video.then_some(crate::mux::timeline::SeamPic {
keyframe: frame.keyframe,
coding: frame.coding,
}),
) else {
return Ok(());
};
pts = mapped;
self.frames_mapped += 1;
}
match route {
// Dropped at create (already warned): nothing to write.
Some(None) => Ok(()),
// Parser output is plain PCM (16 or 24-bit); BD-TS needs the BD LPCM framing back.
@@ -252,7 +298,7 @@ impl crate::pes::PesSink for M2tsStream {
for (offset_ns, payload) in super::codec::lpcm::bd_payloads(&frame.data, header) {
self.muxer.write_frame(
track,
frame.pts.saturating_add(offset_ns),
pts.saturating_add(offset_ns),
frame.keyframe,
&payload,
)?;
@@ -265,12 +311,11 @@ impl crate::pes::PesSink for M2tsStream {
tracing::warn!(target: "mux", track, len = frame.data.len(), "AAC access unit too long for ADTS; dropped");
return Ok(());
};
self.muxer
.write_frame(track, frame.pts, frame.keyframe, &adts)
self.muxer.write_frame(track, pts, frame.keyframe, &adts)
}
Some(Some((track, Repack::Verbatim))) => {
self.muxer
.write_frame(track, frame.pts, frame.keyframe, &frame.data)
.write_frame(track, pts, frame.keyframe, &frame.data)
}
// Out of range: let the muxer report its usual error.
None => self
@@ -280,6 +325,26 @@ impl crate::pes::PesSink for M2tsStream {
}
fn finish(&mut self) -> io::Result<()> {
// As the other sinks: a seam plan that dropped everything, or most of the title,
// is a failed mux, not a short file.
let seam_dropped = self.timeline.dropped_total();
if self.frames_mapped == 0 && seam_dropped > 0 {
return Err(crate::error::Error::SinkWroteNothing.into());
}
if seam_dropped > self.frames_mapped {
return Err(crate::error::Error::SeamPlanDroppedMost {
dropped: seam_dropped,
written: self.frames_mapped,
}
.into());
}
if seam_dropped > 0 {
tracing::info!(
target: "mux",
dropped = seam_dropped,
"frames outside the playlist's clip marks were dropped at clip joins"
);
}
self.muxer.finish()
}
@@ -894,6 +959,55 @@ mod tests {
assert_eq!(t[2].1, Some(t[0].0), "P3 decodes when I0 is presented");
}
// A multi-clip title's source PTS restarts at each join: the TS carries one continuous
// timeline, so the second clip follows the first and its reordered AUs keep their DTS.
#[test]
fn a_clip_join_pts_reset_continues_the_timeline_and_its_dts() {
use crate::mux::decode_ts::test_es as es;
let sps = es::sps_with_reorder(2);
let mut title = make_title();
if let DiscStream::Video(v) = &mut title.streams[0] {
v.codec = Codec::H264;
}
title.codec_privates = vec![Some(es::avcc(&sps.nal()))];
let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap();
// Decode order I P B B, repeated: 6 s clips at 24 fps, each from 1 s.
let order = [0i64, 3, 1, 2];
for _clip in 0..2 {
for i in 0..144i64 {
let shown = (i / 4) * 4 + order[(i % 4) as usize];
let au = es::length_prefixed(&[es::h264_slice(&sps, i == 0, 0, i as u32, None)]);
let pts = 1_000_000_000 + shown * 41_666_667;
stream.write(&frame(0, pts, i == 0, au)).unwrap();
}
}
stream.finish().unwrap();
drop(stream);
let buf = shared.lock().unwrap().clone();
let (_, ts) = ts_after_header(&buf);
let t = pes_times(&ts, VIDEO_PID);
assert_eq!(t.len(), 288);
let clip1_max = t[..144].iter().map(|x| x.0).max().unwrap();
let clip2_min = t[144..].iter().map(|x| x.0).min().unwrap();
assert!(
clip2_min > clip1_max,
"clip 2 at {clip2_min} overlaps clip 1 ({clip1_max})"
);
let dts: Vec<u64> = t.iter().map(|&(p, d)| d.unwrap_or(p)).collect();
assert!(
dts.windows(2).all(|w| w[1] > w[0]),
"DTS strictly increase across the join: {:?}",
&dts[140..148]
);
let with_dts = |r: &[(u64, Option<u64>)]| r.iter().filter(|x| x.1.is_some()).count();
assert_eq!(
with_dts(&t[144..]),
with_dts(&t[..144]),
"clip 2 keeps its DTS"
);
}
// Design §2.3: the MVC dependent view copies its base AU's DTS.
fn mvc_dependent_dts(dependent_first: bool) {
use crate::mux::decode_ts::test_es as es;
+21 -4
View File
@@ -1509,6 +1509,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
src_byte: Option<u64>,
scan: Option<super::codec::FieldOrder>,
) -> io::Result<()> {
use super::codec::{FieldOrder, PictureInfo, coding::CodingType, coding::Mpeg2Coding};
// A P frame picture carrying `scan`.
let coding = scan.map(|o| {
PictureInfo::mpeg2(
CodingType::P,
Mpeg2Coding {
top_field_first: o == FieldOrder::Tff,
repeat_first_field: false,
progressive_frame: o == FieldOrder::Progressive,
progressive_sequence: false,
frame_picture: true,
},
)
});
self.write_frame_at_with_padding(
track_idx,
pts_ns,
@@ -1517,7 +1531,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
duration_ns,
block_additional,
src_byte,
scan,
coding,
0,
)
}
@@ -1532,9 +1546,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
src_byte: Option<u64>,
scan: Option<super::codec::FieldOrder>,
coding: Option<super::codec::PictureInfo>,
discard_padding_ns: i64,
) -> io::Result<()> {
// This picture's measured scan type, tallied for the FlagInterlaced majority.
let scan = coding.as_ref().and_then(|c| c.field_order());
// --log-level 3: capture the first ~100 coded frames per track to the side file
// BEFORE any timeline mangling, with the parser's own PTS, so an opening-GOP
// issue is reconstructable offline. No-op/no alloc normally (capture = `None`).
@@ -1563,12 +1579,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Map the raw PES PTS onto the continuous timeline FIRST: source PTS jumps
// backward at a clip boundary, so rebasing avoids non-monotonic timestamps.
// `None` = outside clip IN/OUT marks; dropping avoids duplicate content at a join.
let Some(pts_ns) = self.continuity.map(
let Some(pts_ns) = self.continuity.map_picture(
pts_ns,
drives_epoch,
track_idx,
self.track_is_video.get(track_idx).copied().unwrap_or(false),
is_video,
src_byte,
is_video.then_some(super::timeline::SeamPic { keyframe, coding }),
) else {
return Ok(());
};
+21 -3
View File
@@ -583,8 +583,8 @@ fn emit_to_muxer(
additional,
// Provenance: which clip this frame came from is a lookup, not a guess.
frame.source.map(|s| s.byte),
// This picture's measured scan type, tallied for the FlagInterlaced majority.
frame.coding.as_ref().and_then(|c| c.field_order()),
// This picture's measured coding: its scan type and what the seam plan may trim.
frame.coding,
frame.discard_padding_ns,
)
}
@@ -821,7 +821,7 @@ impl MkvStream {
f.duration_ns,
additional.as_deref(),
f.source.map(|s| s.byte),
f.coding.as_ref().and_then(|c| c.field_order()),
f.coding,
f.discard_padding_ns,
);
// A track-range reject writes nothing and is not fatal (see
@@ -3118,6 +3118,24 @@ mod tests {
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
}
// The base parser drops a leading picture it cannot decode; the dependent access unit
// of that PTS pairs with no base and is never written.
#[test]
fn mvc_merge_drops_the_dependent_of_a_dropped_base() {
let mut m = empty_merge();
assert!(
m.ingest(&mvc_frame(2, 1, false, lp(&[&DEP_SLICE])))
.is_empty()
);
let dep = lp(&[&DEP_SLICE, &DEP_PPS]);
assert!(m.ingest(&mvc_frame(2, 3, false, dep.clone())).is_empty());
let e = m.ingest(&mvc_frame(0, 3, true, vec![0x65, 1]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep.as_slice()));
assert!(m.flush().is_empty());
assert_eq!(m.orphan_deps, 1);
}
#[test]
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
let mut m = empty_merge();
+5 -1
View File
@@ -490,12 +490,16 @@ impl<W: Write + Seek + Send> PesSink for Mp4Sink<W> {
}
// Onto the continuous timeline first; `None` is material outside the clip marks.
let is_video = self.tracks[slot].media == Media::Video;
let Some(pts_ns) = self.timeline.map(
let Some(pts_ns) = self.timeline.map_picture(
frame.pts,
Some(frame.track) == self.ref_video,
frame.track,
is_video,
frame.source.map(|s| s.byte),
is_video.then_some(crate::mux::timeline::SeamPic {
keyframe: frame.keyframe,
coding: frame.coding,
}),
) else {
return Ok(());
};
+5 -1
View File
@@ -678,12 +678,16 @@ impl<W: Write + Send> PesSink for MpgSink<W> {
}
let is_video = out == self.video_out;
// Onto the continuous timeline first; `None` is material outside the clip marks.
let Some(pts_ns) = self.timeline.map(
let Some(pts_ns) = self.timeline.map_picture(
frame.pts,
is_video,
frame.track,
is_video,
frame.source.map(|s| s.byte),
is_video.then_some(crate::mux::timeline::SeamPic {
keyframe: frame.keyframe,
coding: frame.coding,
}),
) else {
return Ok(());
};
+31 -10
View File
@@ -686,15 +686,24 @@ mod tests {
assert_eq!(frames[0].pts, 90000);
}
// Accept one sender and read to the end; returns the frame count and the
// terminal read result (Ok(None) = a clean end, Err = a failed sender).
// Accept one sender and read to the end: the frame count and the terminal read
// (Ok(None) = a clean end, Err = a failed sender). It signals once the header is
// read; a reset before that fails the accept (macOS: EINVAL on a reset socket).
type ReadEnd = (usize, io::Result<Option<crate::pes::PesFrame>>);
type Reader = (
std::net::SocketAddr,
std::thread::JoinHandle<ReadEnd>,
std::sync::mpsc::Receiver<()>,
);
fn spawn_ending_reader() -> (std::net::SocketAddr, std::thread::JoinHandle<ReadEnd>) {
fn spawn_ending_reader() -> Reader {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (accepted_tx, accepted) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let mut ns = NetworkStream::accept_from(listener).unwrap();
let ns = NetworkStream::accept_from(listener);
let _ = accepted_tx.send(());
let mut ns = ns.unwrap();
let mut n = 0;
loop {
match crate::pes::PesSource::read(&mut ns) {
@@ -703,7 +712,17 @@ mod tests {
}
}
});
(addr, handle)
(addr, handle, accepted)
}
// Send the header and what is written so far, and wait until the receiver has accepted.
fn flush_to_accepted(writer: &mut NetworkStream, accepted: &std::sync::mpsc::Receiver<()>) {
if let Mode::Write { writer: w, .. } = &mut writer.mode {
w.flush().unwrap();
}
accepted
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the receiver accepted");
}
fn one_frame() -> crate::pes::PesFrame {
@@ -725,11 +744,12 @@ mod tests {
fn a_sender_that_fails_mid_title_is_an_error_at_the_receiver() {
use crate::pes::PesSink as _;
for incomplete in [false, true] {
let (addr, handle) = spawn_ending_reader();
let (addr, handle, accepted) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
writer.write(&one_frame()).unwrap();
flush_to_accepted(&mut writer, &accepted);
if incomplete {
writer.finish_incomplete().unwrap();
}
@@ -738,7 +758,7 @@ mod tests {
assert!(end.is_err(), "incomplete={incomplete}: got {end:?}");
}
// A finished sender still ends cleanly.
let (addr, handle) = spawn_ending_reader();
let (addr, handle, _) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
@@ -755,11 +775,12 @@ mod tests {
#[test]
fn finish_incomplete_resets_the_connection_at_once() {
use crate::pes::PesSink as _;
let (addr, handle) = spawn_ending_reader();
let (addr, handle, accepted) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
writer.write(&one_frame()).unwrap();
flush_to_accepted(&mut writer, &accepted);
let linger = |w: &NetworkStream| match &w.mode {
Mode::Write { writer, .. } => {
socket2::SockRef::from(writer.get_ref()).linger().unwrap()
@@ -787,7 +808,7 @@ mod tests {
assert_eq!(timings, vec![Default::default(), timing]);
assert!(set_timing(&mut timings, 2, timing, 2).is_err());
let (addr, handle) = spawn_ending_reader();
let (addr, handle, _) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
@@ -806,7 +827,7 @@ mod tests {
// A connection cut inside a frame (even with a clean FIN) is an error.
#[test]
fn a_connection_cut_mid_frame_is_an_error_at_the_receiver() {
let (addr, handle) = spawn_ending_reader();
let (addr, handle, _) = spawn_ending_reader();
let mut raw = TcpStream::connect(addr).unwrap();
let m = meta::M2tsMeta::from_title(&sample_title());
meta::write_header(&mut raw, &m).unwrap();
+210 -5
View File
@@ -3,7 +3,7 @@
//! A BD/UHD title's clips are read as one concatenated sector stream, so the source PES PTS
//! does not run continuously across a clip join. [`SeamPlan`] places frames exactly from the
//! playlist's marks when present; [`TimelineContinuity::adjust`] infers seams from PTS jumps
//! otherwise, and [`TimelineContinuity::map`] picks between them so every muxer/sink shares one
//! otherwise, and [`TimelineContinuity::map_picture`] picks between them so every muxer/sink shares one
//! correction path.
// A backward PTS step larger than this is a clip-boundary discontinuity (source PES PTS reset),
@@ -47,8 +47,8 @@ pub(crate) struct SeamPlan {
// be trusted to identify a clip. If not, provenance is disabled.
spans_trusted: bool,
clips: Vec<SeamClip>,
// Frames dropped (per track) for falling outside every clip's marks. Counted so an
// unexpected volume is visible instead of silent.
// Frames dropped (per track) for falling outside every clip's marks, or for predicting
// from a picture that did. Counted so an unexpected volume is visible instead of silent.
dropped: Vec<u64>,
// Per-track position: (clip index, last raw PTS seen). Each track crosses a join on its OWN
// frame, since overlap tails arrive after the next clip's video.
@@ -68,6 +68,48 @@ struct TrackPos {
/// silently losing content or rewinding, so the invariant is now checked
/// directly rather than inferred from which rule happened to fire.
last_out_ns: Option<i64>,
/// What this video track's trims took from the pictures after them.
refs: TrimRefs,
}
/// A video picture as the seam plan sees it: enough to keep its trims decodable.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SeamPic {
pub(crate) keyframe: bool,
pub(crate) coding: Option<crate::mux::codec::PictureInfo>,
}
/// Whether a video track's kept pictures still have their references after a trim. A mark
/// need not sit on a random-access picture: a player decodes from it and shows from the
/// mark, but this output can only drop, so a picture predicting from a trimmed one goes too.
#[derive(Debug, Clone, Copy, Default)]
struct TrimRefs {
/// A picture later ones may reference was not kept: drop up to the next keyframe.
broken: bool,
/// The keyframe that ended a broken run (PTS): its leading pictures predict from before it.
resumed_at: Option<i64>,
/// The last keyframe's PTS: a picture displayed before it is leading, and no trailing
/// picture predicts from a leading one.
key_ns: Option<i64>,
}
impl TrimRefs {
// Decide a picture (decode order) the marks `placed`; true keeps it.
fn admit(&mut self, raw_ns: i64, placed: bool, pic: SeamPic) -> bool {
if pic.keyframe {
self.resumed_at = self.broken.then_some(raw_ns);
self.broken = false;
self.key_ns = Some(raw_ns);
}
let leading = !pic.keyframe && self.key_ns.is_some_and(|k| raw_ns < k);
let keep = placed && !self.broken && !(leading && self.resumed_at.is_some());
// Unknown coding may be a reference; an MPEG-2 B-picture never is.
let referenced = pic.coding.is_none_or(|c| c.may_be_referenced());
if !keep && !leading && referenced {
self.broken = true;
}
keep
}
}
impl SeamPlan {
@@ -224,6 +266,27 @@ impl SeamPlan {
first
}
/// [`Self::place`] a video picture, also dropping one that predicts from a picture
/// the marks dropped (see [`TrimRefs`]).
fn place_picture(
&mut self,
raw_ns: i64,
track: usize,
has_reorder: bool,
src_byte: Option<u64>,
pic: SeamPic,
) -> Option<i64> {
let placed = self.place(raw_ns, track, has_reorder, src_byte);
let keep = self.cursors[track]
.refs
.admit(raw_ns, placed.is_some(), pic);
if placed.is_some() && !keep {
self.dropped[track] = self.dropped[track].saturating_add(1);
return None;
}
placed
}
/// Place a raw PTS for `track`, advancing that track's own clip cursor.
/// `None` means DROP: the frame lies outside every clip's marks, so the
/// playlist does not include it.
@@ -241,6 +304,7 @@ impl SeamPlan {
clip: 0,
last_raw_ns: None,
last_out_ns: None,
refs: TrimRefs::default(),
},
);
self.dropped.resize(track + 1, 0);
@@ -269,6 +333,7 @@ impl SeamPlan {
} else {
self.cursors[track].last_out_ns
},
refs: self.cursors[track].refs,
};
if !placed {
// Outside its own clip's marks: material the playlist excludes
@@ -372,6 +437,7 @@ impl SeamPlan {
} else {
self.cursors[track].last_out_ns
},
refs: self.cursors[track].refs,
};
if !placed {
self.dropped[track] = self.dropped[track].saturating_add(1);
@@ -495,7 +561,8 @@ impl TimelineContinuity {
}
// Map a raw PES PTS onto the output timeline, or `None` to drop the frame (only ever
// happens under a SeamPlan).
// happens under a SeamPlan). Sinks call `map_picture`.
#[cfg(test)]
pub(crate) fn map(
&mut self,
raw_pts_ns: i64,
@@ -506,12 +573,29 @@ impl TimelineContinuity {
// source stamps it. Under a seam plan this identifies the clip
// directly; without it the mark heuristics are used instead.
src_byte: Option<u64>,
) -> Option<i64> {
self.map_picture(raw_pts_ns, drives_epoch, track, has_reorder, src_byte, None)
}
// `map`, and for a video picture (`pic`) a seam plan also drops it when it predicts
// from a picture the marks dropped.
pub(crate) fn map_picture(
&mut self,
raw_pts_ns: i64,
drives_epoch: bool,
track: usize,
has_reorder: bool,
src_byte: Option<u64>,
pic: Option<SeamPic>,
) -> Option<i64> {
if self.seams.is_some() {
// Take the plan out for the call so `place` can borrow `self`
// mutably without fighting the borrow checker over the whole struct.
let mut plan = self.seams.take().expect("checked is_some");
let placed = plan.place(raw_pts_ns, track, has_reorder, src_byte);
let placed = match pic {
Some(pic) => plan.place_picture(raw_pts_ns, track, has_reorder, src_byte, pic),
None => plan.place(raw_pts_ns, track, has_reorder, src_byte),
};
self.seams = Some(plan);
if let Some(p) = placed {
// Keep the frontier meaningful for anything that reads it, and
@@ -642,6 +726,7 @@ impl TimelineContinuity {
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::codec::coding::CodingType;
const S: i64 = 1_000_000_000; // 1 second in ns
@@ -955,6 +1040,126 @@ mod tests {
);
}
/// A coded-type-only picture: the keyframe I, else a P.
fn pic(keyframe: bool) -> SeamPic {
use crate::mux::codec::PictureInfo;
let ct = if keyframe {
CodingType::I
} else {
CodingType::P
};
SeamPic {
keyframe,
coding: Some(PictureInfo::coding_type_only(ct)),
}
}
// A PlayItem's IN mark need not sit on a GOP's I-picture: a player decodes from the
// preceding entry point and suppresses display until IN. The plan trims by each frame's
// own PTS, so a picture predicted from the I it trimmed before IN must go too.
#[test]
fn the_in_mark_never_keeps_a_picture_whose_reference_it_dropped() {
const F: i64 = 41_708_333; // one 23.976 fps frame (ns)
let clips = vec![crate::disc::Clip {
feed_span: Some((0, 1_000_000)),
clip_id: "00001".into(),
in_time: 45_000, // IN = 1 s, mid-GOP
out_time: 450_000,
duration_secs: 9.0,
source_packets: 0,
}];
let mut plan = SeamPlan::from_clips(&clips).expect("one clip");
// Decode order: I (display 0.958 s, before IN), then P predicted from it
// (display 1.083 s, after IN).
let i_kept = plan
.place_picture(S - F, 0, true, Some(0), pic(true))
.is_some();
let p_kept = plan
.place_picture(S + 2 * F, 0, true, Some(100), pic(false))
.is_some();
assert!(
i_kept || !p_kept,
"the P is kept but its reference I was dropped (i_kept={i_kept}, p_kept={p_kept})"
);
}
/// One clip, IN = 1 s, OUT = 10 s, and a placer of `(display ns, keyframe, coding type)`
/// pictures in decode order returning which were kept.
fn place_run(pics: &[(i64, bool, CodingType)]) -> (Vec<bool>, u64) {
let clips = vec![crate::disc::Clip {
feed_span: Some((0, 1_000_000)),
clip_id: "00001".into(),
in_time: 45_000,
out_time: 450_000,
duration_secs: 9.0,
source_packets: 0,
}];
let mut plan = SeamPlan::from_clips(&clips).expect("one clip");
let kept = pics
.iter()
.enumerate()
.map(|(i, &(ns, keyframe, ct))| {
let p = SeamPic {
keyframe,
coding: Some(crate::mux::codec::PictureInfo::coding_type_only(ct)),
};
plan.place_picture(ns, 0, true, Some(i as u64), p).is_some()
})
.collect();
(kept, plan.dropped_for(0))
}
const F: i64 = 41_708_333; // one 23.976 fps frame (ns)
// The OUT mark trims an anchor displayed after it, which B-pictures displayed before it
// were decoded after and predict from.
#[test]
fn the_out_mark_never_keeps_a_picture_whose_reference_it_dropped() {
use CodingType::*;
let out = 10 * S;
let (kept, dropped) = place_run(&[
(out - 3 * F, true, I),
(out + F, false, P),
(out - 2 * F, false, B),
(out - F, false, B),
]);
assert_eq!(kept, [true, false, false, false]);
assert_eq!(dropped, 3);
}
// The common opening: IN on the I-picture, its leading B-pictures before IN. Nothing
// after the I predicts from them.
#[test]
fn leading_pictures_trimmed_before_the_in_mark_keep_the_rest_of_the_gop() {
use CodingType::*;
let (kept, dropped) = place_run(&[
(S, true, I),
(S - 2 * F, false, B),
(S - F, false, B),
(S + 3 * F, false, P),
(S + F, false, B),
(S + 2 * F, false, B),
]);
assert_eq!(kept, [true, false, false, true, true, true]);
assert_eq!(dropped, 2);
}
// After a trimmed I the track resumes at the next keyframe, without that keyframe's
// leading pictures (they predict from the dropped P), and with its trailing ones.
#[test]
fn a_mid_gop_in_mark_resumes_at_the_next_keyframe_without_its_leading_pictures() {
use CodingType::*;
let (kept, _) = place_run(&[
(S - F, true, I),
(S + 2 * F, false, P),
(S + 5 * F, true, I),
(S + 3 * F, false, B),
(S + 8 * F, false, P),
(S + 6 * F, false, B),
]);
assert_eq!(kept, [false, false, true, false, true, true]);
}
#[test]
fn a_repeated_clip_shares_one_span_and_is_still_trusted() {
let mut clips = clips_with_spans();
+2 -2
View File
@@ -255,9 +255,9 @@ mod heartbeat_tests {
/// resets.
#[test]
fn beats_once_per_interval() {
let mut hb = Heartbeat::with_interval("test", Duration::from_millis(200));
let mut hb = Heartbeat::with_interval("test", Duration::from_secs(1));
assert!(!hb.tick(1, 100));
std::thread::sleep(Duration::from_millis(250));
std::thread::sleep(Duration::from_millis(1100));
assert!(hb.tick(2, 100), "should beat after interval elapsed");
// Immediately after, throttle suppresses the next.
assert!(!hb.tick(3, 100));
+13 -5
View File
@@ -551,17 +551,25 @@ mod tests {
const LONG_MS: u32 = 10_000;
/// Cancel lands this long after the wait starts.
const CANCEL_AFTER: Duration = Duration::from_millis(50);
/// A cancelled wait returns within this of the cancel: a few 20 ms slices, CI-robust (§3.2).
const WAKE_BOUND: Duration = Duration::from_millis(150);
/// The unmount child is spawned well before a cancel this late, even on a loaded runner.
const SPAWN_CANCEL_AFTER: Duration = Duration::from_millis(500);
/// A cancelled wait returns within this of the cancel: a few 20 ms slices plus a kill and
/// reap, with a 5× margin over a 200 ms scheduler stall, and far under `LONG_MS` (§3.2).
const WAKE_BOUND: Duration = Duration::from_secs(1);
// Run `wait` against a token cancelled CANCEL_AFTER in; returns its result and the time
// from the cancel to its return.
fn cancel_during(wait: impl FnOnce(*const u8) -> i32) -> (i32, Duration) {
cancel_after(CANCEL_AFTER, wait)
}
// `cancel_during` with the cancel landing `after` into the wait.
fn cancel_after(after: Duration, wait: impl FnOnce(*const u8) -> i32) -> (i32, Duration) {
let halt = Halt::new();
let canceller = {
let halt = halt.clone();
thread::spawn(move || {
thread::sleep(CANCEL_AFTER);
thread::sleep(after);
let at = Instant::now();
halt.cancel();
at
@@ -633,7 +641,7 @@ mod tests {
#[test]
fn shim_selftest_cancel_kills_and_reaps_the_unmount() {
let mut pid = 0;
let (rc, wake) = cancel_during(|c| unsafe {
let (rc, wake) = cancel_after(SPAWN_CANCEL_AFTER, |c| unsafe {
shim_selftest_run_and_reap(
c"/bin/sleep".as_ptr().cast(),
c"30".as_ptr().cast(),
@@ -739,7 +747,7 @@ mod tests {
r.err()
);
assert!(
t0.elapsed() < Duration::from_millis(250),
t0.elapsed() < Duration::from_millis(450),
"refusal took {:?}: the post-unmount settle sleep ran",
t0.elapsed()
);
+18 -30
View File
@@ -95,8 +95,8 @@ impl PrefetchedSectorSource {
/// composed read+decrypt stack — every byte the producer emits is
/// what the consumer's demux will feed to its codec parsers.
///
/// Precondition: each extent's `sector_count` should be a multiple of [`SECTOR_ALIGNMENT`],
/// else [`Error::ExtentNotUnitAligned`] surfaces through the channel.
/// Reads come in whole units; a shorter extent tail is one read the decrypt stage judges.
/// A non-whole-sector read surfaces [`Error::ExtentNotUnitAligned`] through the channel.
///
/// [`Error::ExtentNotUnitAligned`]: crate::error::Error::ExtentNotUnitAligned
///
@@ -978,11 +978,11 @@ mod tests {
});
}
// An extent whose sector_count is not a multiple of 3 must not emit a
// still-encrypted sub-unit tail: the producer delivers the readable
// full units, then surfaces a typed error on the tail.
// An extent whose sector_count is not a multiple of 3 delivers its whole units, then
// its sub-unit tail as one short batch, then EOF. Judging the tail (clear passes, an
// encrypted fragment is refused) is the decrypt stage's job, not the reader's.
#[test]
fn non_multiple_of_three_extent_errors_on_tail() {
fn non_multiple_of_three_extent_reads_its_tail() {
let _serial = serial();
with_watchdog(Duration::from_secs(10), || {
// 8 sectors = 2 full units (6 sectors) + 2 leftover.
@@ -993,23 +993,15 @@ mod tests {
let src = PatternSource { capacity: 200 };
let mut pf =
PrefetchedSectorSource::new(src, extents, 3, &Ctx::default()).expect("spawn");
let mut buf = vec![0u8; 3 * 2048];
// First two reads: the 6 unit-aligned sectors come through
// as full 3-sector (6144-byte) batches.
let n0 = pf.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(n0, 3 * 2048);
let n1 = pf.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(n1, 3 * 2048);
// Third read hits the 2-sector tail: it must be an error,
// never a 4096-byte (sub-unit) batch that decrypt would
// leave encrypted.
let err = pf.read_sectors(0, 3, &mut buf, false);
assert!(
err.is_err(),
"non-unit-aligned tail must error, got Ok({:?})",
err
);
let n2 = pf.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(n2, 2 * 2048, "the 2-sector tail is one batch");
assert_eq!(buf[0], 106, "the tail starts at lba 106");
assert_eq!(pf.read_sectors(0, 3, &mut buf, false).unwrap(), 0, "EOF");
});
}
@@ -1299,7 +1291,7 @@ mod tests {
// An extent whose sector_count IS a multiple of 3 must deliver exactly
// that many sectors and then cleanly EOF (no error on the final
// aligned batch); the trailing-tail guard only fires on sub-unit leftovers.
// aligned batch).
#[test]
fn unit_aligned_extent_delivers_all_and_eofs() {
let _serial = serial();
@@ -1656,11 +1648,11 @@ mod tests {
});
}
// A 4-sector extent (one full unit + a 1-sector tail) must deliver the
// 3-sector unit then error on the 1-sector remainder — exercising the
// trim-within-batch path, distinct control flow from the 8-sector case.
// A 4-sector extent (one full unit + a 1-sector tail) delivers the 3-sector unit,
// then the 1-sector tail, then EOF: the trim-within-batch path, distinct control flow
// from the 8-sector case.
#[test]
fn four_sector_extent_errors_on_one_sector_tail() {
fn four_sector_extent_reads_its_one_sector_tail() {
let _serial = serial();
with_watchdog(Duration::from_secs(10), || {
let extents = vec![Extent {
@@ -1671,13 +1663,9 @@ mod tests {
// batch=9 (>4) so the first iter requests 4, trims to 3.
let mut pf =
PrefetchedSectorSource::new(src, extents, 9, &Ctx::default()).expect("spawn");
let mut buf = vec![0u8; 9 * 2048];
let n0 = pf.read_sectors(0, 9, &mut buf, false).unwrap();
assert_eq!(n0, 3 * 2048, "first batch must be exactly one unit");
let r = pf.read_sectors(0, 9, &mut buf, false);
let err = r.expect_err("1-sector tail must error");
let io: std::io::Error = err.into();
assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput);
let (got, last) = drain_direct(&mut pf, 9, 8);
assert_eq!(last.unwrap(), 0, "EOF after the tail");
assert_eq!(got.len(), 4 * 2048, "every sector of the extent");
});
}
+8 -9
View File
@@ -27,8 +27,8 @@ const SECTOR: usize = crate::consts::SECTOR_BYTES;
/// How the Read stage reads a source (the transport's policy, X-1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadPolicy {
/// An image or file: `batch`-sector reads; any read error ends the run, and an extent
/// tail shorter than one decrypt unit is refused.
/// An image or file: `batch`-sector reads in whole decrypt units (an extent tail shorter
/// than one unit is one read); any read error ends the run.
Image { batch: u16 },
/// A live drive: adaptive batches from `batch` down, one ECC recovery read for a unit
/// that still fails, then a zero-filled, counted unit (`skip_errors`) or a `DiscRead`.
@@ -273,14 +273,13 @@ impl ExtentWalk {
) -> Result<bool> {
self.ctx.halt.check()?;
let align = self.unit_align;
// Decrypt processes only full units, so a tail below one unit can't decrypt:
// refuse it rather than emit encrypted bytes.
if remaining < align {
return Err(Error::ExtentNotUnitAligned);
}
let mut sectors = remaining.min(batch as u32);
// Trim to whole units; a window landing on a sub-unit boundary reads one unit.
sectors = if sectors >= align {
// Trim to whole units; a window landing on a sub-unit boundary reads one unit. An
// extent tail below one unit is read as is: the decrypt stage passes it when clear
// and refuses it when it is the head of an encrypted unit.
sectors = if remaining < align {
remaining
} else if sectors >= align {
sectors - sectors % align
} else {
align