mux: detect forced PGS subtitles from the stream
Flag a PGS subtitle track FlagForced when it displays subtitles and every one carries the HDMV forced_on_flag (a dedicated forced/narrative track), independent of the disc's vendor label metadata. The track header reserves a FlagForced byte up front and it is promoted at finish() from the accumulated display-set state. Only ever promotes — a track already forced from the playlist metadata is never demoted.
This commit is contained in:
@@ -30,6 +30,32 @@ const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
||||||
// palette_id_ref) = 13.
|
// palette_id_ref) = 13.
|
||||||
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
||||||
|
// Offset of the first composition_object's flags byte within a PCS PES payload:
|
||||||
|
// PCS header(13) + number_of_composition_objects(1) + object_id_ref(2) +
|
||||||
|
// window_id_ref(1) = 17. `forced_on_flag` is bit 0x40 of that byte (HDMV PCS).
|
||||||
|
const PCS_FIRST_OBJECT_FLAGS_OFFSET: usize = 17;
|
||||||
|
const PCS_FORCED_ON_FLAG: u8 = 0x40;
|
||||||
|
|
||||||
|
/// Whether an emitted PGS display-set frame is a FORCED subtitle — the
|
||||||
|
/// `forced_on_flag` (0x40) on its first composition object. The frame data an
|
||||||
|
/// emitted PGS block carries begins with the display PCS (segment type 0x16), so
|
||||||
|
/// the flag is read directly from it. Returns `None` when the block is not a
|
||||||
|
/// display PCS with a composition object (nothing to classify — a clear PCS, a
|
||||||
|
/// non-PCS segment, or a truncated header).
|
||||||
|
///
|
||||||
|
/// The mux uses this to detect a *forced-narrative track* (every displayed
|
||||||
|
/// subtitle forced) without relying on the disc's vendor label metadata, so
|
||||||
|
/// forced subs are flagged `FlagForced` even on discs that carry no such blob.
|
||||||
|
pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> {
|
||||||
|
if frame_data.first() != Some(&SEGMENT_PCS) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if *frame_data.get(PCS_NUM_OBJECTS_OFFSET)? == 0 {
|
||||||
|
return None; // clear PCS — no composition to classify
|
||||||
|
}
|
||||||
|
let flags = *frame_data.get(PCS_FIRST_OBJECT_FLAGS_OFFSET)?;
|
||||||
|
Some(flags & PCS_FORCED_ON_FLAG != 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
||||||
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
||||||
@@ -222,6 +248,41 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::mux::ts::PesPacket;
|
use crate::mux::ts::PesPacket;
|
||||||
|
|
||||||
|
/// A PCS display-set block with one composition object; `forced` sets the
|
||||||
|
/// forced_on_flag (0x40) in its flags byte at offset 17.
|
||||||
|
fn pcs_display(forced: bool) -> Vec<u8> {
|
||||||
|
let mut d = vec![0u8; 18];
|
||||||
|
d[0] = SEGMENT_PCS;
|
||||||
|
d[PCS_NUM_OBJECTS_OFFSET] = 1;
|
||||||
|
d[PCS_FIRST_OBJECT_FLAGS_OFFSET] = if forced { PCS_FORCED_ON_FLAG } else { 0 };
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_set_forced_flag_detection() {
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(true)), Some(true));
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(false)), Some(false));
|
||||||
|
// Other flag bits set but not forced_on_flag → still not forced.
|
||||||
|
let mut cropped = pcs_display(false);
|
||||||
|
cropped[PCS_FIRST_OBJECT_FLAGS_OFFSET] = 0x80; // object_cropped_flag only
|
||||||
|
assert_eq!(display_set_is_forced(&cropped), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_set_forced_none_for_non_display() {
|
||||||
|
// Clear PCS (0 objects) → None.
|
||||||
|
let mut clear = pcs_display(false);
|
||||||
|
clear[PCS_NUM_OBJECTS_OFFSET] = 0;
|
||||||
|
assert_eq!(display_set_is_forced(&clear), None);
|
||||||
|
// Non-PCS segment → None.
|
||||||
|
let mut ods = pcs_display(true);
|
||||||
|
ods[0] = 0x15; // ODS
|
||||||
|
assert_eq!(display_set_is_forced(&ods), None);
|
||||||
|
// Truncated (no flags byte) → None, no panic.
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(true)[..15]), None);
|
||||||
|
assert_eq!(display_set_is_forced(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
PesPacket {
|
PesPacket {
|
||||||
source: None,
|
source: None,
|
||||||
|
|||||||
+186
-5
@@ -672,6 +672,14 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
||||||
/// `corrected` flips once patched so we only act on the first frame.
|
/// `corrected` flips once patched so we only act on the first frame.
|
||||||
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
||||||
|
/// Deferred PGS forced-subtitle detection. A PGS subtitle track reserves a
|
||||||
|
/// 1-byte `FlagForced` value up-front; as its display sets are written, the
|
||||||
|
/// track is judged forced iff it displayed at least one subtitle and EVERY
|
||||||
|
/// display set carried the HDMV `forced_on_flag` (a dedicated forced/narrative
|
||||||
|
/// track). At `finish()` the reserved byte is promoted to 1 for such tracks —
|
||||||
|
/// so forced subs are flagged even on discs without vendor label metadata.
|
||||||
|
/// Only ever promotes (0→1); a scan/vendor forced flag is never demoted.
|
||||||
|
pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup>,
|
||||||
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
|
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
|
||||||
/// track are written (raw) to a `<output>.opening.bin` side file with a
|
/// track are written (raw) to a `<output>.opening.bin` side file with a
|
||||||
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
|
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
|
||||||
@@ -692,6 +700,18 @@ struct Ac3ChannelFixup {
|
|||||||
corrected: bool,
|
corrected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deferred PGS forced-subtitle detection state for one PGS subtitle track.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
||||||
///
|
///
|
||||||
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
|
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
|
||||||
@@ -917,6 +937,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
let mut pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
|
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
|
||||||
// Filled below from the SAME built record that drives the CodecPrivate
|
// Filled below from the SAME built record that drives the CodecPrivate
|
||||||
// mvcC extension, so the three MVC signals never diverge.
|
// mvcC extension, so the three MVC signals never diverge.
|
||||||
@@ -948,7 +970,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
if !track.is_default {
|
if !track.is_default {
|
||||||
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
||||||
}
|
}
|
||||||
if track.is_forced {
|
if track.track_type == ebml::TRACK_TYPE_SUBTITLE && track.codec_id == ebml::CODEC_PGS {
|
||||||
|
// Reserve a 1-byte FlagForced (initial value = the scan/vendor
|
||||||
|
// flag) and record its offset, so PGS content can promote it to 1
|
||||||
|
// at finish() if the track proves to be forced narrative. Written
|
||||||
|
// explicitly (ID + size + value) so the value is a single,
|
||||||
|
// in-place-patchable byte — same idiom as the Channels fixup.
|
||||||
|
ebml::write_id(&mut writer, ebml::FLAG_FORCED)?;
|
||||||
|
ebml::write_size(&mut writer, 1)?;
|
||||||
|
let value_offset = writer.stream_position()?;
|
||||||
|
writer.write_all(&[track.is_forced as u8])?;
|
||||||
|
pgs_forced_fixups.insert(
|
||||||
|
i,
|
||||||
|
PgsForcedFixup {
|
||||||
|
value_offset,
|
||||||
|
has_display: false,
|
||||||
|
all_forced: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if track.is_forced {
|
||||||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,6 +1243,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
max_block_ticks: 0,
|
max_block_ticks: 0,
|
||||||
last_video_keyframe_ticks: None,
|
last_video_keyframe_ticks: None,
|
||||||
ac3_channel_fixups,
|
ac3_channel_fixups,
|
||||||
|
pgs_forced_fixups,
|
||||||
opening_capture: None,
|
opening_capture: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1514,6 +1555,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate PGS forced-subtitle state: a display set marks the track as
|
||||||
|
// having shown a subtitle, and clears `all_forced` the moment a
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,6 +1598,26 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Close final cluster
|
// Close final cluster
|
||||||
self.end_cluster()?;
|
self.end_cluster()?;
|
||||||
|
|
||||||
|
// Promote FlagForced for PGS subtitle tracks that proved to be forced
|
||||||
|
// narrative (displayed subtitles, every one forced). In-place single-byte
|
||||||
|
// rewrite of the reserved value, then restore the append position for the
|
||||||
|
// Cues that follow. Only promotes (0→1); a track already forced from the
|
||||||
|
// scan/vendor flag stays forced.
|
||||||
|
let forced_offsets: Vec<u64> = self
|
||||||
|
.pgs_forced_fixups
|
||||||
|
.values()
|
||||||
|
.filter(|f| f.has_display && f.all_forced)
|
||||||
|
.map(|f| f.value_offset)
|
||||||
|
.collect();
|
||||||
|
if !forced_offsets.is_empty() {
|
||||||
|
let here = self.writer.stream_position()?;
|
||||||
|
for off in forced_offsets {
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(off))?;
|
||||||
|
self.writer.write_all(&[1u8])?;
|
||||||
|
}
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(here))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Write Cues
|
// Write Cues
|
||||||
let cues_start = self.writer.stream_position()?;
|
let cues_start = self.writer.stream_position()?;
|
||||||
let cues_offset = cues_start - self.segment_start;
|
let cues_offset = cues_start - self.segment_start;
|
||||||
@@ -2600,13 +2672,122 @@ mod tests {
|
|||||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||||
let data = muxer.writer.into_inner();
|
let data = muxer.writer.into_inner();
|
||||||
|
|
||||||
// FlagForced should NOT be written for non-forced tracks
|
// A PGS subtitle track now RESERVES a FlagForced element (so PGS content
|
||||||
assert!(
|
// can promote it later), but for a non-forced track its value is 0. The
|
||||||
find_id(&data, ebml::FLAG_FORCED).is_none(),
|
// value byte sits after the 2-byte ID (0x55AA) + 1-byte size.
|
||||||
"FlagForced element should not be present for non-forced subtitle"
|
let pos =
|
||||||
|
find_id(&data, ebml::FLAG_FORCED).expect("PGS subtitle reserves a FlagForced element");
|
||||||
|
assert_eq!(
|
||||||
|
data[pos + 3],
|
||||||
|
0,
|
||||||
|
"reserved FlagForced value must be 0 for a non-forced subtitle"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A PGS display-set PES block (PCS) with one composition object, whose
|
||||||
|
/// forced_on_flag is set per `forced`.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pgs_display_set(forced: bool) -> Vec<u8> {
|
||||||
|
let mut pcs = vec![0u8; 18];
|
||||||
|
pcs[0] = 0x16; // segment type PCS
|
||||||
|
pcs[13] = 1; // number_of_composition_objects
|
||||||
|
pcs[17] = if forced { 0x40 } else { 0x00 }; // first object flags
|
||||||
|
pcs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkv_pgs_forced_promoted_when_all_display_sets_forced() {
|
||||||
|
// End-to-end: a PGS subtitle track whose every display set is forced is
|
||||||
|
// promoted to FlagForced=1 at finish(), even though the scan flag was
|
||||||
|
// false (no vendor metadata).
|
||||||
|
use crate::disc::SubtitleStream;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let sub = MkvTrack::subtitle(&SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
});
|
||||||
|
let tracks = [make_video_track(), sub];
|
||||||
|
let mut muxer =
|
||||||
|
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
|
||||||
|
.unwrap(); // video keyframe opens a cluster
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(true),
|
||||||
|
Some(2_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
|
||||||
|
assert_eq!(
|
||||||
|
data[pos + 3],
|
||||||
|
1,
|
||||||
|
"all-forced PGS track promoted to FlagForced=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkv_pgs_not_promoted_when_a_display_set_is_not_forced() {
|
||||||
|
// A track with ANY non-forced display set is a full track, not forced
|
||||||
|
// narrative — FlagForced stays 0.
|
||||||
|
use crate::disc::SubtitleStream;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let sub = MkvTrack::subtitle(&SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
});
|
||||||
|
let tracks = [make_video_track(), sub];
|
||||||
|
let mut muxer =
|
||||||
|
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
|
||||||
|
.unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(true),
|
||||||
|
Some(2_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
3_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(false),
|
||||||
|
Some(4_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(); // a normal (non-forced) subtitle → not a forced track
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
|
||||||
|
assert_eq!(data[pos + 3], 0, "mixed PGS track stays FlagForced=0");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user