disc: detect forced PGS subtitles from stream content for info

Give `info` the same forced-subtitle verdict the muxer derives during a
rip, so the two agree. A shared classifier (mux::codec::pgs::ForcedTracker)
folds a PGS track's display sets — forced iff every one carries the
forced_on_flag — and is used by BOTH the MKV writer and a new scan-time
probe that reads the title's PGS streams (reusing the TS demuxer and PGS
parser). The probe only overrides a track it actually observed content
for, so an undecrypted/unread stream keeps its vendor-derived flag. Gated
behind ScanOptions::probe_forced_subtitles (off for the rip path, which
detects forced while muxing without a second read).
This commit is contained in:
Matthew Jackson
2026-07-19 14:23:04 -07:00
parent 2ccb5c9d01
commit 3841ae2250
4 changed files with 280 additions and 13 deletions
+58
View File
@@ -57,6 +57,64 @@ pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> {
Some(flags & PCS_FORCED_ON_FLAG != 0)
}
/// Accumulates the "is this PGS subtitle track a forced-narrative track?" verdict
/// from its display sets. A track is forced iff it displayed at least one subtitle
/// and EVERY display set carried the forced_on_flag — a dedicated forced track,
/// as opposed to a full track that merely has occasional forced signs.
///
/// This is the SINGLE classification used by both the MKV muxer (accumulating a
/// track's frames during a rip) and the `info`-time forced probe (feeding the
/// demuxed display sets), so both reach the identical verdict.
#[derive(Debug, Clone)]
pub struct ForcedTracker {
has_display: bool,
all_forced: bool,
}
impl Default for ForcedTracker {
fn default() -> Self {
Self {
has_display: false,
all_forced: true,
}
}
}
impl ForcedTracker {
pub fn new() -> Self {
Self::default()
}
/// Fold one emitted PGS block into the verdict. Non-display blocks (clear
/// PCS, other segments) are ignored.
pub fn observe(&mut self, frame_data: &[u8]) {
if let Some(forced) = display_set_is_forced(frame_data) {
self.has_display = true;
self.all_forced &= forced;
}
}
/// Whether the track has already shown a NON-forced subtitle — i.e. its
/// verdict is settled at "not forced" and further observation can be skipped
/// (the early-exit the probe uses to avoid reading the whole clip).
pub fn settled_not_forced(&self) -> bool {
self.has_display && !self.all_forced
}
/// Whether ANY display set was observed. When false the track's forced state
/// is unknown (no PGS content seen — e.g. an undecrypted/unread stream), so a
/// probe should leave any existing (vendor-derived) flag untouched rather
/// than assert "not forced".
pub fn observed(&self) -> bool {
self.has_display
}
/// Final verdict: forced iff it displayed subtitles and every one was forced.
pub fn is_forced(&self) -> bool {
self.has_display && self.all_forced
}
}
/// Stateful parser that collapses PGS display/clear PCS pairs into
/// duration-bearing Matroska frames. Implements [`CodecParser`].
pub struct PgsParser {
+6 -13
View File
@@ -704,12 +704,9 @@ struct Ac3ChannelFixup {
struct PgsForcedFixup {
/// Absolute file offset of the 1-byte `FlagForced` value in the Tracks element.
value_offset: u64,
/// Whether the track has displayed at least one subtitle (a display PCS).
has_display: bool,
/// Whether EVERY display set so far carried the forced_on_flag. Starts true;
/// cleared by the first non-forced display set. With `has_display`, a value of
/// true at `finish()` means the whole track is forced narrative.
all_forced: bool,
/// Shared forced-narrative classifier fed the track's display sets. The same
/// type drives the `info`-time forced probe, so both classify identically.
tracker: super::codec::pgs::ForcedTracker,
}
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
@@ -984,8 +981,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
i,
PgsForcedFixup {
value_offset,
has_display: false,
all_forced: true,
tracker: super::codec::pgs::ForcedTracker::new(),
},
);
} else if track.is_forced {
@@ -1560,10 +1556,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
// non-forced set appears. `finish()` promotes FlagForced only for a track
// that displayed subtitles and had every one forced.
if let Some(fixup) = self.pgs_forced_fixups.get_mut(&track_idx) {
if let Some(forced) = super::codec::pgs::display_set_is_forced(data) {
fixup.has_display = true;
fixup.all_forced &= forced;
}
fixup.tracker.observe(data);
}
Ok(())
@@ -1606,7 +1599,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
let forced_offsets: Vec<u64> = self
.pgs_forced_fixups
.values()
.filter(|f| f.has_display && f.all_forced)
.filter(|f| f.tracker.is_forced())
.map(|f| f.value_offset)
.collect();
if !forced_offsets.is_empty() {