Skip to content

Commit 869ed77

Browse files
authored
fix(core)!: report stream files that demux to zero streams as short (#373)
`ClipSummary` gains a `measured` marker, set from the clip's presence in the measurement pass's file map, and the short-stream check reads it instead of inferring measurement from a non-empty per-stream tally list. A 0-byte or header-destroyed `*.m2ts` opens, reads to a clean end of file and registers no stream, so it left exactly the tallies of a file the scan never opened and went unreported where a partially truncated file is named. It now yields `measured 0.0 s of N s` through the existing thresholds. A file whose partial demux was discarded (`keep_partial` off) is absent from the map, stays unmeasured, and remains recorded as a `ScanError` alone. The browser mirror carries the marker as `Clip.measured` so a mirror still rebuilds the disc it mirrors.
1 parent a98a23c commit 869ed77

5 files changed

Lines changed: 129 additions & 38 deletions

File tree

‎crates/bdinfo-rs-core/src/bdrom/disc.rs‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ pub struct ClipSummary {
260260
pub relative_time_in: f64,
261261
/// Clip duration in seconds.
262262
pub length: f64,
263+
/// Whether the measurement pass demuxed this clip's stream file.
264+
///
265+
/// The one thing that separates "measured as empty" from "never looked
266+
/// at", both of which leave every measured field below at zero: a
267+
/// [`ScanMode::Metadata`] or [`ScanMode::Codecs`] open, a file a
268+
/// `scan_files` selection left out, a file that failed to open, and a file
269+
/// whose partial demux was dropped ([`ScanOptions::keep_partial`] off) all
270+
/// come back `false`, while a 0-byte or header-destroyed file that opens,
271+
/// reads to a clean end of file and registers no stream at all comes back
272+
/// `true` with nothing measured — which is what lets
273+
/// [`BdRom::short_stream_files`] name it.
274+
pub measured: bool,
263275
/// Payload bytes the demux attributed to this clip.
264276
pub payload_bytes: u64,
265277
/// Transport packets the demux attributed to this clip.
@@ -311,7 +323,7 @@ impl ClipSummary {
311323

312324
/// Test constructors for [`PlaylistSummary`] and [`ClipSummary`].
313325
///
314-
/// Both are wide plain-data structs — 11 and 12 public fields — while a test
326+
/// Both are wide plain-data structs — 11 and 13 public fields — while a test
315327
/// usually cares about two or three. Each function takes those few and zeroes
316328
/// the rest, so a test spells only its own intent and a field added to either
317329
/// struct changes one body here instead of every construction site.
@@ -334,6 +346,7 @@ pub mod fixtures {
334346
angle_index: 0,
335347
relative_time_in: 0.0,
336348
length,
349+
measured: false,
337350
payload_bytes: 0,
338351
packet_count: 0,
339352
packet_seconds: 0.0,
@@ -1216,15 +1229,19 @@ impl BdRom {
12161229
/// each entry carries).
12171230
///
12181231
/// Derived from the per-clip summaries on every call, over whatever this
1219-
/// [`BdRom`] holds: empty after an open that measured nothing
1232+
/// [`BdRom`] holds, and read through [`ClipSummary::measured`]: empty after
1233+
/// an open that measured nothing
12201234
/// ([`ScanMode::Metadata`]/[`ScanMode::Codecs`]), and silent about the
1221-
/// files a `scan_files` selection left out.
1235+
/// files a `scan_files` selection left out. A 0-byte or header-destroyed
1236+
/// file that registers no stream at all IS named — it was measured, and
1237+
/// what it measured is nothing.
12221238
///
12231239
/// A stream file whose read FAILED partway is named here too when its
12241240
/// partial state was kept ([`ScanOptions::keep_partial`]) — its measured
12251241
/// span is genuinely short. That file is also a [`ScanError`] of the
12261242
/// resilient scan, and this type carries no error list, so a surface
1227-
/// showing both pairs them by file name.
1243+
/// showing both pairs them by file name. With the partial state dropped the
1244+
/// file is silent here and the [`ScanError`] is its only record.
12281245
#[must_use]
12291246
pub fn short_stream_files(&self) -> Vec<ShortStreamFile> {
12301247
shortfall::short_stream_files(&self.playlists)
@@ -1992,6 +2009,11 @@ fn build_clip_summaries(
19922009
angle_index: clip.angle_index,
19932010
relative_time_in: clip.relative_time_in,
19942011
length: clip.length,
2012+
// Presence in the full-pass map IS "the measurement pass demuxed
2013+
// this file": a file that never ran, failed to open, or had its
2014+
// partial state dropped is not in it, and one that opened and read
2015+
// to a clean end of file is — however little it carried.
2016+
measured: file.is_some(),
19952017
payload_bytes: clip.payload_bytes,
19962018
packet_count: clip.packet_count,
19972019
packet_seconds: clip.packet_seconds,
@@ -4693,6 +4715,45 @@ mod tests {
46934715
assert!(unselected.short_stream_files().is_empty());
46944716
}
46954717

4718+
#[test]
4719+
fn a_stream_file_that_demuxes_to_nothing_at_all_is_reported_short() {
4720+
// Total destruction leaves every number a never-scanned file leaves:
4721+
// no per-stream tally, no demuxed second, no error (both files open and
4722+
// read to a clean end of file). `ClipSummary::measured` is the only
4723+
// thing that tells the two apart, so this is the case that would go
4724+
// silent if the check read the tally list instead.
4725+
let clip = clpi(&[(0x1011, 0x1B, [0x62, 0x30, 0, 0])]);
4726+
// A 0-byte stub, as a rip that died before writing a byte leaves, and a
4727+
// non-empty file whose header is gone — no PAT, no PMT, so no stream
4728+
// ever registers.
4729+
for stub in [Vec::new(), vec![0xFF; 4096]] {
4730+
let bytes = stub.len();
4731+
let disc = TempDisc::build(
4732+
&[],
4733+
&[
4734+
("BDMV/PLAYLIST/00000.mpls", mpls("00000", 0, 4_500_000, &[])), // 100 s
4735+
("BDMV/CLIPINF/00000.clpi", clip.clone()),
4736+
("BDMV/STREAM/00000.m2ts", stub),
4737+
],
4738+
);
4739+
let scanned = disc.open_scanned().expect("measured scan");
4740+
let summary = scanned
4741+
.playlists
4742+
.first()
4743+
.and_then(|pl| pl.clips.first())
4744+
.expect("the destroyed file's clip row");
4745+
assert!(summary.measured, "{bytes} bytes: the file was demuxed");
4746+
assert!(summary.streams.is_empty(), "{bytes} bytes: nothing registered");
4747+
let reported = scanned.short_stream_files();
4748+
assert_eq!(reported.len(), 1, "{bytes} bytes: {reported:?}");
4749+
assert_eq!(
4750+
reported.first().expect("the short file").notice(),
4751+
"00000.M2TS is shorter than declared: \
4752+
measured 0.0 s of 100.0 s (100.0 s missing)"
4753+
);
4754+
}
4755+
}
4756+
46964757
#[test]
46974758
fn clip_summaries_skip_a_registration_order_entry_without_a_stream() {
46984759
// The two registration fields are public: a caller can desync them, so
@@ -6137,6 +6198,7 @@ mod tests {
61376198
assert_eq!(pl.chapters.len(), 2);
61386199
assert_eq!(pl.chapters.first().unwrap().avg_rate.to_bits(), 0.0_f64.to_bits());
61396200
let clip = pl.clips.first().unwrap();
6201+
assert!(!clip.measured, "a dropped file is not in the full-pass map");
61406202
assert!(clip.streams.is_empty());
61416203
assert_eq!(clip.file_seconds.to_bits(), 0.0_f64.to_bits());
61426204
// …while the in-place playlist tallies the demux flushed before dying
@@ -6172,9 +6234,9 @@ mod tests {
61726234
short.first().unwrap().file()
61736235
);
61746236

6175-
// Retention off drops the dead file's demux state, so it carries no
6176-
// measured tallies at all — indistinguishable from a file the scan
6177-
// never opened, and named by the recorded failure alone.
6237+
// Retention off drops the dead file from the full-pass map, so its clip
6238+
// comes back unmeasured — the same marker a file the scan never opened
6239+
// carries — and the recorded failure alone names it.
61786240
let off = BdRom::open_resilient(
61796241
&tripping_disc(m2ts, Some(DATA_SIZE)),
61806242
ScanMode::Full,
@@ -6184,6 +6246,13 @@ mod tests {
61846246
)
61856247
.expect("resilient scan continues");
61866248
assert_eq!(off.errors.len(), 1);
6249+
let dropped = off
6250+
.bdrom
6251+
.playlists
6252+
.first()
6253+
.and_then(|pl| pl.clips.first())
6254+
.expect("the dropped file's clip row");
6255+
assert!(!dropped.measured);
61876256
assert!(off.bdrom.short_stream_files().is_empty());
61886257
}
61896258

‎crates/bdinfo-rs-core/src/bdrom/shortfall.rs‎

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ impl ShortStreamFile {
118118
///
119119
/// Silent by construction for everything that was never measured — a
120120
/// metadata-only or codec-only open, and every file a `scan_files` selection
121-
/// left out (see [`is_measured`]) — so the result is evidence of a short file,
122-
/// not of an unscanned one.
121+
/// left out (see [`ClipSummary::measured`]) — so the result is evidence of a
122+
/// short file, not of an unscanned one.
123123
pub(crate) fn short_stream_files(playlists: &[PlaylistSummary]) -> Vec<ShortStreamFile> {
124124
let mut worst: BTreeMap<String, ShortStreamFile> = BTreeMap::new();
125125
for playlist in playlists {
@@ -147,11 +147,16 @@ pub(crate) fn short_stream_files(playlists: &[PlaylistSummary]) -> Vec<ShortStre
147147
/// file or a part of it: a play item that ends before the damage measures its
148148
/// full window and stays quiet, while one that reaches past it comes up short.
149149
///
150+
/// [`ClipSummary::measured`] is what says the file was demuxed at all — not
151+
/// the presence of per-stream tallies, which a 0-byte or header-destroyed file
152+
/// leaves as empty as an unscanned one does, so that total destruction would
153+
/// go unreported where partial truncation notices.
154+
///
150155
/// Comparisons on the difference, not on a ratio, so a `length` of zero — or
151156
/// any non-finite value a caller-assembled summary could hold — falls out as
152157
/// "not short" instead of dividing.
153158
fn clip_shortfall(clip: &ClipSummary) -> Option<ShortStreamFile> {
154-
if !is_measured(clip) {
159+
if !clip.measured {
155160
return None;
156161
}
157162
let missing = clip.length - clip.packet_seconds;
@@ -162,21 +167,6 @@ fn clip_shortfall(clip: &ClipSummary) -> Option<ShortStreamFile> {
162167
})
163168
}
164169

165-
/// Whether `clip`'s stream file was measured at all.
166-
///
167-
/// A clip carries whole-file per-stream tallies exactly when the full
168-
/// measurement pass demuxed its file, so an empty tally list means "not
169-
/// measured" rather than "measured as empty" — which is what keeps an open that
170-
/// measures nothing, and every file left out of a selective scan, out of the
171-
/// result.
172-
///
173-
/// The blind spot it buys: a file too short for a single stream to register
174-
/// leaves the same empty list as a file the scan never opened, so it goes
175-
/// unreported.
176-
const fn is_measured(clip: &ClipSummary) -> bool {
177-
!clip.streams.is_empty()
178-
}
179-
180170
#[cfg(test)]
181171
mod tests {
182172
use super::super::disc::{ClipStreamTally, ClipSummary, PlaylistSummary, fixtures};
@@ -185,22 +175,28 @@ mod tests {
185175
use crate::stream::TsStreamType;
186176

187177
/// A measured clip of `name`: `declared` seconds of play item, `measured`
188-
/// seconds demuxed, and the one per-stream tally that marks the file as
189-
/// scanned.
178+
/// seconds demuxed, and the one per-stream tally a file the demux got
179+
/// anywhere into carries.
190180
fn measured_clip(name: &str, declared: f64, measured: f64) -> ClipSummary {
191181
ClipSummary {
192-
packet_seconds: measured,
193182
streams: vec![ClipStreamTally {
194183
pid: Pid::new(0x1011),
195184
stream_type: TsStreamType::AvcVideo,
196185
codec_short_name: "AVC".to_owned(),
197186
payload_bytes: 1024,
198187
packet_count: 8,
199188
}],
200-
..fixtures::clip(name, declared)
189+
..empty_clip(name, declared, measured)
201190
}
202191
}
203192

193+
/// A measured clip that registered no stream at all — the shape a 0-byte
194+
/// or header-destroyed stream file leaves: opened, read to a clean end of
195+
/// file, nothing demuxed.
196+
fn empty_clip(name: &str, declared: f64, measured: f64) -> ClipSummary {
197+
ClipSummary { measured: true, packet_seconds: measured, ..fixtures::clip(name, declared) }
198+
}
199+
204200
/// One playlist named `00000.MPLS` presenting `clips`.
205201
fn disc_of(clips: Vec<ClipSummary>) -> Vec<PlaylistSummary> {
206202
vec![fixtures::playlist("00000.MPLS", 100.0, clips)]
@@ -246,15 +242,29 @@ mod tests {
246242
}
247243

248244
#[test]
249-
fn a_clip_without_measured_tallies_is_never_reported() {
245+
fn an_unmeasured_clip_is_never_reported() {
250246
// The shape every clip has after an open that measured nothing, and
251247
// every file a selective scan skipped: a declared length, no demuxed
252-
// seconds, and no per-stream tallies.
248+
// seconds, and the flag down.
253249
let unmeasured = fixtures::clip("00000.M2TS", 1640.0);
254-
assert!(unmeasured.streams.is_empty());
250+
assert!(!unmeasured.measured);
255251
assert!(reported(&disc_of(vec![unmeasured])).is_empty());
256252
}
257253

254+
#[test]
255+
fn a_file_that_demuxed_to_nothing_is_reported_as_the_whole_span_missing() {
256+
// The flag, not the tally list, is what separates this from the case
257+
// above: a 0-byte or header-destroyed file opens, ends at once and
258+
// registers no stream, so every number it leaves matches a file the
259+
// scan never touched. Only the marker tells them apart.
260+
let destroyed = empty_clip("00000.M2TS", 1640.0, 0.0);
261+
assert!(destroyed.streams.is_empty());
262+
assert_eq!(
263+
reported(&disc_of(vec![destroyed])),
264+
vec![("00000.M2TS".to_owned(), 1640.0, 0.0)]
265+
);
266+
}
267+
258268
#[test]
259269
fn a_zero_length_clip_is_never_reported() {
260270
// Nothing declared is nothing to miss — and no division to do.

‎crates/bdinfo-rs-gui/src/flow.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2313,11 +2313,11 @@ mod tests {
23132313
assert!(flow.short_stream_notices().is_empty(), "unmeasured clips raise no notice");
23142314

23152315
// The measured result comes back with the first clip demuxed to 500 of
2316-
// its declared 1640 seconds, carrying the per-stream tally that marks
2317-
// the file as measured.
2316+
// its declared 1640 seconds.
23182317
let mut measured = structural().bdrom.playlists;
23192318
let clip = measured.first_mut().and_then(|p| p.clips.first_mut()).expect("the first clip");
23202319
clip.length = 1640.0;
2320+
clip.measured = true;
23212321
clip.packet_seconds = 500.0;
23222322
clip.streams = vec![ClipStreamTally {
23232323
pid: Pid::new(0x1011),

‎crates/bdinfo-rs-wasm/src/mirror.rs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,13 @@ pub struct Clip {
366366
pub relative_time_in_seconds: f64,
367367
/// How long the clip runs, in seconds.
368368
pub length_seconds: f64,
369+
/// Whether the measurement pass demuxed this clip's stream file. False for
370+
/// a file the scan never opened, one a selection left out and one whose
371+
/// failed read was discarded; true for a 0-byte or header-destroyed file
372+
/// that opened and yielded nothing, which is what tells "measured as
373+
/// empty" from "never looked at" when every field below is zero. The disc's
374+
/// own `measured` says whether the scan ran a measurement pass at all.
375+
pub measured: bool,
369376
/// Payload bytes the packet scan attributed to this clip.
370377
pub payload_bytes: u64,
371378
/// Transport packets the packet scan attributed to this clip. The
@@ -836,6 +843,7 @@ impl From<&ClipSummary> for Clip {
836843
angle_index: clip.angle_index,
837844
relative_time_in_seconds: clip.relative_time_in,
838845
length_seconds: clip.length,
846+
measured: clip.measured,
839847
payload_bytes: clip.payload_bytes,
840848
packet_count: clip.packet_count,
841849
packet_seconds: clip.packet_seconds,
@@ -1121,6 +1129,7 @@ impl From<Clip> for ClipSummary {
11211129
angle_index: clip.angle_index,
11221130
relative_time_in: clip.relative_time_in_seconds,
11231131
length: clip.length_seconds,
1132+
measured: clip.measured,
11241133
payload_bytes: clip.payload_bytes,
11251134
packet_count: clip.packet_count,
11261135
packet_seconds: clip.packet_seconds,
@@ -1344,6 +1353,7 @@ mod tests {
13441353
angle_index: 14,
13451354
relative_time_in_seconds: 15.5,
13461355
length_seconds: 16.25,
1356+
measured: true,
13471357
payload_bytes: 17,
13481358
packet_count: 18,
13491359
packet_seconds: 19.5,
@@ -1362,6 +1372,7 @@ mod tests {
13621372
"angleIndex": 14,
13631373
"relativeTimeInSeconds": 15.5,
13641374
"lengthSeconds": 16.25,
1375+
"measured": true,
13651376
"payloadBytes": 17,
13661377
"packetCount": 18,
13671378
"packetSeconds": 19.5,
@@ -1606,9 +1617,9 @@ mod tests {
16061617

16071618
#[test]
16081619
fn a_short_stream_file_crosses_as_cores_notice_sentence() {
1609-
// A clip demuxed to 500 of its declared 1640 seconds, carrying the
1610-
// per-stream tally that marks its file as measured.
1620+
// A clip demuxed to 500 of its declared 1640 seconds.
16111621
let clip = ClipSummary {
1622+
measured: true,
16121623
packet_seconds: 500.0,
16131624
streams: vec![a_core_clip_stream()],
16141625
..fixtures::clip("00011.M2TS", 1640.0)
@@ -1774,6 +1785,7 @@ mod tests {
17741785
interleaved_file_size: 13,
17751786
angle_index: 14,
17761787
relative_time_in: 15.5,
1788+
measured: true,
17771789
payload_bytes: 17,
17781790
packet_count: 18,
17791791
packet_seconds: 19.5,

‎crates/bdinfo-rs/src/main.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2327,10 +2327,10 @@ Options:
23272327
}
23282328

23292329
/// A scanned disc whose one stream file was demuxed to 500 of its declared
2330-
/// 1640 seconds — past both shortfall thresholds — with the per-stream
2331-
/// tally that marks the file as measured.
2330+
/// 1640 seconds — past both shortfall thresholds.
23322331
fn short_disc() -> BdRom {
23332332
let clip = bdinfo_rs_core::bdrom::disc::ClipSummary {
2333+
measured: true,
23342334
packet_seconds: 500.0,
23352335
streams: vec![ClipStreamTally {
23362336
pid: Pid::new(0x1011),

0 commit comments

Comments
 (0)