From 3841ae22507933334b77e2d539f2a4676b52d1bb Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:23:04 -0700 Subject: [PATCH] disc: detect forced PGS subtitles from stream content for info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/disc/mod.rs | 20 ++++ src/disc/pgs_forced_probe.rs | 196 +++++++++++++++++++++++++++++++++++ src/mux/codec/pgs.rs | 58 +++++++++++ src/mux/mkv.rs | 19 ++-- 4 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 src/disc/pgs_forced_probe.rs diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 79864c6..6099303 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -16,6 +16,7 @@ mod extract; mod hddvd; pub mod mapfile; mod patch; +pub(crate) mod pgs_forced_probe; pub mod read_error; mod section_recover; mod sweep; @@ -1491,6 +1492,13 @@ pub struct ScanOptions { /// 50_000 sectors on a live DVD) poll it and bail out cleanly so a /// scan-phase watchdog or operator Stop is never stuck behind a hang. pub halt: Option, + /// Read the PGS subtitle streams during the scan to detect forced-narrative + /// tracks from their content (the `forced_on_flag`), matching what the mux + /// derives during a rip. OFF by default — it reads the clip's PGS content, + /// which is slow, so only callers that want authoritative forced flags in the + /// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off: + /// the muxer detects forced during muxing without a second read. + pub probe_forced_subtitles: bool, } /// Quick disc identification — name, format, capacity. No title/stream parsing. @@ -1983,6 +1991,18 @@ impl Disc { // 4. Metadata + labels let meta_title = Self::read_meta_title(reader, &udf_fs); crate::labels::apply(reader, &udf_fs, &mut titles); + + // Optional content-based forced-subtitle detection. `info` opts in so its + // forced flags match what the muxer derives during a rip (both use the + // one shared PGS classifier); the rip path leaves it off — the muxer + // detects forced while muxing, without a second read of the clip. + if _opts.probe_forced_subtitles { + for title in &mut titles { + if title.content_format == ContentFormat::BdTs { + pgs_forced_probe::probe_and_set_forced(reader, title); + } + } + } crate::labels::fill_defaults(&mut titles); // 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs new file mode 100644 index 0000000..fb08f92 --- /dev/null +++ b/src/disc/pgs_forced_probe.rs @@ -0,0 +1,196 @@ +//! Content-based forced-subtitle detection for Blu-ray/UHD PGS tracks. +//! +//! `freemkv info` and the muxer must agree on which subtitle tracks are forced. +//! The muxer derives it from the PGS `forced_on_flag` while muxing a rip; this +//! module gives `info` the SAME verdict up front by reading the title's PGS +//! streams and feeding them through the one shared classifier +//! ([`crate::mux::codec::pgs::ForcedTracker`]) — so the two never diverge. +//! +//! Cost: a track is only confirmed forced once EVERY display set is seen to be +//! forced, so a disc that has a forced track is read through — the +//! accuracy-over-speed tradeoff `info` opts into. Full tracks early-exit as soon +//! as they show a single non-forced subtitle, and a whole run stops early once +//! every track has settled. +//! +//! Encrypted content: the probe reuses whatever [`SectorSource`] the scan holds. +//! With a decrypting source it sees real PGS; without keys it reads ciphertext +//! and observes no display sets, in which case it leaves each track's existing +//! (vendor-label-derived) forced flag untouched rather than asserting anything. + +use crate::disc::{Codec, DiscTitle, Stream}; +use crate::mux::codec::CodecParser; +use crate::mux::codec::pgs::{ForcedTracker, PgsParser}; +use crate::mux::ts::TsDemuxer; +use crate::sector::SectorSource; +use std::collections::HashMap; + +const SECTOR_BYTES: usize = 2048; +/// Read the clip in 2 MiB chunks. +const CHUNK_SECTORS: u16 = 1024; + +/// Read the title's PGS streams and set `SubtitleStream::forced` from their +/// content. Best-effort: any read error ends the probe with whatever verdicts +/// have accumulated. Only PGS tracks are touched (DVD VobSub forced comes from +/// the IFO/vendor path). +pub(crate) fn probe_and_set_forced( + reader: &mut S, + title: &mut DiscTitle, +) { + let pg_pids: Vec = title + .streams + .iter() + .filter_map(|s| match s { + Stream::Subtitle(sub) if sub.codec == Codec::Pgs => Some(sub.pid), + _ => None, + }) + .collect(); + if pg_pids.is_empty() { + return; + } + + let mut demux = TsDemuxer::new(&pg_pids); + let mut parsers: HashMap = + pg_pids.iter().map(|&p| (p, PgsParser::new())).collect(); + let mut trackers: HashMap = + pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect(); + + let extents = title.extents.clone(); + let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES]; + 'outer: for ext in &extents { + let mut lba = ext.start_lba; + let mut remaining = ext.sector_count; + while remaining > 0 { + let count = remaining.min(CHUNK_SECTORS as u32) as u16; + let want = count as usize * SECTOR_BYTES; + let n = match reader.read_sectors(lba, count, &mut buf[..want], false) { + Ok(n) => n, + Err(_) => break 'outer, // best-effort — stop, keep what we have + }; + if n == 0 { + break 'outer; + } + for pes in demux.feed(&buf[..n]) { + if let (Some(parser), Some(tracker)) = + (parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid)) + { + for frame in parser.parse(&pes) { + tracker.observe(&frame.data); + } + } + } + // Every track has already shown a non-forced set → nothing left to + // learn; stop reading the (huge) clip. + if trackers.values().all(ForcedTracker::settled_not_forced) { + break 'outer; + } + lba += count as u32; + remaining -= count as u32; + } + } + + // Drain any buffered final display set. + for (pid, parser) in parsers.iter_mut() { + if let Some(tracker) = trackers.get_mut(pid) { + for frame in parser.flush() { + tracker.observe(&frame.data); + } + } + } + + // Apply verdicts. Only override a track we actually saw content for — an + // undecrypted/unread track keeps its vendor-derived flag. + for s in &mut title.streams { + if let Stream::Subtitle(sub) = s { + if sub.codec == Codec::Pgs { + if let Some(t) = trackers.get(&sub.pid) { + if t.observed() { + sub.forced = t.is_forced(); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::disc::{ContentFormat, Extent, LabelQualifier, SubtitleStream}; + + /// A reader that yields all-zeros (an encrypted / unreadable clip) for a + /// bounded span, then EOF. + struct ZeroReader { + served: u32, + cap: u32, + } + impl SectorSource for ZeroReader { + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + if self.served >= self.cap { + return Ok(0); + } + self.served += count as u32; + buf.fill(0); + Ok(buf.len()) + } + fn capacity_sectors(&self) -> u32 { + self.cap + } + } + + fn pgs_title(pid: u16, vendor_forced: bool) -> DiscTitle { + DiscTitle { + playlist: String::new(), + playlist_id: 0, + duration_secs: 0.0, + size_bytes: 0, + clips: vec![], + streams: vec![Stream::Subtitle(SubtitleStream { + pid, + codec: Codec::Pgs, + language: "eng".into(), + forced: vendor_forced, + qualifier: LabelQualifier::None, + codec_data: None, + })], + chapters: vec![], + extents: vec![Extent { + start_lba: 0, + sector_count: 4, + }], + content_format: ContentFormat::BdTs, + codec_privates: vec![None], + } + } + + #[test] + fn no_observed_content_preserves_vendor_forced() { + // An unreadable/encrypted clip yields no PGS display sets — the probe must + // leave the existing vendor-derived forced flag untouched, never assert + // "not forced" from having seen nothing. + let mut reader = ZeroReader { served: 0, cap: 4 }; + let mut title = pgs_title(0x1200, true); + probe_and_set_forced(&mut reader, &mut title); + let Stream::Subtitle(s) = &title.streams[0] else { + panic!() + }; + assert!(s.forced, "no content observed → vendor forced preserved"); + } + + #[test] + fn no_pgs_streams_is_noop() { + // A title with no PGS subtitle streams is a no-op (the reader is never + // touched — a DVD/VobSub or audio-only title). + let mut reader = ZeroReader { served: 0, cap: 0 }; + let mut title = pgs_title(0x1200, false); + // Swap the PGS sub for an audio stream so there are no PGS PIDs. + title.streams.clear(); + probe_and_set_forced(&mut reader, &mut title); + assert_eq!(reader.served, 0, "no PGS PIDs → no reads"); + } +} diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index 649efe5..e6b6a18 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -57,6 +57,64 @@ pub fn display_set_is_forced(frame_data: &[u8]) -> Option { 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 { diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index f08bddd..a04a6d8 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -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 MkvMuxer { 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 MkvMuxer { // 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 MkvMuxer { let forced_offsets: Vec = 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() {