mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
mux: sink and reader audit fixes; DTS in MP4; halt-aware network receive
Fixes from the mkv, mp4, network and ts sink audit; track timing and DiscardPadding ride the FMKV wire. MP4 writes DTS per ETSI TS 102 114 (ddts rates and timescales). The MKV PCM depth probe is bounded and evidence-based. Network receives use keepalive and wait on readiness, so a Stop or a dead peer ends them.
This commit is contained in:
@@ -14,6 +14,8 @@
|
|||||||
- `list_drives` on Linux without sysfs keeps an sg node unless INQUIRY says it is not optical or open() fails with no device (ENOENT/ENXIO/ENODEV) or no permission (EACCES/EPERM), so a wedged or busy drive is still listed.
|
- `list_drives` on Linux without sysfs keeps an sg node unless INQUIRY says it is not optical or open() fails with no device (ENOENT/ENXIO/ENODEV) or no permission (EACCES/EPERM), so a wedged or busy drive is still listed.
|
||||||
- Dropping a transport or `Drive` unlocks the tray only if it locked it.
|
- Dropping a transport or `Drive` unlocks the tray only if it locked it.
|
||||||
- `Error::ImageTruncated` displays as `E<code>: have/want`.
|
- `Error::ImageTruncated` displays as `E<code>: have/want`.
|
||||||
|
- `json://` audio `sample_rate` is now a number in Hz (`null` when unknown) instead of a display string such as `"48kHz"`; a new `sample_rates` array lists every rate a stream carries (e.g. `[48000, 96000]` for a 48/96 kHz combo).
|
||||||
|
- `network://` / `stdio://` FMKV headers use version 2 when a track carries decoder timing (Opus CodecDelay/SeekPreRoll), adding per-frame DiscardPadding; streams without timing stay version 1.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
@@ -28,6 +30,9 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- TrueHD channel labels keep the LFE split (3.0 was labelled "2.1", 7.0 "6.1"), and a 6-channel presentation counts its Lvh/Rvh pair as 2. An older network:// receiver parses the new layout strings as unknown and omits the MKV Channels element for those tracks; keep both ends on the same version.
|
- TrueHD channel labels keep the LFE split (3.0 was labelled "2.1", 7.0 "6.1"), and a 6-channel presentation counts its Lvh/Rvh pair as 2. An older network:// receiver parses the new layout strings as unknown and omits the MKV Channels element for those tracks; keep both ends on the same version.
|
||||||
|
- `mp4://` DTS tracks write the stream's maximum rate in `ddts` and read back as 96 kHz where the source is 96 kHz.
|
||||||
|
- `mkv://` PCM tracks without BitDepth: bounded read-ahead and a multi-block depth estimate.
|
||||||
|
- `network://` receive arms TCP keepalive on every accepted socket; a rejected keepalive option only warns.
|
||||||
|
|
||||||
## [1.7.7] — 2026-09-26
|
## [1.7.7] — 2026-09-26
|
||||||
|
|
||||||
|
|||||||
Generated
+12
@@ -328,9 +328,11 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"rayon",
|
"rayon",
|
||||||
"roxmltree",
|
"roxmltree",
|
||||||
|
"rustix",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
"socket2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tracing",
|
"tracing",
|
||||||
"zip",
|
"zip",
|
||||||
@@ -626,6 +628,16 @@ version = "1.16.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ rip = [
|
|||||||
"dep:crossbeam-channel",
|
"dep:crossbeam-channel",
|
||||||
"dep:rayon",
|
"dep:rayon",
|
||||||
"dep:memchr",
|
"dep:memchr",
|
||||||
|
"dep:socket2",
|
||||||
|
"dep:rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -62,6 +64,12 @@ aes = { version = "0.9", optional = true }
|
|||||||
freemkv-unlock = { path = "../freemkv-unlock", optional = true }
|
freemkv-unlock = { path = "../freemkv-unlock", optional = true }
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
||||||
base64 = { version = "0.23", optional = true }
|
base64 = { version = "0.23", optional = true }
|
||||||
|
# Safe TCP keepalive setup for `network://` receive (dead-peer detection);
|
||||||
|
# `all` provides TcpKeepalive::with_retries.
|
||||||
|
socket2 = { version = "0.6", features = ["all"], optional = true }
|
||||||
|
# Safe readiness poll (poll/WSAPoll) so a halt-aware `network://` receive never
|
||||||
|
# arms SO_RCVTIMEO, which on Windows can drop data when it cancels a recv.
|
||||||
|
rustix = { version = "1", default-features = false, features = ["std", "event"], optional = true }
|
||||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||||
|
|||||||
@@ -198,6 +198,10 @@ pub const E_DIR_IMAGE_FANOUT: u16 = 9068;
|
|||||||
pub const E_SEAM_PLAN_DROPPED_MOST: u16 = 9069;
|
pub const E_SEAM_PLAN_DROPPED_MOST: u16 = 9069;
|
||||||
/// A sink finished having written no frames at all.
|
/// A sink finished having written no frames at all.
|
||||||
pub const E_SINK_WROTE_NOTHING: u16 = 9070;
|
pub const E_SINK_WROTE_NOTHING: u16 = 9070;
|
||||||
|
/// A write or finish reached a stream that is already finished or whose output failed.
|
||||||
|
pub const E_STREAM_CLOSED: u16 = 9071;
|
||||||
|
/// Per-track metadata was set after the stream header was already written.
|
||||||
|
pub const E_STREAM_HEADER_WRITTEN: u16 = 9072;
|
||||||
pub const E_M2TS_PACKET_MALFORMED: u16 = 9021;
|
pub const E_M2TS_PACKET_MALFORMED: u16 = 9021;
|
||||||
/// A `network://` output target resolved to no address that is safe to
|
/// A `network://` output target resolved to no address that is safe to
|
||||||
/// connect to (every resolved IP was loopback / private / link-local /
|
/// connect to (every resolved IP was loopback / private / link-local /
|
||||||
@@ -841,6 +845,10 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
/// A sink finished having written no frames at all.
|
/// A sink finished having written no frames at all.
|
||||||
SinkWroteNothing,
|
SinkWroteNothing,
|
||||||
|
/// See [`E_STREAM_CLOSED`].
|
||||||
|
StreamClosed,
|
||||||
|
/// See [`E_STREAM_HEADER_WRITTEN`].
|
||||||
|
StreamHeaderWritten,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
@@ -972,6 +980,8 @@ impl Error {
|
|||||||
Error::DirImageFanout { .. } => E_DIR_IMAGE_FANOUT,
|
Error::DirImageFanout { .. } => E_DIR_IMAGE_FANOUT,
|
||||||
Error::SeamPlanDroppedMost { .. } => E_SEAM_PLAN_DROPPED_MOST,
|
Error::SeamPlanDroppedMost { .. } => E_SEAM_PLAN_DROPPED_MOST,
|
||||||
Error::SinkWroteNothing => E_SINK_WROTE_NOTHING,
|
Error::SinkWroteNothing => E_SINK_WROTE_NOTHING,
|
||||||
|
Error::StreamClosed => E_STREAM_CLOSED,
|
||||||
|
Error::StreamHeaderWritten => E_STREAM_HEADER_WRITTEN,
|
||||||
Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED,
|
Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED,
|
||||||
Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE,
|
Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE,
|
||||||
}
|
}
|
||||||
@@ -1572,6 +1582,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
.code(),
|
.code(),
|
||||||
Error::SinkWroteNothing.code(),
|
Error::SinkWroteNothing.code(),
|
||||||
|
Error::StreamClosed.code(),
|
||||||
|
Error::StreamHeaderWritten.code(),
|
||||||
Error::ShortImageRead {
|
Error::ShortImageRead {
|
||||||
lba: 0,
|
lba: 0,
|
||||||
expected: 1,
|
expected: 1,
|
||||||
@@ -2006,6 +2018,8 @@ mod tests {
|
|||||||
(Error::MkvLacingInvalid, E_MKV_LACING_INVALID),
|
(Error::MkvLacingInvalid, E_MKV_LACING_INVALID),
|
||||||
(Error::MkvSourceInvalid, E_MKV_SOURCE_INVALID),
|
(Error::MkvSourceInvalid, E_MKV_SOURCE_INVALID),
|
||||||
(Error::MkvUnencodable, E_MKV_UNENCODABLE),
|
(Error::MkvUnencodable, E_MKV_UNENCODABLE),
|
||||||
|
(Error::StreamClosed, E_STREAM_CLOSED),
|
||||||
|
(Error::StreamHeaderWritten, E_STREAM_HEADER_WRITTEN),
|
||||||
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
||||||
(Error::Mp4Invalid, E_MP4_INVALID),
|
(Error::Mp4Invalid, E_MP4_INVALID),
|
||||||
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
||||||
|
|||||||
+78
-7
@@ -286,6 +286,9 @@ struct PgsSupWriter {
|
|||||||
// Delay a duration-derived clear until the next frame, so an original
|
// Delay a duration-derived clear until the next frame, so an original
|
||||||
// clear (or replacement PCS) at that timestamp takes precedence.
|
// clear (or replacement PCS) at that timestamp takes precedence.
|
||||||
pending_clear: Option<(i64, u16, u16)>,
|
pending_clear: Option<(i64, u16, u16)>,
|
||||||
|
// Bytes of truncated trailing segments not written (a partial segment would
|
||||||
|
// desync the `.sup` framing); reported at finish.
|
||||||
|
truncated_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PGS / HDMV segment framing constants ─────────────────────────────────────
|
// ── PGS / HDMV segment framing constants ─────────────────────────────────────
|
||||||
@@ -320,13 +323,14 @@ fn ns_to_90k(pts_ns: i64) -> u32 {
|
|||||||
|
|
||||||
impl PgsSupWriter {
|
impl PgsSupWriter {
|
||||||
/// Walk the concatenated segments in `data`, emitting each with a `PG`
|
/// Walk the concatenated segments in `data`, emitting each with a `PG`
|
||||||
/// header carrying `pts90k`/`dts90k`. Returns bytes written.
|
/// header carrying `pts90k`/`dts90k`. Returns `(bytes written, trailing
|
||||||
|
/// bytes of a truncated segment left unwritten)`.
|
||||||
fn emit_segments(
|
fn emit_segments(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
pts90k: u32,
|
pts90k: u32,
|
||||||
dts90k: u32,
|
dts90k: u32,
|
||||||
w: &mut dyn Write,
|
w: &mut dyn Write,
|
||||||
) -> io::Result<usize> {
|
) -> io::Result<(usize, usize)> {
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
let mut written = 0;
|
let mut written = 0;
|
||||||
// Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes.
|
// Each PGS segment in the payload is: type(1) + size(2 BE) + size bytes.
|
||||||
@@ -343,7 +347,7 @@ impl PgsSupWriter {
|
|||||||
written += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size;
|
written += SUP_HEADER_LEN + PGS_SEG_HEADER_LEN + size;
|
||||||
pos = seg_end;
|
pos = seg_end;
|
||||||
}
|
}
|
||||||
Ok(written)
|
Ok((written, data.len() - pos))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthetic "clear" display set (empty PCS + END) re-emitted at `display_pts + duration`;
|
// Synthetic "clear" display set (empty PCS + END) re-emitted at `display_pts + duration`;
|
||||||
@@ -402,14 +406,16 @@ impl EsWriter for PgsSupWriter {
|
|||||||
if end < pts_ns || (end == pts_ns && !is_pcs) {
|
if end < pts_ns || (end == pts_ns && !is_pcs) {
|
||||||
let clear = Self::synthetic_clear_display_set(width, height);
|
let clear = Self::synthetic_clear_display_set(width, height);
|
||||||
let pts = ns_to_90k(end);
|
let pts = ns_to_90k(end);
|
||||||
written += Self::emit_segments(&clear, pts, pts, w)?;
|
written += Self::emit_segments(&clear, pts, pts, w)?.0;
|
||||||
self.pending_clear = None;
|
self.pending_clear = None;
|
||||||
} else if is_pcs {
|
} else if is_pcs {
|
||||||
// A real clear/replacement at or before the computed end wins.
|
// A real clear/replacement at or before the computed end wins.
|
||||||
self.pending_clear = None;
|
self.pending_clear = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
written += Self::emit_segments(&f.data, pts90, pts90, w)?;
|
let (n, truncated) = Self::emit_segments(&f.data, pts90, pts90, w)?;
|
||||||
|
written += n;
|
||||||
|
self.truncated_bytes += truncated as u64;
|
||||||
// Retain fallback support for old MKVs that only carry durations, and
|
// Retain fallback support for old MKVs that only carry durations, and
|
||||||
// for a final display without a clear. Never synthesize a clear OF a
|
// for a final display without a clear. Never synthesize a clear OF a
|
||||||
// clear (nor of a standalone WDS/END segment).
|
// clear (nor of a standalone WDS/END segment).
|
||||||
@@ -427,6 +433,13 @@ impl EsWriter for PgsSupWriter {
|
|||||||
let pts = ns_to_90k(end);
|
let pts = ns_to_90k(end);
|
||||||
Self::emit_segments(&clear, pts, pts, w)?;
|
Self::emit_segments(&clear, pts, pts, w)?;
|
||||||
}
|
}
|
||||||
|
if self.truncated_bytes > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
bytes = self.truncated_bytes,
|
||||||
|
"PGS .sup: truncated trailing segments were not written"
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1347,7 +1360,7 @@ mod tests {
|
|||||||
// One segment: type=0x16, size=2, payload=[0xDE,0xAD].
|
// One segment: type=0x16, size=2, payload=[0xDE,0xAD].
|
||||||
let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD];
|
let payload = [SEG_PCS, 0x00, 0x02, 0xDE, 0xAD];
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let written = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap();
|
let (written, _) = PgsSupWriter::emit_segments(&payload, 0x10, 0x10, &mut out).unwrap();
|
||||||
assert_eq!(&out[0..2], &SUP_MAGIC);
|
assert_eq!(&out[0..2], &SUP_MAGIC);
|
||||||
assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS
|
assert_eq!(&out[2..6], &0x10u32.to_be_bytes()); // PTS
|
||||||
assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS
|
assert_eq!(&out[6..10], &0x10u32.to_be_bytes()); // DTS
|
||||||
@@ -1581,10 +1594,18 @@ mod tests {
|
|||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
PgsSupWriter::emit_segments(data, 0, 0, &mut bytes).unwrap(),
|
PgsSupWriter::emit_segments(data, 0, 0, &mut bytes).unwrap(),
|
||||||
0
|
(0, data.len())
|
||||||
);
|
);
|
||||||
assert!(bytes.is_empty());
|
assert!(bytes.is_empty());
|
||||||
}
|
}
|
||||||
|
// A frame ending in a truncated segment is accounted, not silently lost.
|
||||||
|
let mut writer = PgsSupWriter::default();
|
||||||
|
let mut frame = sup_frame(0, None, true);
|
||||||
|
frame.data.extend_from_slice(&[SEG_PCS, 0, 9, 1]);
|
||||||
|
writer
|
||||||
|
.write_frame(&mut Vec::new(), &frame, frame.pts)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(writer.truncated_bytes, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2065,6 +2086,56 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `audio://` where every AUDIO frame falls outside the marks but video frames
|
||||||
|
// map: filtered video must not count as written, so the export fails loudly.
|
||||||
|
#[test]
|
||||||
|
fn a_filtered_export_whose_persisted_track_was_emptied_fails() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let mut title = title_with(
|
||||||
|
vec![video_stream(Codec::H264), audio_stream(Codec::Ac3, "eng")],
|
||||||
|
vec![None, None],
|
||||||
|
);
|
||||||
|
title.clips = (0..2u32)
|
||||||
|
.map(|i| crate::disc::Clip {
|
||||||
|
feed_span: None,
|
||||||
|
clip_id: format!("0000{i}"),
|
||||||
|
in_time: (100 + 100 * i) * 45_000,
|
||||||
|
out_time: (200 + 100 * i) * 45_000,
|
||||||
|
duration_secs: 100.0,
|
||||||
|
source_packets: 0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let opts = DemuxOptions {
|
||||||
|
base: "Empty".to_string(),
|
||||||
|
kind_filter: Some(TrackKind::Audio),
|
||||||
|
export_chapters: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||||
|
let frame = |track: usize, pts: i64| PesFrame {
|
||||||
|
discard_padding_ns: 0,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track,
|
||||||
|
pts,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0x0B, 0x77],
|
||||||
|
duration_ns: None,
|
||||||
|
};
|
||||||
|
for s in [150i64, 151, 152] {
|
||||||
|
let _ = Stream::write(&mut sink, &frame(0, s * 1_000_000_000));
|
||||||
|
}
|
||||||
|
for s in [0i64, 1] {
|
||||||
|
let _ = Stream::write(&mut sink, &frame(1, s * 1_000_000_000));
|
||||||
|
}
|
||||||
|
let err = Stream::finish(&mut sink).expect_err("no audio was written");
|
||||||
|
assert_eq!(
|
||||||
|
crate::error::error_code(&err),
|
||||||
|
Some(crate::error::E_SINK_WROTE_NOTHING)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sink_read_returns_write_only() {
|
fn sink_read_returns_write_only() {
|
||||||
let dir = tempdir();
|
let dir = tempdir();
|
||||||
|
|||||||
+37
-2
@@ -4,8 +4,9 @@
|
|||||||
//! consumer thread so feed and codec parse pipeline instead of serialise.
|
//! consumer thread so feed and codec parse pipeline instead of serialise.
|
||||||
//!
|
//!
|
||||||
//! [`DemuxThread::spawn_zero_copy`] returns a handle plus a
|
//! [`DemuxThread::spawn_zero_copy`] returns a handle plus a
|
||||||
//! `Receiver<DemuxBatch>`; dropping the handle closes the channel and
|
//! `Receiver<DemuxBatch>`. `Drop::drop` joins the worker, which exits only once
|
||||||
//! `Drop::drop` joins the worker.
|
//! that receiver is gone: drop the receiver FIRST (an owner declares it before
|
||||||
|
//! the handle), or a worker blocked on a full channel deadlocks the join.
|
||||||
|
|
||||||
use crate::halt::Halt;
|
use crate::halt::Halt;
|
||||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||||
@@ -586,4 +587,38 @@ mod tests {
|
|||||||
"recycle_rx recv must fail after recycle_tx drop"
|
"recycle_rx recv must fail after recycle_tx drop"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelinedPesStream must drop its receiver before this handle (field order):
|
||||||
|
// a worker blocked on the full channel then exits instead of deadlocking.
|
||||||
|
#[test]
|
||||||
|
fn dropping_a_stream_with_a_blocked_worker_does_not_hang() {
|
||||||
|
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(16);
|
||||||
|
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(16);
|
||||||
|
let pid = 0x1011;
|
||||||
|
let ts = super::super::ts::TsDemuxer::new(&[pid]);
|
||||||
|
let (dt, rx) =
|
||||||
|
DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, Some(ts), None).unwrap();
|
||||||
|
for i in 0..8u8 {
|
||||||
|
let mut chunk = bdts_pes_packet(pid, &[i]);
|
||||||
|
chunk.extend(bdts_pes_packet(pid, &[i, i]));
|
||||||
|
pf_tx.send(Ok(chunk)).unwrap();
|
||||||
|
}
|
||||||
|
drop(pf_tx);
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
let stream = super::super::pipelined_stream::PipelinedPesStream::new(
|
||||||
|
dt,
|
||||||
|
rx,
|
||||||
|
crate::disc::DiscTitle::empty(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
drop(stream);
|
||||||
|
let _ = done_tx.send(());
|
||||||
|
});
|
||||||
|
done_rx
|
||||||
|
.recv_timeout(Duration::from_secs(10))
|
||||||
|
.expect("drop must not deadlock on the blocked demux worker");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-6
@@ -22,7 +22,8 @@ use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
|
|||||||
use crate::session::DiscSession;
|
use crate::session::DiscSession;
|
||||||
|
|
||||||
use super::resolve::{
|
use super::resolve::{
|
||||||
InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map,
|
InputOptions, StreamUrl, build_iso_pipeline, input_with_halt, output, parse_url,
|
||||||
|
resolve_mux_key_map,
|
||||||
};
|
};
|
||||||
use super::videomap::{Medium, SourceInfo};
|
use super::videomap::{Medium, SourceInfo};
|
||||||
|
|
||||||
@@ -112,11 +113,11 @@ pub enum MuxInput<'a> {
|
|||||||
key_map: Option<Arc<AacsKeyMap>>,
|
key_map: Option<Arc<AacsKeyMap>>,
|
||||||
},
|
},
|
||||||
/// Any URL-addressed source (`iso://`, `mkv://`, `m2ts://`, `network://`,
|
/// Any URL-addressed source (`iso://`, `mkv://`, `m2ts://`, `network://`,
|
||||||
/// stdio) opened via [`input`].
|
/// stdio) opened via [`input`](super::resolve::input).
|
||||||
Url {
|
Url {
|
||||||
/// The source URL.
|
/// The source URL.
|
||||||
url: &'a str,
|
url: &'a str,
|
||||||
/// Input options forwarded to [`input`].
|
/// Input options forwarded to [`input`](super::resolve::input).
|
||||||
opts: InputOptions,
|
opts: InputOptions,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,22 @@ pub fn mux_stream(
|
|||||||
title: in_opts.title_index.unwrap_or(0),
|
title: in_opts.title_index.unwrap_or(0),
|
||||||
..SourceInfo::default()
|
..SourceInfo::default()
|
||||||
};
|
};
|
||||||
let stream = input(url, &in_opts)?;
|
// A Stop while `network://` waits for its sender is a clean interrupt.
|
||||||
|
let stream = match input_with_halt(url, &in_opts, Some(halt)) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if crate::error::is_halt(&e) => {
|
||||||
|
return Ok(MuxOutcome {
|
||||||
|
completed: false,
|
||||||
|
output_opened: false,
|
||||||
|
bytes_written: 0,
|
||||||
|
errors: 0,
|
||||||
|
lost_bytes: 0,
|
||||||
|
streams: 0,
|
||||||
|
undelivered_streams: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
let source = SourceInfo {
|
let source = SourceInfo {
|
||||||
playlist: stream.info().playlist.clone(),
|
playlist: stream.info().playlist.clone(),
|
||||||
..source
|
..source
|
||||||
@@ -507,6 +523,21 @@ fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::Eve
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join the write consumer after the pump. A send that hit its deadline means the
|
||||||
|
// consumer is wedged, so the join gets only the short grace, not JOIN_TIMEOUT.
|
||||||
|
fn finish_pumped<I: Send + 'static, R: Send + 'static>(
|
||||||
|
pipe: Pipeline<I, R>,
|
||||||
|
halt: &Halt,
|
||||||
|
send_timed_out: bool,
|
||||||
|
) -> Result<R, Error> {
|
||||||
|
if send_timed_out {
|
||||||
|
let wedged = Halt::new();
|
||||||
|
wedged.cancel();
|
||||||
|
return pipe.finish_with_halt(Some(&wedged));
|
||||||
|
}
|
||||||
|
pipe.finish_with_halt(Some(halt))
|
||||||
|
}
|
||||||
|
|
||||||
// Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, or halt_cancelled
|
// Whether a finished mux counts as COMPLETED: interrupted, finalize_failed, or halt_cancelled
|
||||||
// each force `false`. Pure fn so this mapping is unit-tested directly.
|
// each force `false`. Pure fn so this mapping is unit-tested directly.
|
||||||
fn mux_run_completed(interrupted: bool, finalize_failed: bool, halt_cancelled: bool) -> bool {
|
fn mux_run_completed(interrupted: bool, finalize_failed: bool, halt_cancelled: bool) -> bool {
|
||||||
@@ -704,11 +735,14 @@ fn drive_mux(
|
|||||||
// every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` stays responsive.
|
// every `POLL_INTERVAL`, so Ctrl-C / `/api/stop` stays responsive.
|
||||||
let deadline = send_deadline;
|
let deadline = send_deadline;
|
||||||
let mut interrupted = false;
|
let mut interrupted = false;
|
||||||
|
// A send refused with no halt and a healthy consumer ran out its deadline.
|
||||||
|
let mut send_timed_out = false;
|
||||||
|
|
||||||
// Buffered header frames first, in order.
|
// Buffered header frames first, in order.
|
||||||
for frame in buffered {
|
for frame in buffered {
|
||||||
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
|
send_timed_out = !halt.is_cancelled() && !pipe.consumer_failed();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Feed write-side progress during the drain exactly as the steady-state
|
// Feed write-side progress during the drain exactly as the steady-state
|
||||||
@@ -732,6 +766,7 @@ fn drive_mux(
|
|||||||
}
|
}
|
||||||
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
|
send_timed_out = !halt.is_cancelled() && !pipe.consumer_failed();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes);
|
events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes);
|
||||||
@@ -785,7 +820,7 @@ fn drive_mux(
|
|||||||
// the container. On halt/wedge this returns an error variant, translated
|
// the container. On halt/wedge this returns an error variant, translated
|
||||||
// to `completed = false` rather than a hard failure.
|
// to `completed = false` rather than a hard failure.
|
||||||
let (bytes_written, undelivered_streams, finalize_failed) =
|
let (bytes_written, undelivered_streams, finalize_failed) =
|
||||||
match pipe.finish_with_halt(Some(halt)) {
|
match finish_pumped(pipe, halt, send_timed_out) {
|
||||||
Ok((b, undelivered)) => (b, undelivered, false),
|
Ok((b, undelivered)) => (b, undelivered, false),
|
||||||
Err(Error::Halted | Error::PipelineJoinTimeout) => {
|
Err(Error::Halted | Error::PipelineJoinTimeout) => {
|
||||||
(bytes.load(Ordering::Relaxed), Vec::new(), true)
|
(bytes.load(Ordering::Relaxed), Vec::new(), true)
|
||||||
@@ -796,7 +831,8 @@ fn drive_mux(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
streams = ?undelivered_streams,
|
streams = ?undelivered_streams,
|
||||||
"output sink could not deliver every planned stream; the file does not match the pre-mux plan (surfaced as MuxOutcome::undelivered_streams)"
|
"output sink could not deliver every planned stream; the file does not match \
|
||||||
|
the pre-mux plan (surfaced as MuxOutcome::undelivered_streams)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2715,4 +2751,136 @@ mod tests {
|
|||||||
"a non-DVD passes decrypt_keys() through unchanged"
|
"a non-DVD passes decrypt_keys() through unchanged"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A consumer wedged past the send deadline must not hold the finish for the
|
||||||
|
// full JOIN_TIMEOUT (600 s).
|
||||||
|
#[test]
|
||||||
|
fn a_send_timeout_bounds_the_final_join() {
|
||||||
|
// Only the first write wedges, returning after finish has given up; the
|
||||||
|
// leaked consumer must then neither apply the queued item nor close.
|
||||||
|
struct Stuck {
|
||||||
|
closed: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
woke: std::sync::mpsc::Sender<()>,
|
||||||
|
first: bool,
|
||||||
|
applied: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
impl Sink<u32> for Stuck {
|
||||||
|
type Output = ();
|
||||||
|
fn apply(&mut self, _: u32) -> Result<Flow, Error> {
|
||||||
|
self.applied.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if std::mem::take(&mut self.first) {
|
||||||
|
std::thread::sleep(Duration::from_secs(7));
|
||||||
|
let _ = self.woke.send(());
|
||||||
|
}
|
||||||
|
Ok(Flow::Continue)
|
||||||
|
}
|
||||||
|
fn close(self) -> Result<(), Error> {
|
||||||
|
self.closed.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let applied = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let (woke_tx, woke) = std::sync::mpsc::channel();
|
||||||
|
let stuck = Stuck {
|
||||||
|
closed: closed.clone(),
|
||||||
|
woke: woke_tx,
|
||||||
|
first: true,
|
||||||
|
applied: applied.clone(),
|
||||||
|
};
|
||||||
|
let pipe = Pipeline::spawn(1, stuck).unwrap();
|
||||||
|
let halt = Halt::new();
|
||||||
|
let mut timed_out = false;
|
||||||
|
for i in 0..4 {
|
||||||
|
if pipe
|
||||||
|
.send_with_halt(i, &halt, Duration::from_millis(50))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
timed_out = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(timed_out, "the stuck consumer trips the send deadline");
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(finish_pumped(pipe, &halt, true).is_err());
|
||||||
|
});
|
||||||
|
let failed = rx
|
||||||
|
.recv_timeout(Duration::from_secs(30))
|
||||||
|
.expect("the join must be bounded by the grace, not JOIN_TIMEOUT");
|
||||||
|
assert!(failed);
|
||||||
|
woke.recv_timeout(Duration::from_secs(30))
|
||||||
|
.expect("the wedged write returns");
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
assert!(
|
||||||
|
!closed.load(Ordering::SeqCst),
|
||||||
|
"an abandoned consumer must not finalise the output late"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
applied.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"nor apply the queued item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Opus-style CodecDelay/SeekPreRoll on an mkv:// source must reach the
|
||||||
|
// mkv:// output through the driver (the sink's set_track_timing wiring).
|
||||||
|
#[test]
|
||||||
|
fn track_timing_propagates_from_source_to_mkv_output() {
|
||||||
|
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||||
|
use crate::mux::mkvstream::MkvStream;
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let src = dir.path().join("src.mkv");
|
||||||
|
let dst = dir.path().join("dst.mkv");
|
||||||
|
let mut title = DiscTitle {
|
||||||
|
streams: vec![crate::disc::Stream::Video(VideoStream {
|
||||||
|
pid: 0x1011,
|
||||||
|
codec: Codec::H264,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: FrameRate::F24,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})],
|
||||||
|
..DiscTitle::empty()
|
||||||
|
};
|
||||||
|
title.codec_privates = vec![Some(vec![0x01, 0x64, 0x00, 0x1F, 0xFF, 0xE1])];
|
||||||
|
let timing = crate::pes::TrackTiming {
|
||||||
|
codec_delay_ns: 6_500_000,
|
||||||
|
seek_preroll_ns: 80_000_000,
|
||||||
|
};
|
||||||
|
let mut w = MkvStream::create(Box::new(std::fs::File::create(&src).unwrap()), &title, None)
|
||||||
|
.unwrap();
|
||||||
|
w.set_track_timing(0, timing).unwrap();
|
||||||
|
w.write(&PesFrame {
|
||||||
|
discard_padding_ns: 0,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0xA1; 16],
|
||||||
|
duration_ns: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
w.finish().unwrap();
|
||||||
|
|
||||||
|
let stream = Box::new(MkvStream::open(std::fs::File::open(&src).unwrap()).unwrap());
|
||||||
|
let out = drive_mux(
|
||||||
|
stream,
|
||||||
|
&format!("mkv://{}", dst.display()),
|
||||||
|
&Halt::new(),
|
||||||
|
&SpyEvents::new(),
|
||||||
|
None,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("mkv remux runs");
|
||||||
|
assert!(out.completed);
|
||||||
|
let back = MkvStream::open(std::fs::File::open(&dst).unwrap()).unwrap();
|
||||||
|
assert_eq!(back.track_timing(0), timing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,6 +563,11 @@ pub const BLOCK_ADD_ID: u32 = 0xEE;
|
|||||||
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
|
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
|
||||||
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
|
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
|
||||||
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
||||||
|
/// DiscardPadding (RFC 9559 §5.1.3.5.7): signed ns of decoded audio to drop.
|
||||||
|
pub const DISCARD_PADDING: u32 = 0x75A2;
|
||||||
|
/// CodecDelay / SeekPreRoll (RFC 9559 §5.1.4.1.18/19), TrackEntry children, ns.
|
||||||
|
pub const CODEC_DELAY: u32 = 0x56AA;
|
||||||
|
pub const SEEK_PRE_ROLL: u32 = 0x56BB;
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
pub const AUDIO: u32 = 0xE1;
|
pub const AUDIO: u32 = 0xE1;
|
||||||
@@ -629,6 +634,8 @@ pub const CODEC_EAC3: &str = "A_EAC3";
|
|||||||
pub const CODEC_TRUEHD: &str = "A_TRUEHD";
|
pub const CODEC_TRUEHD: &str = "A_TRUEHD";
|
||||||
pub const CODEC_DTS: &str = "A_DTS";
|
pub const CODEC_DTS: &str = "A_DTS";
|
||||||
pub const CODEC_PCM_BE: &str = "A_PCM/INT/BIG";
|
pub const CODEC_PCM_BE: &str = "A_PCM/INT/BIG";
|
||||||
|
/// Little-endian integer PCM (read side only; converted to big-endian).
|
||||||
|
pub const CODEC_PCM_LE: &str = "A_PCM/INT/LIT";
|
||||||
/// AAC. The generic registered ID; the AudioSpecificConfig travels in
|
/// AAC. The generic registered ID; the AudioSpecificConfig travels in
|
||||||
/// CodecPrivate, so no profile suffix is needed (and the `A_AAC/MPEG4/*`
|
/// CodecPrivate, so no profile suffix is needed (and the `A_AAC/MPEG4/*`
|
||||||
/// suffixed forms are legacy).
|
/// suffixed forms are legacy).
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
//! *video-index* record per coded picture of the title's primary video
|
//! *video-index* record per coded picture of the title's primary video
|
||||||
//! track, instead of muxing frames into a container.
|
//! track, instead of muxing frames into a container.
|
||||||
//!
|
//!
|
||||||
//! On-disk shape: the freemkv FVI format — JSON Lines, a header object on line 1, then one
|
//! On-disk shape: the freemkv FVI format — JSON Lines, a header object on line 1, then one
|
||||||
//! record per picture. The sink is purely additive: it does NOT touch the MKV mux path.
|
//! record per picture. The sink is purely additive: it does NOT touch the MKV mux path.
|
||||||
|
|
||||||
use crate::disc::{DiscTitle, Stream as DiscStream};
|
use crate::disc::{DiscTitle, Stream as DiscStream};
|
||||||
@@ -17,7 +17,7 @@ use std::fs::File;
|
|||||||
use std::io::{self, BufWriter, Write};
|
use std::io::{self, BufWriter, Write};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Write the FVI header row into `w`.
|
/// Write the FVI header row into `w`.
|
||||||
fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> {
|
fn write_fvi_header(w: &mut dyn Write, h: &MapHeader) -> io::Result<()> {
|
||||||
let mut source = serde_json::json!({
|
let mut source = serde_json::json!({
|
||||||
"medium": h.source.medium.as_str(),
|
"medium": h.source.medium.as_str(),
|
||||||
|
|||||||
@@ -66,6 +66,30 @@ fn generate(
|
|||||||
vfr: bool,
|
vfr: bool,
|
||||||
bframes: bool,
|
bframes: bool,
|
||||||
multi_audio: bool,
|
multi_audio: bool,
|
||||||
|
) -> PathBuf {
|
||||||
|
generate_with(
|
||||||
|
dir,
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
transport,
|
||||||
|
vfr,
|
||||||
|
bframes,
|
||||||
|
multi_audio,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `video_late`: video starts 0.3 s in, so the 0.1 s audio precedes the first IDR.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn generate_with(
|
||||||
|
dir: &Path,
|
||||||
|
video: &str,
|
||||||
|
audio: Option<&str>,
|
||||||
|
transport: bool,
|
||||||
|
vfr: bool,
|
||||||
|
bframes: bool,
|
||||||
|
multi_audio: bool,
|
||||||
|
video_late: bool,
|
||||||
) -> PathBuf {
|
) -> PathBuf {
|
||||||
let path = dir.join(if transport { "input.m2ts" } else { "input.mkv" });
|
let path = dir.join(if transport { "input.m2ts" } else { "input.mkv" });
|
||||||
let mut command = Command::new("ffmpeg");
|
let mut command = Command::new("ffmpeg");
|
||||||
@@ -145,6 +169,9 @@ fn generate(
|
|||||||
"vfr",
|
"vfr",
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
if video_late {
|
||||||
|
command.args(["-vf", "setpts=PTS+0.3/TB"]);
|
||||||
|
}
|
||||||
if transport {
|
if transport {
|
||||||
command.args(["-f", "mpegts", "-mpegts_m2ts_mode", "0"]);
|
command.args(["-f", "mpegts", "-mpegts_m2ts_mode", "0"]);
|
||||||
}
|
}
|
||||||
@@ -477,8 +504,7 @@ fn ffmpeg_mkv_roundtrip_preserves_vfr_and_multiple_audio_tracks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Discovered while building the gate; retain executable, strict reproducers.
|
// Discovered while building the gate; retain executable, strict reproducers.
|
||||||
// These deliberately do NOT have the ffmpeg_ prefix: run ffmpeg_ explicitly
|
// They carry the ffmpeg_ prefix, so the QA gate runs them with the rest.
|
||||||
// to investigate them. Promote each into the QA gate when its fix lands.
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "requires ffmpeg and ffprobe"]
|
#[ignore = "requires ffmpeg and ffprobe"]
|
||||||
@@ -539,3 +565,22 @@ fn ffmpeg_generic_transport_audio_discovery() {
|
|||||||
validate(dir.path(), &input, &output, 2, false);
|
validate(dir.path(), &input, &output, 2, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires ffmpeg and ffprobe"]
|
||||||
|
fn ffmpeg_audio_before_the_first_video_frame_keeps_its_offset() {
|
||||||
|
let dir = fixture_dir("audio-first");
|
||||||
|
let input = generate_with(
|
||||||
|
dir.path(),
|
||||||
|
"libx264",
|
||||||
|
Some("ac3"),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let output = dir.path().join("output.mkv");
|
||||||
|
remux(&input, &output, true);
|
||||||
|
validate(dir.path(), &input, &output, 2, false);
|
||||||
|
}
|
||||||
|
|||||||
+76
-17
@@ -119,6 +119,8 @@ pub struct M2tsMux<W: Write> {
|
|||||||
/// onto the very first video PES so a receiver tuning at stream start
|
/// onto the very first video PES so a receiver tuning at stream start
|
||||||
/// has a clock reference (the PMT advertises the video PID as PCR_PID).
|
/// has a clock reference (the PMT advertises the video PID as PCR_PID).
|
||||||
first_video_written: bool,
|
first_video_written: bool,
|
||||||
|
/// Last PCR stamped (90 kHz); the PCR is held monotonic against it.
|
||||||
|
last_pcr_90k: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<W: Write> M2tsMux<W> {
|
impl<W: Write> M2tsMux<W> {
|
||||||
@@ -140,6 +142,7 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
packets_written: 0,
|
packets_written: 0,
|
||||||
video_packets_since_pcr: 0,
|
video_packets_since_pcr: 0,
|
||||||
first_video_written: false,
|
first_video_written: false,
|
||||||
|
last_pcr_90k: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,11 +176,16 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
/// drives the random_access_indicator bit on the first packet of this
|
/// drives the random_access_indicator bit on the first packet of this
|
||||||
/// PES (and gates codec_private NAL prepending — those only attach to
|
/// PES (and gates codec_private NAL prepending — those only attach to
|
||||||
/// the first keyframe).
|
/// the first keyframe).
|
||||||
|
///
|
||||||
|
/// Frames carry no DTS, so the PCR leads the PTS by `PCR_LEAD_90KHZ` and is
|
||||||
|
/// held monotonic; reordering deeper than that lead can still let it pass a
|
||||||
|
/// B-frame's PTS.
|
||||||
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||||
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true);
|
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true);
|
||||||
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
|
let pcr = pts_90k
|
||||||
// first frame so we don't underflow.
|
.saturating_sub(PCR_LEAD_90KHZ)
|
||||||
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
|
.max(self.last_pcr_90k);
|
||||||
|
self.last_pcr_90k = pcr;
|
||||||
|
|
||||||
// Annex-B-ify the frame and prepend VPS/SPS/PPS once, on the
|
// Annex-B-ify the frame and prepend VPS/SPS/PPS once, on the
|
||||||
// FIRST keyframe (not first frame — non-key frames before the
|
// FIRST keyframe (not first frame — non-key frames before the
|
||||||
@@ -256,7 +264,11 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
0
|
0
|
||||||
};
|
};
|
||||||
if may_seed_base {
|
if may_seed_base {
|
||||||
self.base_pts_90k.get_or_insert(raw_90k);
|
// Seeded one headroom early (see tsmux) so a slightly earlier frame
|
||||||
|
// arriving second keeps its offset.
|
||||||
|
let headroom = (super::tsmux::ORIGIN_HEADROOM_NS as u64) * 9 / 100_000;
|
||||||
|
self.base_pts_90k
|
||||||
|
.get_or_insert(raw_90k.wrapping_sub(headroom) & 0x1_FFFF_FFFF);
|
||||||
}
|
}
|
||||||
let base = self.base_pts_90k.unwrap_or(raw_90k);
|
let base = self.base_pts_90k.unwrap_or(raw_90k);
|
||||||
// Modular 33-bit subtraction: the PTS clock wraps every 2^33 ticks
|
// Modular 33-bit subtraction: the PTS clock wraps every 2^33 ticks
|
||||||
@@ -569,6 +581,9 @@ fn mpegts_crc32(data: &[u8]) -> u32 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// The origin headroom in 90 kHz ticks: the seeding frame's encoded PTS.
|
||||||
|
const H: u64 = super::super::tsmux::ORIGIN_HEADROOM_NS as u64 * 9 / 100_000;
|
||||||
|
|
||||||
// Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7).
|
// Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7).
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() {
|
fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() {
|
||||||
@@ -1280,9 +1295,7 @@ mod tests {
|
|||||||
let mut frame = Vec::new();
|
let mut frame = Vec::new();
|
||||||
frame.extend_from_slice(&4u32.to_be_bytes());
|
frame.extend_from_slice(&4u32.to_be_bytes());
|
||||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||||
// 1s → 90000 ticks; base is this same frame, so relative PTS=0
|
// A single frame: it seeds the origin one headroom below itself.
|
||||||
// and PCR clamps to 0. Use a single frame: PTS rebases to 0,
|
|
||||||
// so PCR = 0.saturating_sub(lead) = 0.
|
|
||||||
mux.write_video(1_000_000_000, true, &frame).unwrap();
|
mux.write_video(1_000_000_000, true, &frame).unwrap();
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
}
|
}
|
||||||
@@ -1295,16 +1308,16 @@ mod tests {
|
|||||||
| ((af[3] as u64) << 9)
|
| ((af[3] as u64) << 9)
|
||||||
| ((af[4] as u64) << 1)
|
| ((af[4] as u64) << 1)
|
||||||
| ((af[5] as u64 >> 7) & 0x01);
|
| ((af[5] as u64 >> 7) & 0x01);
|
||||||
// Single frame rebases its own PTS to 0; PCR = 0 - lead clamped to 0.
|
// The seeding frame sits one headroom past the origin; PCR leads it.
|
||||||
assert_eq!(base, 0, "first frame PCR clamps to 0 (no underflow)");
|
assert_eq!(base, H - PCR_LEAD_90KHZ, "first frame PCR leads its PTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── base_relative_pts overflow / saturation ───────────────────────────
|
// ── base_relative_pts overflow / saturation ───────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extreme_pts_does_not_overflow_and_clamps_to_33bit() {
|
fn extreme_pts_does_not_overflow_and_clamps_to_33bit() {
|
||||||
// Two frames are required: a single frame always rebases to PTS 0, making
|
// Two frames are required: a single frame always encodes the headroom, making
|
||||||
// the only assertion the vacuous `0 < 2^33`. Bit 32 is unreachable (the
|
// the only assertion vacuous. Bit 32 is unreachable (the
|
||||||
// signed-33-bit delta rule floors the upper half to 0), so this pins encoding.
|
// signed-33-bit delta rule floors the upper half to 0), so this pins encoding.
|
||||||
let decode_pts = |pkt: &[u8]| -> u64 {
|
let decode_pts = |pkt: &[u8]| -> u64 {
|
||||||
// Payload after the AF: AF area = 1 (length byte) + af_len.
|
// Payload after the AF: AF area = 1 (length byte) + af_len.
|
||||||
@@ -1328,7 +1341,8 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let mut mux = M2tsMux::new(&mut sink);
|
let mut mux = M2tsMux::new(&mut sink);
|
||||||
mux.write_video(0, true, &frame).unwrap();
|
mux.write_video(0, true, &frame).unwrap();
|
||||||
mux.write_video(477_218_477 * 100_000, true, &frame)
|
// Less one headroom (10_000 x 100 us), which the origin already adds.
|
||||||
|
mux.write_video((477_218_477 - 10_000) * 100_000, true, &frame)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
mux.finish().unwrap();
|
mux.finish().unwrap();
|
||||||
}
|
}
|
||||||
@@ -1398,7 +1412,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn negative_pts_ns_encodes_zero() {
|
fn negative_pts_ns_encodes_zero() {
|
||||||
// base_relative_pts treats pts_ns <= 0 as raw 0. A negative input
|
// base_relative_pts treats pts_ns <= 0 as raw 0. A negative input
|
||||||
// must encode PTS 0, not a wrapped value.
|
// must encode as t=0 (the headroom), not a wrapped value.
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut mux = M2tsMux::new(&mut sink);
|
let mut mux = M2tsMux::new(&mut sink);
|
||||||
@@ -1416,7 +1430,7 @@ mod tests {
|
|||||||
| (((pes[11] >> 1) as u64) << 15)
|
| (((pes[11] >> 1) as u64) << 15)
|
||||||
| ((pes[12] as u64) << 7)
|
| ((pes[12] as u64) << 7)
|
||||||
| ((pes[13] >> 1) as u64);
|
| ((pes[13] >> 1) as u64);
|
||||||
assert_eq!(pts, 0, "negative pts_ns encodes PTS 0");
|
assert_eq!(pts, H, "negative pts_ns is treated as 0, the seeding frame");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1454,12 +1468,12 @@ mod tests {
|
|||||||
assert_eq!(pkts.len(), 2, "one PUSI packet per small audio frame");
|
assert_eq!(pkts.len(), 2, "one PUSI packet per small audio frame");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_audio_pts(pkts[0]),
|
decode_audio_pts(pkts[0]),
|
||||||
0,
|
H,
|
||||||
"the first (audio) frame seeds the origin → 0"
|
"the first (audio) frame seeds the origin one headroom below it"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_audio_pts(pkts[1]),
|
decode_audio_pts(pkts[1]),
|
||||||
90_000,
|
H + 90_000,
|
||||||
"the second audio frame (1s later) must keep its spacing, not collapse to 0"
|
"the second audio frame (1s later) must keep its spacing, not collapse to 0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1518,4 +1532,49 @@ mod tests {
|
|||||||
"first packet is PAT on PID 0"
|
"first packet is PAT on PID 0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same for the 188-byte muxer: a frame shortly before the seeding one keeps
|
||||||
|
// its offset instead of flooring to the origin.
|
||||||
|
#[test]
|
||||||
|
fn a_frame_shortly_before_the_seeding_frame_keeps_its_offset() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
let mut mux = M2tsMux::new(&mut sink);
|
||||||
|
let audio = mux.base_relative_pts(100_000_000, true);
|
||||||
|
let video = mux.base_relative_pts(50_000_000, true);
|
||||||
|
assert_eq!(audio - video, 4_500);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_pcrs(buf: &[u8]) -> Vec<u64> {
|
||||||
|
find_all_pkts(buf, PID_VIDEO, false)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(af_body)
|
||||||
|
.filter(|af| af.first().is_some_and(|f| f & 0x10 != 0))
|
||||||
|
.map(|af| {
|
||||||
|
((af[1] as u64) << 25)
|
||||||
|
| ((af[2] as u64) << 17)
|
||||||
|
| ((af[3] as u64) << 9)
|
||||||
|
| ((af[4] as u64) << 1)
|
||||||
|
| ((af[5] as u64 >> 7) & 0x01)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// B-frames arrive with PTS below an earlier P-frame's: the PCR must still
|
||||||
|
// never step backwards (ISO/IEC 13818-1 §2.4.2.2).
|
||||||
|
#[test]
|
||||||
|
fn pcr_never_goes_backwards_with_b_frames() {
|
||||||
|
let mut frame = Vec::new();
|
||||||
|
frame.extend_from_slice(&8000u32.to_be_bytes());
|
||||||
|
frame.extend(std::iter::repeat_n(0x41u8, 8000));
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = M2tsMux::new(&mut sink);
|
||||||
|
for (i, ms) in [80i64, 200, 120, 160].into_iter().enumerate() {
|
||||||
|
mux.write_video(ms * 1_000_000, i == 0, &frame).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pcrs = all_pcrs(&sink);
|
||||||
|
assert!(pcrs.len() >= 4, "{pcrs:?}");
|
||||||
|
assert!(pcrs.windows(2).all(|w| w[1] >= w[0]), "{pcrs:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-5
@@ -26,8 +26,10 @@ const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
|
|||||||
|
|
||||||
/// Highest header format version this build understands. A header tagged with
|
/// Highest header format version this build understands. A header tagged with
|
||||||
/// a newer version is rejected so older readers cleanly refuse incompatible
|
/// a newer version is rejected so older readers cleanly refuse incompatible
|
||||||
/// formats instead of silently mis-parsing them as v1.
|
/// formats instead of silently mis-parsing them as v1. Version 2 = every PES
|
||||||
const SUPPORTED_VERSION: u8 = 1;
|
/// frame after the header carries an 8-byte DiscardPadding extension; it is
|
||||||
|
/// written only when a track has decoder timing, so other streams stay v1.
|
||||||
|
const SUPPORTED_VERSION: u8 = 2;
|
||||||
|
|
||||||
/// Index of the version byte within [`MAGIC`].
|
/// Index of the version byte within [`MAGIC`].
|
||||||
const VERSION_BYTE: usize = 5;
|
const VERSION_BYTE: usize = 5;
|
||||||
@@ -47,6 +49,24 @@ pub struct M2tsMeta {
|
|||||||
pub duration: f64,
|
pub duration: f64,
|
||||||
/// Stream descriptors.
|
/// Stream descriptors.
|
||||||
pub streams: Vec<MetaStream>,
|
pub streams: Vec<MetaStream>,
|
||||||
|
/// Per-track decoder timing (Opus CodecDelay/SeekPreRoll); non-default only.
|
||||||
|
/// Readers that predate it ignore the field.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub timings: Vec<MetaTiming>,
|
||||||
|
/// Frames on the wire carry the DiscardPadding extension (header v2). Taken
|
||||||
|
/// from the version byte, not the JSON.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub frame_padding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoder timing for one stream index, carried in the FMKV header.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct MetaTiming {
|
||||||
|
pub track: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub codec_delay_ns: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seek_preroll_ns: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single stream descriptor in the metadata.
|
/// A single stream descriptor in the metadata.
|
||||||
@@ -165,9 +185,40 @@ impl M2tsMeta {
|
|||||||
title: title.playlist.clone(),
|
title: title.playlist.clone(),
|
||||||
duration: title.duration_secs,
|
duration: title.duration_secs,
|
||||||
streams,
|
streams,
|
||||||
|
timings: Vec::new(),
|
||||||
|
frame_padding: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach per-track decoder timing; any non-default timing switches the
|
||||||
|
/// stream to header v2 so DiscardPadding travels with each frame.
|
||||||
|
pub fn with_timings(mut self, timings: &[crate::pes::TrackTiming]) -> Self {
|
||||||
|
self.timings = timings
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, t)| **t != crate::pes::TrackTiming::default())
|
||||||
|
.map(|(track, t)| MetaTiming {
|
||||||
|
track,
|
||||||
|
codec_delay_ns: t.codec_delay_ns,
|
||||||
|
seek_preroll_ns: t.seek_preroll_ns,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.frame_padding = !self.timings.is_empty();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoder timing of stream `track` (default when the header has none).
|
||||||
|
pub fn timing(&self, track: usize) -> crate::pes::TrackTiming {
|
||||||
|
self.timings
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.track == track)
|
||||||
|
.map(|t| crate::pes::TrackTiming {
|
||||||
|
codec_delay_ns: t.codec_delay_ns,
|
||||||
|
seek_preroll_ns: t.seek_preroll_ns,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert back to a library Title (for remux).
|
/// Convert back to a library Title (for remux).
|
||||||
pub fn to_title(&self) -> DiscTitle {
|
pub fn to_title(&self) -> DiscTitle {
|
||||||
let streams = self
|
let streams = self
|
||||||
@@ -310,7 +361,11 @@ pub fn write_header(w: &mut impl Write, meta: &M2tsMeta) -> io::Result<()> {
|
|||||||
let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES;
|
let padded_len = raw_len.div_ceil(BD_SOURCE_PACKET_BYTES) * BD_SOURCE_PACKET_BYTES;
|
||||||
let padding = padded_len - raw_len;
|
let padding = padded_len - raw_len;
|
||||||
|
|
||||||
w.write_all(&MAGIC)?;
|
let mut magic = MAGIC;
|
||||||
|
if meta.frame_padding {
|
||||||
|
magic[VERSION_BYTE] = 2;
|
||||||
|
}
|
||||||
|
w.write_all(&magic)?;
|
||||||
w.write_all(&json_len.to_be_bytes())?;
|
w.write_all(&json_len.to_be_bytes())?;
|
||||||
w.write_all(&json)?;
|
w.write_all(&json)?;
|
||||||
if padding > 0 {
|
if padding > 0 {
|
||||||
@@ -367,8 +422,9 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
|
|||||||
let mut json_buf = vec![0u8; json_len];
|
let mut json_buf = vec![0u8; json_len];
|
||||||
r.read_exact(&mut json_buf)?;
|
r.read_exact(&mut json_buf)?;
|
||||||
|
|
||||||
let meta: M2tsMeta =
|
let mut meta: M2tsMeta =
|
||||||
serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?;
|
serde_json::from_slice(&json_buf).map_err(|_| crate::error::Error::NoMetadata)?;
|
||||||
|
meta.frame_padding = magic[VERSION_BYTE] >= 2;
|
||||||
|
|
||||||
// Skip padding to next 192-byte boundary (at most BD_SOURCE_PACKET_BYTES-1 bytes →
|
// Skip padding to next 192-byte boundary (at most BD_SOURCE_PACKET_BYTES-1 bytes →
|
||||||
// a stack buffer, no heap allocation).
|
// a stack buffer, no heap allocation).
|
||||||
@@ -625,7 +681,7 @@ mod tests {
|
|||||||
// The version byte lives at index 5. A regression that shifted the
|
// The version byte lives at index 5. A regression that shifted the
|
||||||
// version byte would make every header read the wrong version.
|
// version byte would make every header read the wrong version.
|
||||||
assert_eq!(&MAGIC[0..4], b"FMKV");
|
assert_eq!(&MAGIC[0..4], b"FMKV");
|
||||||
assert_eq!(MAGIC[VERSION_BYTE], SUPPORTED_VERSION);
|
assert_eq!(MAGIC[VERSION_BYTE], 1, "the base (v1) magic");
|
||||||
assert_eq!(VERSION_BYTE, 5);
|
assert_eq!(VERSION_BYTE, 5);
|
||||||
assert_eq!(MAGIC.len(), 8);
|
assert_eq!(MAGIC.len(), 8);
|
||||||
}
|
}
|
||||||
@@ -813,4 +869,23 @@ mod tests {
|
|||||||
assert_eq!(back.playlist, "The Movie");
|
assert_eq!(back.playlist, "The Movie");
|
||||||
assert_eq!(back.duration_secs, 7384.5);
|
assert_eq!(back.duration_secs, 7384.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timing switches the header to v2 (older readers refuse it cleanly); no
|
||||||
|
// timing keeps the v1 wire byte-identical.
|
||||||
|
#[test]
|
||||||
|
fn timing_round_trips_and_selects_the_header_version() {
|
||||||
|
let t = video_title(HdrFormat::Sdr, ColorSpace::Bt709);
|
||||||
|
let timing = crate::pes::TrackTiming {
|
||||||
|
codec_delay_ns: 1,
|
||||||
|
seek_preroll_ns: 2,
|
||||||
|
};
|
||||||
|
for (timings, version) in [(vec![], 1u8), (vec![timing], 2)] {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_header(&mut buf, &M2tsMeta::from_title(&t).with_timings(&timings)).unwrap();
|
||||||
|
assert_eq!(buf[VERSION_BYTE], version);
|
||||||
|
let back = read_header(&mut io::Cursor::new(&buf)).unwrap().unwrap();
|
||||||
|
assert_eq!(back.frame_padding, version == 2);
|
||||||
|
assert_eq!(back.timing(0), timings.first().copied().unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-5
@@ -144,6 +144,18 @@ pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
|
|||||||
doc
|
doc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every rate a stream carries, in Hz: a BD combo (48/96, 48/192) lists both,
|
||||||
|
// Unknown is empty.
|
||||||
|
fn sample_rates(rate: crate::disc::SampleRate) -> Vec<u32> {
|
||||||
|
use crate::disc::SampleRate::*;
|
||||||
|
match rate {
|
||||||
|
S48_96 => vec![48_000, 96_000],
|
||||||
|
S48_192 => vec![48_000, 192_000],
|
||||||
|
Unknown => Vec::new(),
|
||||||
|
r => vec![r.hz() as u32],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compact id for an audio stream's editorial purpose (no localized prose —
|
/// Compact id for an audio stream's editorial purpose (no localized prose —
|
||||||
/// the app maps it to display text). Mirrors the `Codec::id()` convention.
|
/// the app maps it to display text). Mirrors the `Codec::id()` convention.
|
||||||
fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str {
|
fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str {
|
||||||
@@ -157,6 +169,22 @@ fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `doc[key][i]` as a mutable object, or `dummy` (reset to an object) if absent.
|
||||||
|
fn entry<'a>(
|
||||||
|
doc: &'a mut serde_json::Value,
|
||||||
|
key: &str,
|
||||||
|
i: usize,
|
||||||
|
dummy: &'a mut serde_json::Value,
|
||||||
|
) -> &'a mut serde_json::Value {
|
||||||
|
match doc.get_mut(key).and_then(|v| v.get_mut(i)) {
|
||||||
|
Some(e) if e.is_object() => e,
|
||||||
|
_ => {
|
||||||
|
*dummy = serde_json::json!({});
|
||||||
|
dummy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge the per-stream fields `TitleProfile` drops back into the `json://`
|
/// Merge the per-stream fields `TitleProfile` drops back into the `json://`
|
||||||
/// document, straight from the source Title. Walks `title.streams` in declared
|
/// document, straight from the source Title. Walks `title.streams` in declared
|
||||||
/// order with a per-kind cursor so each raw stream lines up with the profile's
|
/// order with a per-kind cursor so each raw stream lines up with the profile's
|
||||||
@@ -165,10 +193,12 @@ fn enrich_streams(doc: &mut serde_json::Value, title: &DiscTitle) {
|
|||||||
use crate::disc::Stream;
|
use crate::disc::Stream;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
let (mut vi, mut ai, mut si) = (0usize, 0usize, 0usize);
|
let (mut vi, mut ai, mut si) = (0usize, 0usize, 0usize);
|
||||||
|
// `doc[key][i]` panics on a missing array; skip an entry the profile lacks.
|
||||||
|
let mut dummy = serde_json::Value::Null;
|
||||||
for s in &title.streams {
|
for s in &title.streams {
|
||||||
match s {
|
match s {
|
||||||
Stream::Video(v) => {
|
Stream::Video(v) => {
|
||||||
let e = &mut doc["video"][vi];
|
let e = entry(doc, "video", vi, &mut dummy);
|
||||||
e["pid"] = json!(v.pid);
|
e["pid"] = json!(v.pid);
|
||||||
e["color_space"] = json!(v.color_space.id());
|
e["color_space"] = json!(v.color_space.id());
|
||||||
e["display_aspect"] = match v.display_aspect {
|
e["display_aspect"] = match v.display_aspect {
|
||||||
@@ -199,15 +229,18 @@ fn enrich_streams(doc: &mut serde_json::Value, title: &DiscTitle) {
|
|||||||
vi += 1;
|
vi += 1;
|
||||||
}
|
}
|
||||||
Stream::Audio(a) => {
|
Stream::Audio(a) => {
|
||||||
let e = &mut doc["audio"][ai];
|
let e = entry(doc, "audio", ai, &mut dummy);
|
||||||
e["pid"] = json!(a.pid);
|
e["pid"] = json!(a.pid);
|
||||||
e["secondary"] = json!(a.secondary);
|
e["secondary"] = json!(a.secondary);
|
||||||
e["sample_rate"] = json!(a.sample_rate.to_string());
|
// Numeric Hz (null when unknown), not the "48kHz" display form.
|
||||||
|
let hz = a.sample_rate.hz() as u32;
|
||||||
|
e["sample_rate"] = if hz == 0 { json!(null) } else { json!(hz) };
|
||||||
|
e["sample_rates"] = json!(sample_rates(a.sample_rate));
|
||||||
e["purpose"] = json!(purpose_id(a.purpose));
|
e["purpose"] = json!(purpose_id(a.purpose));
|
||||||
ai += 1;
|
ai += 1;
|
||||||
}
|
}
|
||||||
Stream::Subtitle(sub) => {
|
Stream::Subtitle(sub) => {
|
||||||
let e = &mut doc["subtitles"][si];
|
let e = entry(doc, "subtitles", si, &mut dummy);
|
||||||
e["pid"] = json!(sub.pid);
|
e["pid"] = json!(sub.pid);
|
||||||
e["descriptive_service"] =
|
e["descriptive_service"] =
|
||||||
json!(sub.qualifier == crate::disc::LabelQualifier::DescriptiveService);
|
json!(sub.qualifier == crate::disc::LabelQualifier::DescriptiveService);
|
||||||
@@ -446,7 +479,8 @@ mod tests {
|
|||||||
let a = &v["audio"][0];
|
let a = &v["audio"][0];
|
||||||
assert_eq!(a["pid"], 0x1100);
|
assert_eq!(a["pid"], 0x1100);
|
||||||
assert_eq!(a["secondary"], true);
|
assert_eq!(a["secondary"], true);
|
||||||
assert_eq!(a["sample_rate"], "48kHz");
|
assert_eq!(a["sample_rate"], 48_000);
|
||||||
|
assert_eq!(a["sample_rates"], serde_json::json!([48_000]));
|
||||||
assert_eq!(a["purpose"], "commentary");
|
assert_eq!(a["purpose"], "commentary");
|
||||||
|
|
||||||
let s = &v["subtitles"][0];
|
let s = &v["subtitles"][0];
|
||||||
@@ -497,4 +531,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = std::fs::remove_file(&jpath);
|
let _ = std::fs::remove_file(&jpath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A document missing the per-kind arrays must not panic the enrichment.
|
||||||
|
#[test]
|
||||||
|
fn enriching_a_document_without_stream_arrays_does_not_panic() {
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![crate::disc::Stream::Audio(crate::disc::AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: crate::disc::Codec::Ac3,
|
||||||
|
channels: crate::disc::AudioChannels::Stereo,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: crate::disc::SampleRate::Unknown,
|
||||||
|
secondary: false,
|
||||||
|
purpose: crate::disc::LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
})];
|
||||||
|
let mut doc = serde_json::json!({});
|
||||||
|
enrich_streams(&mut doc, &t);
|
||||||
|
assert_eq!(
|
||||||
|
title_json(&t)["audio"][0]["sample_rate"],
|
||||||
|
serde_json::Value::Null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combo_sample_rates_keep_both_rates() {
|
||||||
|
use crate::disc::SampleRate;
|
||||||
|
assert_eq!(sample_rates(SampleRate::S48_96), vec![48_000, 96_000]);
|
||||||
|
assert_eq!(sample_rates(SampleRate::S48_192), vec![48_000, 192_000]);
|
||||||
|
assert!(sample_rates(SampleRate::Unknown).is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-10
@@ -548,6 +548,9 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
/// track type (mirroring `track_is_video`) so the invariant holds for every
|
/// track type (mirroring `track_is_video`) so the invariant holds for every
|
||||||
/// subtitle track — including a sparse forced-narrative second track.
|
/// subtitle track — including a sparse forced-narrative second track.
|
||||||
track_is_subtitle: Vec<bool>,
|
track_is_subtitle: Vec<bool>,
|
||||||
|
/// Per-track-index flag: true for an `S_VOBSUB` track, whose SPU carries its
|
||||||
|
/// own display duration.
|
||||||
|
track_is_vobsub: Vec<bool>,
|
||||||
/// Index of the PRIMARY video track — the first track whose type is video.
|
/// Index of the PRIMARY video track — the first track whose type is video.
|
||||||
/// This (not the literal index 0) is the clip-boundary epoch driver: the
|
/// This (not the literal index 0) is the clip-boundary epoch driver: the
|
||||||
/// M2TS/PMT path orders streams by PMT declaration order and may list an
|
/// M2TS/PMT path orders streams by PMT declaration order and may list an
|
||||||
@@ -570,6 +573,11 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
/// `finish()` via `frame_count == 0`, not this counter; a PARTIAL drop is normal and is
|
/// `finish()` via `frame_count == 0`, not this counter; a PARTIAL drop is normal and is
|
||||||
/// only logged, not an error — `finish()` is this field's only reader, so keep that log.
|
/// only logged, not an error — `finish()` is this field's only reader, so keep that log.
|
||||||
dropped_pre_cluster: u64,
|
dropped_pre_cluster: u64,
|
||||||
|
/// Ticks the timeline origin sits before the first video keyframe.
|
||||||
|
origin_lead_ticks: i64,
|
||||||
|
/// Per VobSub track: file offset of an open-ended block's 3-byte
|
||||||
|
/// BlockDuration value, and that block's timestamp in ticks.
|
||||||
|
vobsub_open_end: std::collections::HashMap<usize, (u64, i64)>,
|
||||||
seek_fixups: Vec<SeekPositionFixup>,
|
seek_fixups: Vec<SeekPositionFixup>,
|
||||||
/// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek
|
/// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek
|
||||||
/// element). When `finish()` writes no Cues element (zero cue points), this
|
/// element). When `finish()` writes no Cues element (zero cue points), this
|
||||||
@@ -705,10 +713,48 @@ struct InterlacedRewrite {
|
|||||||
// AUs.
|
// AUs.
|
||||||
const TIMESTAMP_SCALE_NS: i64 = 100_000;
|
const TIMESTAMP_SCALE_NS: i64 = 100_000;
|
||||||
|
|
||||||
// Last-resort BlockDuration (1 s) for a SUBTITLE frame that reaches the muxer with
|
// BlockDuration for a durationless PGS block (issue #52): a PGS cue ends at the
|
||||||
// no duration. Parsers (PGS) already synthesize one; this only fires if another
|
// next display set, so a long bound only caps a cue that is already replaced.
|
||||||
// subtitle path forgets, keeping every subtitle block durated (issue #52).
|
const PGS_FALLBACK_BLOCK_DURATION_NS: i64 = 30_000_000_000;
|
||||||
const MIN_SUBTITLE_BLOCK_DURATION_NS: i64 = 1_000_000_000;
|
|
||||||
|
// A VobSub SPU with no stop command ends at the next SPU on its track (patched
|
||||||
|
// in place), or after this cap. 100_000 ticks keeps the field 3 bytes wide.
|
||||||
|
const VOBSUB_OPEN_END_TICKS: u64 = 100_000;
|
||||||
|
|
||||||
|
// VobSub SP_DCSQ delays count 1024 ticks of the 90 kHz clock.
|
||||||
|
const SPU_DELAY_NS: u64 = 1024 * 1_000_000_000 / 90_000;
|
||||||
|
|
||||||
|
// Display time of a VobSub SPU: the delay of its first STP_DSP (0x02) command,
|
||||||
|
// walking the SP_DCSQ chain. `None` when the SPU has no stop or is malformed.
|
||||||
|
fn vobsub_display_ns(spu: &[u8]) -> Option<u64> {
|
||||||
|
let be16 = |at: usize| -> Option<usize> {
|
||||||
|
Some(u16::from_be_bytes([*spu.get(at)?, *spu.get(at + 1)?]) as usize)
|
||||||
|
};
|
||||||
|
let mut dcsq = be16(2)?;
|
||||||
|
// Each DCSQ is at least 5 bytes, so a well-formed chain has bounded length.
|
||||||
|
for _ in 0..spu.len() / 5 + 1 {
|
||||||
|
let delay = be16(dcsq)? as u64;
|
||||||
|
let next = be16(dcsq + 2)?;
|
||||||
|
let mut at = dcsq + 4;
|
||||||
|
loop {
|
||||||
|
let args = match *spu.get(at)? {
|
||||||
|
0x02 => return Some(delay * SPU_DELAY_NS),
|
||||||
|
0x00 | 0x01 => 0,
|
||||||
|
0x03 | 0x04 => 2,
|
||||||
|
0x05 => 6,
|
||||||
|
0x06 => 4,
|
||||||
|
0x07 => be16(at + 1)?,
|
||||||
|
_ => break,
|
||||||
|
};
|
||||||
|
at += 1 + args;
|
||||||
|
}
|
||||||
|
if next == dcsq {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
dcsq = next;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe only opens a new
|
// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe only opens a new
|
||||||
// cluster once this much has elapsed.
|
// cluster once this much has elapsed.
|
||||||
@@ -898,10 +944,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||||
if let Some(timing) = timings.get(i) {
|
if let Some(timing) = timings.get(i) {
|
||||||
if timing.codec_delay_ns > 0 {
|
if timing.codec_delay_ns > 0 {
|
||||||
ebml::write_uint(&mut writer, 0x56AA, timing.codec_delay_ns)?;
|
ebml::write_uint(&mut writer, ebml::CODEC_DELAY, timing.codec_delay_ns)?;
|
||||||
}
|
}
|
||||||
if timing.seek_preroll_ns > 0 {
|
if timing.seek_preroll_ns > 0 {
|
||||||
ebml::write_uint(&mut writer, 0x56BB, timing.seek_preroll_ns)?;
|
ebml::write_uint(&mut writer, ebml::SEEK_PRE_ROLL, timing.seek_preroll_ns)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,6 +1213,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.track_type == ebml::TRACK_TYPE_SUBTITLE)
|
.map(|t| t.track_type == ebml::TRACK_TYPE_SUBTITLE)
|
||||||
.collect(),
|
.collect(),
|
||||||
|
track_is_vobsub: tracks
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.codec_id == ebml::CODEC_VOBSUB)
|
||||||
|
.collect(),
|
||||||
primary_video_track: tracks
|
primary_video_track: tracks
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||||
@@ -1175,6 +1225,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
cues: Vec::new(),
|
cues: Vec::new(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
dropped_pre_cluster: 0,
|
dropped_pre_cluster: 0,
|
||||||
|
origin_lead_ticks: 0,
|
||||||
|
vobsub_open_end: std::collections::HashMap::new(),
|
||||||
seek_fixups,
|
seek_fixups,
|
||||||
cues_seek_entry_pos,
|
cues_seek_entry_pos,
|
||||||
info_offset,
|
info_offset,
|
||||||
@@ -1225,6 +1277,22 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overwrite a 3-byte big-endian value at `pos`, then return to the end.
|
||||||
|
fn patch_u24(&mut self, pos: u64, val: u64) -> io::Result<()> {
|
||||||
|
let end = self.writer.stream_position()?;
|
||||||
|
self.writer.seek(io::SeekFrom::Start(pos))?;
|
||||||
|
self.writer.write_all(&val.to_be_bytes()[5..])?;
|
||||||
|
self.writer.seek(io::SeekFrom::Start(end))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put the timeline origin `lead_ns` before the first video keyframe (the
|
||||||
|
/// earliest audio/video frame), and count `dropped` frames that lay before it.
|
||||||
|
pub(crate) fn set_origin_lead_ns(&mut self, lead_ns: i64, dropped: u64) {
|
||||||
|
self.origin_lead_ticks = lead_ns.max(0) / TIMESTAMP_SCALE_NS;
|
||||||
|
self.dropped_pre_cluster += dropped;
|
||||||
|
}
|
||||||
|
|
||||||
/// Attach an opening-frame capture (`--log-level 3`). The capture writes the
|
/// Attach an opening-frame capture (`--log-level 3`). The capture writes the
|
||||||
/// first ~100 coded frames per track to `<output>.opening.bin` and logs a
|
/// first ~100 coded frames per track to `<output>.opening.bin` and logs a
|
||||||
/// per-frame summary, so opening-GOP / menu issues are diagnosable from a
|
/// per-frame summary, so opening-GOP / menu issues are diagnosable from a
|
||||||
@@ -1379,8 +1447,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
self.dropped_pre_cluster += 1;
|
self.dropped_pre_cluster += 1;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.base_pts_ticks = Some(raw_ticks);
|
let base = raw_ticks - self.origin_lead_ticks;
|
||||||
raw_ticks
|
self.base_pts_ticks = Some(base);
|
||||||
|
base
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Floor at 0: a frame earlier than base (pre-keyframe audio, or a stream
|
// Floor at 0: a frame earlier than base (pre-keyframe audio, or a stream
|
||||||
@@ -1452,10 +1521,27 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
.get(track_idx)
|
.get(track_idx)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let is_vobsub = self
|
||||||
|
.track_is_vobsub
|
||||||
|
.get(track_idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_vobsub && let Some((pos, start)) = self.vobsub_open_end.remove(&track_idx) {
|
||||||
|
let ticks = ((pts_ticks - start).max(1) as u64).min(VOBSUB_OPEN_END_TICKS);
|
||||||
|
self.patch_u24(pos, ticks)?;
|
||||||
|
}
|
||||||
|
let mut vobsub_open = false;
|
||||||
let duration_ticks = match duration_ticks {
|
let duration_ticks = match duration_ticks {
|
||||||
Some(dt) => Some(dt),
|
Some(dt) => Some(dt),
|
||||||
|
None if is_vobsub => match vobsub_display_ns(data).filter(|&ns| ns > 0) {
|
||||||
|
Some(ns) => Some((ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64),
|
||||||
|
None => {
|
||||||
|
vobsub_open = true;
|
||||||
|
Some(VOBSUB_OPEN_END_TICKS)
|
||||||
|
}
|
||||||
|
},
|
||||||
None if is_subtitle => {
|
None if is_subtitle => {
|
||||||
Some((MIN_SUBTITLE_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS).max(1) as u64)
|
Some((PGS_FALLBACK_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS) as u64)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -1533,6 +1619,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// BlockDuration is the group's last element: its 3 value bytes end the write.
|
||||||
|
if vobsub_open {
|
||||||
|
let end = self.writer.stream_position()?;
|
||||||
|
self.vobsub_open_end.insert(track_idx, (end - 3, pts_ticks));
|
||||||
|
}
|
||||||
// Recorded per track (not a single global slot) so a later non-keyframe
|
// Recorded per track (not a single global slot) so a later non-keyframe
|
||||||
// references a keyframe on its OWN track — a shared slot produced cross-track
|
// references a keyframe on its OWN track — a shared slot produced cross-track
|
||||||
// references on a multi-video-track title (MVC base+EL, or a two-angle disc).
|
// references on a multi-video-track title (MVC base+EL, or a two-angle disc).
|
||||||
@@ -1983,7 +2074,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
buf.push(flags);
|
buf.push(flags);
|
||||||
buf.extend_from_slice(data);
|
buf.extend_from_slice(data);
|
||||||
if discard_padding_ns != 0 {
|
if discard_padding_ns != 0 {
|
||||||
ebml::write_int(buf, 0x75A2, discard_padding_ns)?;
|
ebml::write_int(buf, ebml::DISCARD_PADDING, discard_padding_ns)?;
|
||||||
}
|
}
|
||||||
if let Some(dt) = duration_ticks {
|
if let Some(dt) = duration_ticks {
|
||||||
ebml::write_uint(buf, ebml::BLOCK_DURATION, dt)?;
|
ebml::write_uint(buf, ebml::BLOCK_DURATION, dt)?;
|
||||||
@@ -5072,6 +5163,63 @@ mod tests {
|
|||||||
assert!(groups.iter().any(|g| g.track == 2), "sparse track present");
|
assert!(groups.iter().any(|g| g.track == 2), "sparse track present");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A VobSub SPU: 4-byte header, 2 pixel bytes, then a STA_DSP DCSQ (delay 0)
|
||||||
|
// chained to a STP_DSP DCSQ at `stop_delay` (units of 1024/90000 s).
|
||||||
|
fn vobsub_spu(stop_delay: u16) -> Vec<u8> {
|
||||||
|
let mut spu = vec![0, 18, 0, 6, 0xAB, 0xCD];
|
||||||
|
spu.extend_from_slice(&[0, 0, 0, 12, 0x01, 0xFF]);
|
||||||
|
spu.extend_from_slice(&stop_delay.to_be_bytes());
|
||||||
|
spu.extend_from_slice(&[0, 12, 0x02, 0xFF]);
|
||||||
|
spu
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vobsub_block_duration_comes_from_the_spu_stop_command() {
|
||||||
|
let mut track = make_subtitle_track();
|
||||||
|
track.codec_id = ebml::CODEC_VOBSUB;
|
||||||
|
// 0x0100 * 1024 / 90 kHz = 2.9127 s -> 29_127 ticks at 0.1 ms.
|
||||||
|
let data = mux_with_durations(&[track], &[(0, 0, true, vobsub_spu(0x0100), None)]);
|
||||||
|
let groups = all_block_groups(&data);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
assert_eq!(groups[0].duration, 29_127, "SPU stop time, not a 1 s guess");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A VobSub SPU with no stop command ends at the next SPU on its track, else
|
||||||
|
// after a 10 s cap.
|
||||||
|
#[test]
|
||||||
|
fn vobsub_without_a_stop_ends_at_the_next_spu_or_the_cap() {
|
||||||
|
let mut track = make_subtitle_track();
|
||||||
|
track.codec_id = ebml::CODEC_VOBSUB;
|
||||||
|
// One DCSQ at offset 6: delay 0, next = itself, STA_DSP, CMD_END.
|
||||||
|
let spu = vec![0, 12, 0, 6, 0xAB, 0xCD, 0, 0, 0, 6, 0x01, 0xFF];
|
||||||
|
let data = mux_with_durations(
|
||||||
|
&[track],
|
||||||
|
&[
|
||||||
|
(0, 0, true, spu.clone(), None),
|
||||||
|
(0, 2_000_000_000, true, spu, None),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let groups = all_block_groups(&data);
|
||||||
|
assert_eq!(groups.len(), 2);
|
||||||
|
assert_eq!(groups[0].duration, 20_000, "ends at the next SPU");
|
||||||
|
assert_eq!(groups[1].duration, 100_000, "last one capped at 10 s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_durationless_pgs_block_is_not_cut_to_one_second() {
|
||||||
|
let data = mux_with_durations(
|
||||||
|
&[make_subtitle_track()],
|
||||||
|
&[(0, 0, true, vec![0x16, 0xAA], None)],
|
||||||
|
);
|
||||||
|
let groups = all_block_groups(&data);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
assert!(
|
||||||
|
groups[0].duration > 10_000,
|
||||||
|
"a self-terminating cue must not be hidden after 1 s (got {} ticks)",
|
||||||
|
groups[0].duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video
|
// Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video
|
||||||
// keyframe once >= the cluster duration has elapsed since the open cluster's
|
// keyframe once >= the cluster duration has elapsed since the open cluster's
|
||||||
// timestamp; one just under the boundary stays in the current cluster.
|
// timestamp; one just under the boundary stays in the current cluster.
|
||||||
|
|||||||
+995
-91
File diff suppressed because it is too large
Load Diff
+91
-18
@@ -349,7 +349,7 @@ fn dts_channel_layout(amode: usize, lfe: bool) -> u16 {
|
|||||||
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
|
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
|
||||||
/// Describes the DTS core; whole access units (core + any extension) are passed
|
/// Describes the DTS core; whole access units (core + any extension) are passed
|
||||||
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
|
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
|
||||||
fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
fn ddts_box(c: &DtsConfig, max_rate: u32) -> Vec<u8> {
|
||||||
// avg/max bitrate: derived from core frame size × frame rate (core RATE reads
|
// avg/max bitrate: derived from core frame size × frame rate (core RATE reads
|
||||||
// "open/variable" for lossless, so unusable directly). Rate isn't integral
|
// "open/variable" for lossless, so unusable directly). Rate isn't integral
|
||||||
// (e.g. 93.75 frames/s), so multiply before dividing, rounding to nearest.
|
// (e.g. 93.75 frames/s), so multiply before dividing, rounding to nearest.
|
||||||
@@ -362,14 +362,18 @@ fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency
|
out.extend_from_slice(&max_rate.to_be_bytes()); // DTSSamplingFrequency
|
||||||
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
|
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
|
||||||
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
|
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
|
||||||
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
|
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
|
||||||
|
// FrameDuration counts samples at DTSSamplingFrequency, not the core rate.
|
||||||
|
let at_max = (u64::from(c.frame_samples) * u64::from(max_rate))
|
||||||
|
.checked_div(u64::from(c.sample_rate))
|
||||||
|
.unwrap_or(u64::from(c.frame_samples));
|
||||||
// Bit-packed tail (56 bits): FrameDuration2 StreamConstruction5 CoreLFEPresent1
|
// Bit-packed tail (56 bits): FrameDuration2 StreamConstruction5 CoreLFEPresent1
|
||||||
// CoreLayout6 CoreSize14 StereoDownmix1 RepresentationType3 ChannelLayout16
|
// CoreLayout6 CoreSize14 StereoDownmix1 RepresentationType3 ChannelLayout16
|
||||||
// MultiAssetFlag1 LBRDurationMod1 ReservedBoxPresent1 Reserved5
|
// MultiAssetFlag1 LBRDurationMod1 ReservedBoxPresent1 Reserved5
|
||||||
let frame_duration = match c.frame_samples {
|
let frame_duration = match at_max {
|
||||||
0..=512 => 0,
|
0..=512 => 0,
|
||||||
513..=1024 => 1,
|
513..=1024 => 1,
|
||||||
1025..=2048 => 2,
|
1025..=2048 => 2,
|
||||||
@@ -404,10 +408,26 @@ fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
|||||||
bx(b"ddts", &out)
|
bx(b"ddts", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DTS sample-entry samplerate: the family base of the maximum rate (ETSI TS 102 114
|
||||||
|
// E.2.2.2); the 4x core rates join their family per Table 5-6.
|
||||||
|
fn dts_entry_rate(max_rate: u32) -> u32 {
|
||||||
|
match max_rate {
|
||||||
|
12_000 | 24_000 | 48_000 | 96_000 | 192_000 => 48_000,
|
||||||
|
11_025 | 22_050 | 44_100 | 88_200 | 176_400 => 44_100,
|
||||||
|
8_000 | 16_000 | 32_000 | 64_000 | 128_000 => 32_000,
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
|
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
|
||||||
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
|
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
|
||||||
/// audio: only what returns `Some` is muxable.
|
/// audio: only what returns `Some` is muxable.
|
||||||
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
|
/// `stream_hz` is the title's rate for the track (0 if unknown).
|
||||||
|
pub(super) fn dolby_sample_entry(
|
||||||
|
codec: Codec,
|
||||||
|
first_frame: &[u8],
|
||||||
|
stream_hz: u32,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Ac3 => {
|
Codec::Ac3 => {
|
||||||
let c = parse_dolby(first_frame)?;
|
let c = parse_dolby(first_frame)?;
|
||||||
@@ -439,11 +459,17 @@ pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec
|
|||||||
let c = parse_dts(first_frame)?;
|
let c = parse_dts(first_frame)?;
|
||||||
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
|
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
|
||||||
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
|
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
|
||||||
|
// The core may run at a divisor of the stream rate (48 kHz core in 96 kHz MA).
|
||||||
|
let max_rate = if stream_hz > c.sample_rate && stream_hz.is_multiple_of(c.sample_rate) {
|
||||||
|
stream_hz
|
||||||
|
} else {
|
||||||
|
c.sample_rate
|
||||||
|
};
|
||||||
Some(audio_sample_entry(
|
Some(audio_sample_entry(
|
||||||
fourcc,
|
fourcc,
|
||||||
c.channels,
|
c.channels,
|
||||||
c.sample_rate,
|
dts_entry_rate(max_rate),
|
||||||
&ddts_box(&c),
|
&ddts_box(&c, max_rate),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -549,7 +575,7 @@ mod tests {
|
|||||||
// A Codec::Ac3Plus track whose first syncframe is legacy AC-3 (bsid 8 < 11)
|
// A Codec::Ac3Plus track whose first syncframe is legacy AC-3 (bsid 8 < 11)
|
||||||
// has no nominal data rate; an `ec-3` entry built from it would declare
|
// has no nominal data rate; an `ec-3` entry built from it would declare
|
||||||
// 0 kbps, violating ETSI TS 102 366 F.6.1 — emit ac-3/dac3 (Annex F.4) instead.
|
// 0 kbps, violating ETSI TS 102 366 F.6.1 — emit ac-3/dac3 (Annex F.4) instead.
|
||||||
let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1()).expect("entry built");
|
let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1(), 0).expect("entry built");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&e[4..8],
|
&e[4..8],
|
||||||
b"ac-3",
|
b"ac-3",
|
||||||
@@ -570,7 +596,7 @@ mod tests {
|
|||||||
// A real Annex-E syncframe still gets `ec-3` + `dec3`, and the data_rate
|
// A real Annex-E syncframe still gets `ec-3` + `dec3`, and the data_rate
|
||||||
// read back out of the emitted box is the frame's nominal rate, not 0.
|
// read back out of the emitted box is the frame's nominal rate, not 0.
|
||||||
// 128 B / 1536 samples @ 48 kHz = 32 kbit/s.
|
// 128 B / 1536 samples @ 48 kHz = 32 kbit/s.
|
||||||
let e = dolby_sample_entry(Codec::Ac3Plus, &eac3_frame_5_1()).expect("entry built");
|
let e = dolby_sample_entry(Codec::Ac3Plus, &eac3_frame_5_1(), 0).expect("entry built");
|
||||||
assert_eq!(&e[4..8], b"ec-3");
|
assert_eq!(&e[4..8], b"ec-3");
|
||||||
let i = e
|
let i = e
|
||||||
.windows(4)
|
.windows(4)
|
||||||
@@ -642,7 +668,7 @@ mod tests {
|
|||||||
assert_eq!(c.core_size, 96);
|
assert_eq!(c.core_size, 96);
|
||||||
assert_eq!(c.frame_samples, 512);
|
assert_eq!(c.frame_samples, 512);
|
||||||
|
|
||||||
let ddts = ddts_box(&c);
|
let ddts = ddts_box(&c, c.sample_rate);
|
||||||
assert_eq!(&ddts[4..8], b"ddts");
|
assert_eq!(&ddts[4..8], b"ddts");
|
||||||
// DTSSamplingFrequency (first field) = 48000.
|
// DTSSamplingFrequency (first field) = 48000.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -650,10 +676,57 @@ mod tests {
|
|||||||
48_000
|
48_000
|
||||||
);
|
);
|
||||||
// Sample entry uses dtsc (no extension in this synthetic frame).
|
// Sample entry uses dtsc (no extension in this synthetic frame).
|
||||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap();
|
||||||
assert_eq!(&e[4..8], b"dtsc");
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ETSI TS 102 114 E.2.2: a 48 kHz core in a 96 kHz stream → entry 48000, ddts 96000.
|
||||||
|
#[test]
|
||||||
|
fn a_96k_dts_stream_writes_its_max_rate_in_ddts_and_the_family_base_in_the_entry() {
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f, 96_000).unwrap();
|
||||||
|
assert_eq!(&e[32..34], &48_000u16.to_be_bytes(), "entry samplerate");
|
||||||
|
let ddts = e.windows(4).position(|w| w == b"ddts").unwrap();
|
||||||
|
assert_eq!(&e[ddts + 4..ddts + 8], &96_000u32.to_be_bytes());
|
||||||
|
// FrameDuration counts samples at DTSSamplingFrequency: 512 core samples
|
||||||
|
// at 48 kHz are 1024 at 96 kHz → code 1.
|
||||||
|
let c = parse_dts(&f).unwrap();
|
||||||
|
assert_eq!(ddts_tail(&ddts_box(&c, 96_000))[0] >> 6, 1);
|
||||||
|
assert_eq!(ddts_tail(&ddts_box(&c, 48_000))[0] >> 6, 0);
|
||||||
|
// A title rate that is not a multiple of the core rate is ignored.
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f, 44_100).unwrap();
|
||||||
|
let ddts = e.windows(4).position(|w| w == b"ddts").unwrap();
|
||||||
|
assert_eq!(&e[ddts + 4..ddts + 8], &48_000u32.to_be_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETSI TS 102 114 E.2.2.2 families, with the 4x-interpolated core rates of
|
||||||
|
// Table 5-6 (8/11.025/12 kHz) in the same family as their 2x siblings.
|
||||||
|
#[test]
|
||||||
|
fn dts_entry_rate_maps_every_rate_to_its_family_base() {
|
||||||
|
for (rate, base) in [
|
||||||
|
(12_000, 48_000),
|
||||||
|
(24_000, 48_000),
|
||||||
|
(48_000, 48_000),
|
||||||
|
(96_000, 48_000),
|
||||||
|
(192_000, 48_000),
|
||||||
|
(11_025, 44_100),
|
||||||
|
(22_050, 44_100),
|
||||||
|
(44_100, 44_100),
|
||||||
|
(88_200, 44_100),
|
||||||
|
(176_400, 44_100),
|
||||||
|
(8_000, 32_000),
|
||||||
|
(16_000, 32_000),
|
||||||
|
(32_000, 32_000),
|
||||||
|
(64_000, 32_000),
|
||||||
|
(128_000, 32_000),
|
||||||
|
(36_000, 36_000),
|
||||||
|
] {
|
||||||
|
assert_eq!(dts_entry_rate(rate), base, "{rate}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dts_ext_sync_inside_core_is_not_a_false_positive() {
|
fn dts_ext_sync_inside_core_is_not_a_false_positive() {
|
||||||
// A 4-byte ext-sync pattern INSIDE the compressed core payload must NOT be
|
// A 4-byte ext-sync pattern INSIDE the compressed core payload must NOT be
|
||||||
@@ -665,7 +738,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let c = parse_dts(&f).expect("parses");
|
let c = parse_dts(&f).expect("parses");
|
||||||
assert!(!c.has_extension, "ext-sync inside core is not an extension");
|
assert!(!c.has_extension, "ext-sync inside core is not an extension");
|
||||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap();
|
||||||
assert_eq!(&e[4..8], b"dtsc");
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,7 +752,7 @@ mod tests {
|
|||||||
let c = parse_dts(&f).expect("parses");
|
let c = parse_dts(&f).expect("parses");
|
||||||
assert_eq!(c.core_size, 9);
|
assert_eq!(c.core_size, 9);
|
||||||
assert!(c.has_extension, "EXSS sync at core end is a real extension");
|
assert!(c.has_extension, "EXSS sync at core end is a real extension");
|
||||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f, 0).unwrap();
|
||||||
assert_eq!(&e[4..8], b"dtsh");
|
assert_eq!(&e[4..8], b"dtsh");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +796,7 @@ mod tests {
|
|||||||
has_extension: false,
|
has_extension: false,
|
||||||
channel_layout: dts_channel_layout(9, true),
|
channel_layout: dts_channel_layout(9, true),
|
||||||
};
|
};
|
||||||
let b = ddts_box(&c);
|
let b = ddts_box(&c, c.sample_rate);
|
||||||
// Decode CoreSize back out of the emitted box: 8-byte header + 13 bytes
|
// Decode CoreSize back out of the emitted box: 8-byte header + 13 bytes
|
||||||
// (freq/bitrates/depth) precede the 56-bit tail, where CoreSize sits after
|
// (freq/bitrates/depth) precede the 56-bit tail, where CoreSize sits after
|
||||||
// FrameDuration(2)+StreamConstruction(5)+CoreLFEPresent(1)+CoreLayout(6) = bits 14..28.
|
// FrameDuration(2)+StreamConstruction(5)+CoreLFEPresent(1)+CoreLayout(6) = bits 14..28.
|
||||||
@@ -752,7 +825,7 @@ mod tests {
|
|||||||
// Decode the flag from the emitted box: 8-byte header + 13 bytes precede the
|
// Decode the flag from the emitted box: 8-byte header + 13 bytes precede the
|
||||||
// 56-bit tail, where MultiAssetFlag is bit 48 — after FrameDuration+
|
// 56-bit tail, where MultiAssetFlag is bit 48 — after FrameDuration+
|
||||||
// StreamConstruction+CoreLFEPresent+CoreLayout+CoreSize+StereoDownmix+RepType+ChannelLayout — MSB of byte 7.
|
// StreamConstruction+CoreLFEPresent+CoreLayout+CoreSize+StereoDownmix+RepType+ChannelLayout — MSB of byte 7.
|
||||||
let b = ddts_box(&c);
|
let b = ddts_box(&c, c.sample_rate);
|
||||||
let tail = &b[8 + 13..];
|
let tail = &b[8 + 13..];
|
||||||
assert_eq!(tail.len(), 7, "56-bit packed tail");
|
assert_eq!(tail.len(), 7, "56-bit packed tail");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1114,7 +1187,7 @@ mod tests {
|
|||||||
!c.has_extension,
|
!c.has_extension,
|
||||||
"the pattern ends at the core's last byte, inside it"
|
"the pattern ends at the core's last byte, inside it"
|
||||||
);
|
);
|
||||||
let e = dolby_sample_entry(Codec::Dts, &f).expect("entry built");
|
let e = dolby_sample_entry(Codec::Dts, &f, 0).expect("entry built");
|
||||||
assert_eq!(&e[4..8], b"dtsc");
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1206,7 @@ mod tests {
|
|||||||
has_extension: false,
|
has_extension: false,
|
||||||
channel_layout: dts_channel_layout(9, true),
|
channel_layout: dts_channel_layout(9, true),
|
||||||
};
|
};
|
||||||
let (max, avg) = ddts_bitrates(&ddts_box(&c));
|
let (max, avg) = ddts_bitrates(&ddts_box(&c, c.sample_rate));
|
||||||
assert_eq!(max, 5513, "2822400/512 = 5512.5 → 5513");
|
assert_eq!(max, 5513, "2822400/512 = 5512.5 → 5513");
|
||||||
assert_eq!(avg, 5513, "both fields carry the same computed rate");
|
assert_eq!(avg, 5513, "both fields carry the same computed rate");
|
||||||
}
|
}
|
||||||
@@ -1152,7 +1225,7 @@ mod tests {
|
|||||||
has_extension: false,
|
has_extension: false,
|
||||||
channel_layout: dts_channel_layout(9, true),
|
channel_layout: dts_channel_layout(9, true),
|
||||||
};
|
};
|
||||||
assert_eq!(ddts_bitrates(&ddts_box(&c)), (0, 0));
|
assert_eq!(ddts_bitrates(&ddts_box(&c, c.sample_rate)), (0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1171,7 +1244,7 @@ mod tests {
|
|||||||
has_extension: false,
|
has_extension: false,
|
||||||
channel_layout: dts_channel_layout(9, true),
|
channel_layout: dts_channel_layout(9, true),
|
||||||
};
|
};
|
||||||
ddts_tail(&ddts_box(&c))[0] >> 6
|
ddts_tail(&ddts_box(&c, c.sample_rate))[0] >> 6
|
||||||
};
|
};
|
||||||
assert_eq!(dur(512), 0, "512 samples");
|
assert_eq!(dur(512), 0, "512 samples");
|
||||||
assert_eq!(dur(1024), 1, "1024 samples");
|
assert_eq!(dur(1024), 1, "1024 samples");
|
||||||
|
|||||||
+5
-1
@@ -428,7 +428,11 @@ impl<W: Write + Seek + Send> Stream for Mp4Sink<W> {
|
|||||||
// here used to lose audio silently — finish() now reports that case loudly.
|
// here used to lose audio silently — finish() now reports that case loudly.
|
||||||
if self.tracks[slot].media == Media::Audio
|
if self.tracks[slot].media == Media::Audio
|
||||||
&& self.tracks[slot].audio_entry.is_none()
|
&& self.tracks[slot].audio_entry.is_none()
|
||||||
&& let Some(entry) = audio::dolby_sample_entry(self.tracks[slot].codec, &frame.data)
|
&& let Some(entry) = audio::dolby_sample_entry(
|
||||||
|
self.tracks[slot].codec,
|
||||||
|
&frame.data,
|
||||||
|
self.tracks[slot].audio_timescale,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
self.tracks[slot].audio_entry = Some(entry);
|
self.tracks[slot].audio_entry = Some(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
+278
-31
@@ -135,6 +135,8 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
config,
|
config,
|
||||||
channels,
|
channels,
|
||||||
sample_rate: entry_sample_rate,
|
sample_rate: entry_sample_rate,
|
||||||
|
dts_max_rate,
|
||||||
|
dts_hd,
|
||||||
}) = parse_stsd(stsd)
|
}) = parse_stsd(stsd)
|
||||||
else {
|
else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -165,13 +167,10 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
// `as u8` (a crafted 256 would alias to 0/Mono).
|
// `as u8` (a crafted 256 would alias to 0/Mono).
|
||||||
channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8),
|
channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8),
|
||||||
language: language.clone().unwrap_or_else(|| "und".into()),
|
language: language.clone().unwrap_or_else(|| "und".into()),
|
||||||
// Prefer the AudioSampleEntry's own samplerate; the mdhd media
|
sample_rate: SampleRate::from_hz(
|
||||||
// timescale is only a fallback when the entry omits it (0).
|
dts_max_rate
|
||||||
sample_rate: SampleRate::from_hz(if entry_sample_rate != 0 {
|
.unwrap_or_else(|| audio_rate(entry_sample_rate, timescale, dts_hd)),
|
||||||
entry_sample_rate
|
),
|
||||||
} else {
|
|
||||||
timescale
|
|
||||||
}),
|
|
||||||
secondary: false,
|
secondary: false,
|
||||||
purpose: LabelPurpose::Normal,
|
purpose: LabelPurpose::Normal,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
@@ -679,6 +678,10 @@ struct StsdInfo {
|
|||||||
/// field (high 16 bits). `0` when absent/video — the caller then falls
|
/// field (high 16 bits). `0` when absent/video — the caller then falls
|
||||||
/// back to the mdhd media timescale.
|
/// back to the mdhd media timescale.
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
|
/// DTS `ddts` DTSSamplingFrequency when present and non-zero.
|
||||||
|
dts_max_rate: Option<u32>,
|
||||||
|
/// `Some(dtsh|dtsl)` for a DTS entry; `None` otherwise.
|
||||||
|
dts_hd: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// stsd → codec + dimensions + codec_private + channel count (first entry).
|
/// stsd → codec + dimensions + codec_private + channel count (first entry).
|
||||||
@@ -727,35 +730,84 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
|
|||||||
config,
|
config,
|
||||||
channels: 0,
|
channels: 0,
|
||||||
sample_rate: 0,
|
sample_rate: 0,
|
||||||
|
dts_max_rate: None,
|
||||||
|
dts_hd: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// AudioSampleEntry header is 28 bytes, then child boxes. AAC (mp4a) carries
|
// AudioSampleEntry (ISO/IEC 14496-12 §12.2.3): 28 bytes then children. Under
|
||||||
// its AudioSpecificConfig in an `esds` box — the MKV CodecPrivate for
|
// a v0 stsd, entry v1 is QuickTime (+16 bytes) and v2 has a float64 rate at
|
||||||
// A_AAC. AC-3/DTS are self-describing in-band (None).
|
// 32, u32 channels at 40, children at 64.
|
||||||
let channels = if body.len() >= 28 { be16(body, 16) } else { 2 };
|
let version = if b[0] == 0 && body.len() >= 28 {
|
||||||
// Real audio sample rate: AudioSampleEntry 16.16 samplerate field at body
|
be16(body, 8)
|
||||||
// offset 24 (ISO/IEC 14496-12 §12.2.3), integer rate = high 16 bits. The mdhd
|
|
||||||
// media timescale is only a fallback (USUALLY the rate, but need not be).
|
|
||||||
let sample_rate = if body.len() >= 28 {
|
|
||||||
be16(body, 24) as u32
|
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let config = if matches!(codec, Codec::Aac) && body.len() >= 28 {
|
let (channels, sample_rate, children) = match version {
|
||||||
find_box(&body[28..], b"esds").and_then(parse_esds_asc)
|
2 if body.len() >= 64 => {
|
||||||
|
let rate = f64::from_bits(u64::from_be_bytes(body[32..40].try_into().ok()?));
|
||||||
|
let rate = if rate.is_finite() && rate >= 1.0 && rate <= u32::MAX as f64 {
|
||||||
|
rate.round() as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let ch = be32(body, 40).min(u16::MAX as u32) as u16;
|
||||||
|
(ch, rate, 64)
|
||||||
|
}
|
||||||
|
// The 16.16 field's integer part; `audio_rate` falls back to mdhd.
|
||||||
|
1 if body.len() >= 44 => (be16(body, 16), be16(body, 24) as u32, 44),
|
||||||
|
_ if body.len() >= 28 => (be16(body, 16), be16(body, 24) as u32, 28),
|
||||||
|
_ => (2, 0, body.len()),
|
||||||
|
};
|
||||||
|
let config = if matches!(codec, Codec::Aac) {
|
||||||
|
find_box(&body[children..], b"esds").and_then(parse_esds_asc)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// ISO AudioSampleEntryV1 carries rates above 65535 in `srat`.
|
||||||
|
let sample_rate = find_box(&body[children..], b"srat")
|
||||||
|
.filter(|b| b.len() >= 8)
|
||||||
|
.map_or(sample_rate, |b| be32(b, 4));
|
||||||
|
// ETSI TS 102 114 E.2.2.3: DTSSamplingFrequency is the stream's maximum rate.
|
||||||
|
// An unnameable rate stays Unknown; the entry holds only its family base.
|
||||||
|
let dts_max_rate = match codec {
|
||||||
|
Codec::Dts => find_box(&body[children..], b"ddts")
|
||||||
|
.filter(|b| b.len() >= 4)
|
||||||
|
.map(|b| be32(b, 0))
|
||||||
|
.filter(|&hz| hz != 0),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
Some(StsdInfo {
|
Some(StsdInfo {
|
||||||
codec,
|
codec,
|
||||||
height: 0,
|
height: 0,
|
||||||
config,
|
config,
|
||||||
channels,
|
channels,
|
||||||
sample_rate,
|
sample_rate,
|
||||||
|
dts_max_rate,
|
||||||
|
dts_hd: (codec == Codec::Dts).then_some(matches!(&fourcc, b"dtsh" | b"dtsl")),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sample rate from the entry rate and the mdhd timescale. The entry wins when
|
||||||
|
// it is known; a DTS-HD entry (`dts_hd`) holds the family base of a 2x/4x rate.
|
||||||
|
fn audio_rate(entry: u32, timescale: u32, dts_hd: Option<bool>) -> u32 {
|
||||||
|
let known = |hz| SampleRate::from_hz(hz) != SampleRate::Unknown;
|
||||||
|
if matches!(entry, 0 | 1 | 0xFFFF) {
|
||||||
|
return timescale;
|
||||||
|
}
|
||||||
|
let is = |k: u32| entry.checked_mul(k) == Some(timescale) && known(timescale);
|
||||||
|
let wider = match dts_hd {
|
||||||
|
Some(hd) => hd && (is(2) || is(4)),
|
||||||
|
None => {
|
||||||
|
!known(entry)
|
||||||
|
&& timescale > 0xFFFF
|
||||||
|
&& known(timescale)
|
||||||
|
&& timescale.is_multiple_of(entry)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if wider { timescale } else { entry }
|
||||||
|
}
|
||||||
|
|
||||||
/// Read an MPEG-4 expandable descriptor length (ISO/IEC 14496-1), advancing `pos`.
|
/// Read an MPEG-4 expandable descriptor length (ISO/IEC 14496-1), advancing `pos`.
|
||||||
/// Each byte contributes 7 bits, continued while the high bit is set (max 4 bytes).
|
/// Each byte contributes 7 bits, continued while the high bit is set (max 4 bytes).
|
||||||
fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize {
|
fn read_descriptor_len(b: &[u8], pos: &mut usize) -> usize {
|
||||||
@@ -2251,13 +2303,12 @@ mod tests {
|
|||||||
/// (mdhd timescale = 90_000, AudioSampleEntry samplerate = 48_000); the
|
/// (mdhd timescale = 90_000, AudioSampleEntry samplerate = 48_000); the
|
||||||
/// entry value must win. Old code read the rate from the timescale, so a
|
/// entry value must win. Old code read the rate from the timescale, so a
|
||||||
/// 90 kHz mdhd yielded SampleRate::Unknown, dropping the real 48 kHz.
|
/// 90 kHz mdhd yielded SampleRate::Unknown, dropping the real 48 kHz.
|
||||||
#[test]
|
// One-sample audio moov whose mdhd timescale and sample entry are given.
|
||||||
fn audio_sample_entry_samplerate_wins_over_mdhd_timescale() {
|
fn audio_moov(timescale: u32, fourcc: &[u8; 4], entry: &[u8]) -> Vec<u8> {
|
||||||
use std::io::Cursor;
|
|
||||||
let mdhd = {
|
let mdhd = {
|
||||||
// v0: version+flags(4) creation(4) modification(4) timescale(4) duration(4).
|
// v0: version+flags(4) creation(4) modification(4) timescale(4) duration(4).
|
||||||
let mut p = vec![0u8; 24];
|
let mut p = vec![0u8; 24];
|
||||||
p[12..16].copy_from_slice(&90_000u32.to_be_bytes()); // NOT the audio rate
|
p[12..16].copy_from_slice(×cale.to_be_bytes());
|
||||||
mp4_box(b"mdhd", &p)
|
mp4_box(b"mdhd", &p)
|
||||||
};
|
};
|
||||||
let hdlr = {
|
let hdlr = {
|
||||||
@@ -2265,8 +2316,7 @@ mod tests {
|
|||||||
p[8..12].copy_from_slice(b"soun");
|
p[8..12].copy_from_slice(b"soun");
|
||||||
mp4_box(b"hdlr", &p)
|
mp4_box(b"hdlr", &p)
|
||||||
};
|
};
|
||||||
// Full AudioSampleEntry: samplerate 48_000 in the 16.16 field at body 24.
|
let stsd = mp4_box(b"stsd", &stsd_with(fourcc, entry));
|
||||||
let stsd = mp4_box(b"stsd", &stsd_with(b"ac-3", &audio_entry(6, 0, &[])));
|
|
||||||
// stsz: sample_size=10, count=1 (one 10-byte sample).
|
// stsz: sample_size=10, count=1 (one 10-byte sample).
|
||||||
let stsz = {
|
let stsz = {
|
||||||
let mut p = vec![0u8, 0, 0, 0];
|
let mut p = vec![0u8, 0, 0, 0];
|
||||||
@@ -2305,19 +2355,216 @@ mod tests {
|
|||||||
mdia.extend_from_slice(&hdlr);
|
mdia.extend_from_slice(&hdlr);
|
||||||
mdia.extend_from_slice(&minf);
|
mdia.extend_from_slice(&minf);
|
||||||
let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia));
|
let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia));
|
||||||
let moov = mp4_box(b"moov", &trak);
|
mp4_box(b"moov", &trak)
|
||||||
|
}
|
||||||
|
|
||||||
let rd = Mp4Reader::from_reader(Cursor::new(moov), "sr".into()).unwrap();
|
fn read_audio(moov: Vec<u8>) -> AudioStream {
|
||||||
|
let rd = Mp4Reader::from_reader(std::io::Cursor::new(moov), "sr".into()).unwrap();
|
||||||
match &rd.info().streams[0] {
|
match &rd.info().streams[0] {
|
||||||
DiscStream::Audio(a) => assert_eq!(
|
DiscStream::Audio(a) => a.clone(),
|
||||||
a.sample_rate,
|
|
||||||
SampleRate::S48,
|
|
||||||
"the AudioSampleEntry 48 kHz must win over the 90 kHz mdhd timescale"
|
|
||||||
),
|
|
||||||
other => panic!("expected an audio stream, got {other:?}"),
|
other => panic!("expected an audio stream, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: audio sample rate must come from the AudioSampleEntry 16.16
|
||||||
|
/// samplerate field, NOT the mdhd media timescale, when they merely differ
|
||||||
|
/// (a 90 kHz mdhd is not an audio rate).
|
||||||
|
#[test]
|
||||||
|
fn audio_sample_entry_samplerate_wins_over_mdhd_timescale() {
|
||||||
|
let a = read_audio(audio_moov(90_000, b"ac-3", &audio_entry(6, 0, &[])));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::S48);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 16-bit entry field cannot hold 96 kHz: our own Mp4Sink saturates it to
|
||||||
|
// 65535, and a DTS-HD entry may carry the 48 kHz core rate. mdhd wins then.
|
||||||
|
#[test]
|
||||||
|
fn a_high_rate_mdhd_timescale_beats_an_unrepresentable_entry_rate() {
|
||||||
|
for entry_rate in [0xFFFFu32, 48_000] {
|
||||||
|
let mut e = audio_entry(6, 0, &[]);
|
||||||
|
e[24..28].copy_from_slice(&(entry_rate << 16).to_be_bytes());
|
||||||
|
let a = read_audio(audio_moov(96_000, b"dtsh", &e));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::S96, "entry {entry_rate}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without ddts, only a DTS-HD entry takes a 2x/4x mdhd timescale; dtsc keeps its rate.
|
||||||
|
#[test]
|
||||||
|
fn a_dts_timescale_counts_only_as_2x_or_4x_on_dts_hd_entries() {
|
||||||
|
let entry = |hz: u32| {
|
||||||
|
let mut e = audio_entry(6, 0, &[]);
|
||||||
|
e[24..28].copy_from_slice(&(hz << 16).to_be_bytes());
|
||||||
|
e
|
||||||
|
};
|
||||||
|
for (fourcc, entry_hz, ts, want) in [
|
||||||
|
(b"dtsc", 48_000, 96_000, SampleRate::S48),
|
||||||
|
(b"dtsh", 48_000, 192_000, SampleRate::S192),
|
||||||
|
(b"dtsl", 44_100, 88_200, SampleRate::S88_2),
|
||||||
|
(b"dtsh", 32_000, 96_000, SampleRate::from_hz(32_000)),
|
||||||
|
] {
|
||||||
|
let a = read_audio(audio_moov(ts, fourcc, &entry(entry_hz)));
|
||||||
|
assert_eq!(a.sample_rate, want, "{fourcc:?} {entry_hz} {ts}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETSI TS 102 114 E.2.2.3: ddts DTSSamplingFrequency is the stream's maximum rate.
|
||||||
|
#[test]
|
||||||
|
fn a_dts_ddts_max_rate_beats_the_family_entry_rate() {
|
||||||
|
let mut ddts = 96_000u32.to_be_bytes().to_vec();
|
||||||
|
ddts.extend_from_slice(&[0u8; 15]);
|
||||||
|
let e = audio_entry(6, 0, &mp4_box(b"ddts", &ddts));
|
||||||
|
let a = read_audio(audio_moov(90_000, b"dtsh", &e));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::S96);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ddts rate SampleRate cannot name reads as Unknown, never the entry's family base.
|
||||||
|
#[test]
|
||||||
|
fn a_dts_ddts_rate_outside_sample_rate_is_not_read_as_its_family_base() {
|
||||||
|
for hz in [12_000u32, 24_000, 32_000] {
|
||||||
|
let mut ddts = hz.to_be_bytes().to_vec();
|
||||||
|
ddts.extend_from_slice(&[0u8; 15]);
|
||||||
|
// audio_entry's samplerate is 48000, the family base of 12/24 kHz.
|
||||||
|
let e = audio_entry(6, 0, &mp4_box(b"ddts", &ddts));
|
||||||
|
let a = read_audio(audio_moov(48_000, b"dtsc", &e));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::Unknown, "{hz}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 96 kHz DTS-HD MA track written by Mp4Sink reads back as 96 kHz.
|
||||||
|
#[test]
|
||||||
|
fn a_96k_dts_hd_track_round_trips_its_rate() {
|
||||||
|
use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle, LabelPurpose};
|
||||||
|
use crate::mux::mp4::Mp4Sink;
|
||||||
|
use crate::pes::{PesFrame, Stream as _};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![
|
||||||
|
DiscStream::Video(VideoStream {
|
||||||
|
pid: 0x1011,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: crate::disc::FrameRate::F23_976,
|
||||||
|
hdr: crate::disc::HdrFormat::Sdr,
|
||||||
|
color_space: crate::disc::ColorSpace::Unknown,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
}),
|
||||||
|
DiscStream::Audio(AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: Codec::DtsHdMa,
|
||||||
|
channels: AudioChannels::Surround51,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: SampleRate::S96,
|
||||||
|
secondary: false,
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC]), None];
|
||||||
|
// 48 kHz DTS core (SFREQ=13), as on a 96 kHz DTS-HD MA disc track.
|
||||||
|
let core = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap();
|
||||||
|
for i in 0..2i64 {
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
discard_padding_ns: 0,
|
||||||
|
track: 0,
|
||||||
|
pts: i * 41_708_333,
|
||||||
|
keyframe: i == 0,
|
||||||
|
data: vec![0x11; 64],
|
||||||
|
duration_ns: None,
|
||||||
|
source: None,
|
||||||
|
coding: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
discard_padding_ns: 0,
|
||||||
|
track: 1,
|
||||||
|
pts: i * 5_333_333,
|
||||||
|
keyframe: true,
|
||||||
|
data: core.clone(),
|
||||||
|
duration_ns: None,
|
||||||
|
source: None,
|
||||||
|
coding: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
sink.finish().unwrap();
|
||||||
|
}
|
||||||
|
let rd = Mp4Reader::from_reader(Cursor::new(buf), "dts96".into()).unwrap();
|
||||||
|
match &rd.info().streams[1] {
|
||||||
|
DiscStream::Audio(a) => assert_eq!(a.sample_rate, SampleRate::S96),
|
||||||
|
other => panic!("expected audio, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid known entry rate is kept even when mdhd uses a higher timescale.
|
||||||
|
#[test]
|
||||||
|
fn a_known_entry_rate_beats_a_higher_timescale() {
|
||||||
|
let a = read_audio(audio_moov(96_000, b"ac-3", &audio_entry(2, 0, &[])));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::S48);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO AudioSampleEntryV1 (stsd version 1): not a QuickTime layout; children
|
||||||
|
// follow the 28-byte part and `srat` carries a rate above 65535.
|
||||||
|
#[test]
|
||||||
|
fn an_iso_v1_sample_entry_reads_srat_and_keeps_its_children() {
|
||||||
|
let asc = [0x11u8, 0x90];
|
||||||
|
let mut esds = vec![0u8, 0, 0, 0, 0x03, 22, 0, 1, 0, 0x04, 17, 0x40, 0x15];
|
||||||
|
esds.extend_from_slice(&[0u8; 11]);
|
||||||
|
esds.extend_from_slice(&[0x05, 2]);
|
||||||
|
esds.extend_from_slice(&asc);
|
||||||
|
let mut srat = vec![0u8; 4];
|
||||||
|
srat.extend_from_slice(&96_000u32.to_be_bytes());
|
||||||
|
let mut e = audio_entry(2, 0, &[]);
|
||||||
|
e[8..10].copy_from_slice(&1u16.to_be_bytes());
|
||||||
|
e[24..28].copy_from_slice(&(1u32 << 16).to_be_bytes());
|
||||||
|
e.extend_from_slice(&mp4_box(b"esds", &esds));
|
||||||
|
e.extend_from_slice(&mp4_box(b"srat", &srat));
|
||||||
|
let mut stsd = stsd_with(b"mp4a", &e);
|
||||||
|
stsd[0] = 1;
|
||||||
|
let info = parse_stsd(&stsd).expect("mp4a parses");
|
||||||
|
assert_eq!(info.config.as_deref(), Some(&asc[..]));
|
||||||
|
assert_eq!(info.sample_rate, 96_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickTime SoundDescription v2: the 16.16 field is fixed at 1.0 and the real
|
||||||
|
// rate is a float64 at body offset 32; channels at 40; children at 64.
|
||||||
|
#[test]
|
||||||
|
fn a_quicktime_v2_sound_description_reads_its_float_rate_and_channels() {
|
||||||
|
let mut e = vec![0u8; 64];
|
||||||
|
e[8..10].copy_from_slice(&2u16.to_be_bytes());
|
||||||
|
e[16..18].copy_from_slice(&3u16.to_be_bytes());
|
||||||
|
e[24..28].copy_from_slice(&(1u32 << 16).to_be_bytes());
|
||||||
|
e[32..40].copy_from_slice(&44_100f64.to_bits().to_be_bytes());
|
||||||
|
e[40..44].copy_from_slice(&6u32.to_be_bytes());
|
||||||
|
let a = read_audio(audio_moov(1_000, b"ac-3", &e));
|
||||||
|
assert_eq!(a.sample_rate, SampleRate::S44_1);
|
||||||
|
assert_eq!(a.channels, AudioChannels::from_count(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickTime SoundDescription v1 inserts 16 bytes before the child boxes, so an
|
||||||
|
// mp4a esds must be looked up at body offset 44, not 28.
|
||||||
|
#[test]
|
||||||
|
fn a_quicktime_v1_sound_description_finds_its_esds_after_the_v1_fields() {
|
||||||
|
let asc = [0x12u8, 0x10];
|
||||||
|
let mut esds = vec![0u8, 0, 0, 0, 0x03, 22, 0, 1, 0, 0x04, 17, 0x40, 0x15];
|
||||||
|
esds.extend_from_slice(&[0u8; 11]);
|
||||||
|
esds.extend_from_slice(&[0x05, 2]);
|
||||||
|
esds.extend_from_slice(&asc);
|
||||||
|
let mut e = audio_entry(2, 0, &[]);
|
||||||
|
e[8..10].copy_from_slice(&1u16.to_be_bytes());
|
||||||
|
e.extend_from_slice(&[0u8; 16]);
|
||||||
|
e.extend_from_slice(&mp4_box(b"esds", &esds));
|
||||||
|
let info = parse_stsd(&stsd_with(b"mp4a", &e)).expect("mp4a parses");
|
||||||
|
assert_eq!(info.config.as_deref(), Some(&asc[..]));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Edit lists — ISO/IEC 14496-12 §8.6.5 (`edts`) / §8.6.6 (`elst`).
|
// Edit lists — ISO/IEC 14496-12 §8.6.5 (`edts`) / §8.6.6 (`elst`).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
+557
-18
@@ -8,12 +8,117 @@
|
|||||||
|
|
||||||
use super::meta;
|
use super::meta;
|
||||||
use crate::disc::DiscTitle;
|
use crate::disc::DiscTitle;
|
||||||
use std::io::{self, BufReader, BufWriter, Write};
|
use crate::halt::{Halt, POLL_INTERVAL};
|
||||||
|
use rustix::event::{PollFd, PollFlags, Timespec};
|
||||||
|
use std::io::{self, BufReader, BufWriter, Read, Write};
|
||||||
use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs};
|
use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs};
|
||||||
|
|
||||||
/// I/O buffer size for network reads/writes.
|
/// I/O buffer size for network reads/writes.
|
||||||
const NET_BUF_SIZE: usize = 256 * 1024;
|
const NET_BUF_SIZE: usize = 256 * 1024;
|
||||||
|
|
||||||
|
// Accept poll period while waiting for the sender (a halt is checked each tick).
|
||||||
|
const ACCEPT_POLL: std::time::Duration = std::time::Duration::from_millis(50);
|
||||||
|
|
||||||
|
// A slow sender (bad-sector retries) may be silent for minutes, so there is no
|
||||||
|
// data-idle limit; a vanished peer is detected by TCP keepalive instead.
|
||||||
|
const KEEPALIVE_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||||
|
const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||||
|
const KEEPALIVE_RETRIES: u32 = 6;
|
||||||
|
|
||||||
|
// Enable keepalive probes so a crashed or unplugged sender fails the read.
|
||||||
|
fn enable_keepalive(stream: &TcpStream) -> io::Result<()> {
|
||||||
|
let ka = socket2::TcpKeepalive::new().with_time(KEEPALIVE_IDLE);
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
|
||||||
|
let ka = ka
|
||||||
|
.with_interval(KEEPALIVE_INTERVAL)
|
||||||
|
.with_retries(KEEPALIVE_RETRIES);
|
||||||
|
socket2::SockRef::from(stream).set_tcp_keepalive(&ka)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keepalive is best effort: a stack that rejects an option (e.g. TCP_KEEPCNT on
|
||||||
|
// old Windows) still receives, just without dead-peer detection.
|
||||||
|
fn arm_keepalive(stream: &TcpStream) {
|
||||||
|
if let Err(e) = enable_keepalive(stream) {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
error = %e,
|
||||||
|
"network: TCP keepalive unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll outcome: `Ok(true)` reads now (data, EOF or a socket error for `read` to
|
||||||
|
// surface), `Ok(false)` is a halt-check tick. Readiness, not SO_RCVTIMEO: on
|
||||||
|
// Windows a receive timeout can cancel a completing recv and lose its data.
|
||||||
|
fn poll_says_read(res: io::Result<usize>, revents: PollFlags) -> io::Result<bool> {
|
||||||
|
match res {
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(false),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
Ok(0) => Ok(false),
|
||||||
|
Ok(_) => Ok(!revents.is_empty()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive side: waits for readability in `tick` slices so a halt is observed
|
||||||
|
// instead of blocking in `read` forever.
|
||||||
|
struct HaltRead {
|
||||||
|
stream: TcpStream,
|
||||||
|
halt: Option<Halt>,
|
||||||
|
halted: bool,
|
||||||
|
tick: Timespec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HaltRead {
|
||||||
|
fn new(stream: TcpStream, halt: Option<Halt>) -> Self {
|
||||||
|
Self::with_tick(stream, halt, POLL_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_tick(stream: TcpStream, halt: Option<Halt>, tick: std::time::Duration) -> Self {
|
||||||
|
let tick = Timespec {
|
||||||
|
tv_sec: tick.as_secs() as _,
|
||||||
|
tv_nsec: tick.subsec_nanos() as _,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
stream,
|
||||||
|
halt,
|
||||||
|
halted: false,
|
||||||
|
tick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `e`, or `Error::Halted` when the read was aborted by the halt.
|
||||||
|
fn halted_or(&self, e: io::Error) -> io::Error {
|
||||||
|
if self.halted {
|
||||||
|
crate::error::Error::Halted.into()
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for HaltRead {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
let Some(halt) = &self.halt else {
|
||||||
|
return self.stream.read(buf);
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
if halt.is_cancelled() {
|
||||||
|
// Halted maps to ErrorKind::Interrupted, which std's read_exact
|
||||||
|
// retries forever; abort with another kind and remap in `halted_or`.
|
||||||
|
self.halted = true;
|
||||||
|
return Err(io::ErrorKind::ConnectionAborted.into());
|
||||||
|
}
|
||||||
|
let mut fds = [PollFd::new(&self.stream, PollFlags::IN)];
|
||||||
|
let res = rustix::event::poll(&mut fds, Some(&self.tick)).map_err(io::Error::from);
|
||||||
|
if poll_says_read(res, fds[0].revents())? {
|
||||||
|
return self.stream.read(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// True if `ip` must never be a `network://` connect target (loopback, private, link-local,
|
// True if `ip` must never be a `network://` connect target (loopback, private, link-local,
|
||||||
// unspecified, multicast). Re-checked here at connect time to close a DNS-rebinding TOCTOU.
|
// unspecified, multicast). Re-checked here at connect time to close a DNS-rebinding TOCTOU.
|
||||||
pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
|
pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
|
||||||
@@ -94,9 +199,12 @@ enum Mode {
|
|||||||
Write {
|
Write {
|
||||||
writer: BufWriter<TcpStream>,
|
writer: BufWriter<TcpStream>,
|
||||||
header_written: bool,
|
header_written: bool,
|
||||||
|
timings: Vec<crate::pes::TrackTiming>,
|
||||||
|
padded: bool,
|
||||||
},
|
},
|
||||||
Read {
|
Read {
|
||||||
reader: BufReader<TcpStream>,
|
reader: BufReader<HaltRead>,
|
||||||
|
meta: meta::M2tsMeta,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +243,8 @@ impl NetworkStream {
|
|||||||
mode: Mode::Write {
|
mode: Mode::Write {
|
||||||
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
|
writer: BufWriter::with_capacity(NET_BUF_SIZE, stream),
|
||||||
header_written: false,
|
header_written: false,
|
||||||
|
timings: Vec::new(),
|
||||||
|
padded: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -158,7 +268,13 @@ impl NetworkStream {
|
|||||||
/// `accept`, so the bound port is freed and any subsequent connection
|
/// `accept`, so the bound port is freed and any subsequent connection
|
||||||
/// attempt to the same address is refused.
|
/// attempt to the same address is refused.
|
||||||
pub fn listen(addr: &str) -> io::Result<Self> {
|
pub fn listen(addr: &str) -> io::Result<Self> {
|
||||||
Self::accept_from(TcpListener::bind(addr)?)
|
Self::listen_with_halt(addr, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`listen`](Self::listen) that a [`Halt`] can interrupt, while waiting for
|
||||||
|
/// the sender to connect and while blocked on a stalled sender.
|
||||||
|
pub fn listen_with_halt(addr: &str, halt: Option<Halt>) -> io::Result<Self> {
|
||||||
|
Self::accept_from_with_halt(TcpListener::bind(addr)?, halt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept one connection from an already-bound listener and read from it.
|
/// Accept one connection from an already-bound listener and read from it.
|
||||||
@@ -166,33 +282,69 @@ impl NetworkStream {
|
|||||||
/// `:0` bind) and hand the listener in, closing the bind/drop/re-bind race
|
/// `:0` bind) and hand the listener in, closing the bind/drop/re-bind race
|
||||||
/// that `listen(addr)` would otherwise have.
|
/// that `listen(addr)` would otherwise have.
|
||||||
pub fn accept_from(listener: TcpListener) -> io::Result<Self> {
|
pub fn accept_from(listener: TcpListener) -> io::Result<Self> {
|
||||||
let (stream, _peer) = listener.accept()?;
|
Self::accept_from_with_halt(listener, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`accept_from`](Self::accept_from) that a [`Halt`] can interrupt.
|
||||||
|
pub fn accept_from_with_halt(listener: TcpListener, halt: Option<Halt>) -> io::Result<Self> {
|
||||||
|
let Some(h) = halt else {
|
||||||
|
let (stream, _peer) = listener.accept()?;
|
||||||
|
stream.set_nodelay(true)?;
|
||||||
|
arm_keepalive(&stream);
|
||||||
|
return Self::read_from(stream, None);
|
||||||
|
};
|
||||||
|
listener.set_nonblocking(true)?;
|
||||||
|
let halt = Some(h);
|
||||||
|
let stream = loop {
|
||||||
|
if halt.as_ref().is_some_and(Halt::is_cancelled) {
|
||||||
|
return Err(crate::error::Error::Halted.into());
|
||||||
|
}
|
||||||
|
match listener.accept() {
|
||||||
|
Ok((stream, _peer)) => break stream,
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(ACCEPT_POLL);
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Accepted sockets can inherit O_NONBLOCK (BSD/macOS); reads wait on readiness.
|
||||||
|
stream.set_nonblocking(false)?;
|
||||||
stream.set_nodelay(true)?;
|
stream.set_nodelay(true)?;
|
||||||
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, stream);
|
arm_keepalive(&stream);
|
||||||
|
Self::read_from(stream, halt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap an accepted connection and read its FMKV header.
|
||||||
|
fn read_from(stream: TcpStream, halt: Option<Halt>) -> io::Result<Self> {
|
||||||
|
let mut reader = BufReader::with_capacity(NET_BUF_SIZE, HaltRead::new(stream, halt));
|
||||||
|
|
||||||
// Read FMKV metadata header
|
// Read FMKV metadata header
|
||||||
let disc_title = meta::read_header(&mut reader)?
|
let meta = meta::read_header(&mut reader)
|
||||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?
|
.map_err(|e| reader.get_ref().halted_or(e))?
|
||||||
.to_title();
|
.ok_or_else(|| -> io::Error { crate::error::Error::NoMetadata.into() })?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
disc_title,
|
disc_title: meta.to_title(),
|
||||||
mode: Mode::Read { reader },
|
mode: Mode::Read { reader, meta },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the FMKV header exactly once, before any frames. Always writes
|
// Write the FMKV header once, before any frames, even for a zero-frame stream
|
||||||
// (even for a title with no streams) so read_header() finds the magic
|
// (else read_header() reports NoMetadata). Sets `padded` when frames carry the
|
||||||
// and doesn't fall into NoMetadata on a zero-frame stream.
|
// DiscardPadding extension (header v2).
|
||||||
fn ensure_header_written(
|
fn ensure_header_written(
|
||||||
writer: &mut BufWriter<TcpStream>,
|
writer: &mut BufWriter<TcpStream>,
|
||||||
header_written: &mut bool,
|
header_written: &mut bool,
|
||||||
disc_title: &DiscTitle,
|
disc_title: &DiscTitle,
|
||||||
|
timings: &[crate::pes::TrackTiming],
|
||||||
|
padded: &mut bool,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
if !*header_written {
|
if !*header_written {
|
||||||
let m = meta::M2tsMeta::from_title(disc_title);
|
let m = meta::M2tsMeta::from_title(disc_title).with_timings(timings);
|
||||||
meta::write_header(writer, &m)?;
|
meta::write_header(writer, &m)?;
|
||||||
|
*padded = m.frame_padding;
|
||||||
*header_written = true;
|
*header_written = true;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -201,7 +353,10 @@ fn ensure_header_written(
|
|||||||
impl crate::pes::Stream for NetworkStream {
|
impl crate::pes::Stream for NetworkStream {
|
||||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
match &mut self.mode {
|
match &mut self.mode {
|
||||||
Mode::Read { reader } => crate::pes::PesFrame::deserialize(reader),
|
Mode::Read { reader, meta } => {
|
||||||
|
crate::pes::PesFrame::deserialize_ext(reader, meta.frame_padding)
|
||||||
|
.map_err(|e| reader.get_ref().halted_or(e))
|
||||||
|
}
|
||||||
_ => Err(crate::error::Error::StreamWriteOnly.into()),
|
_ => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,9 +365,11 @@ impl crate::pes::Stream for NetworkStream {
|
|||||||
Mode::Write {
|
Mode::Write {
|
||||||
writer,
|
writer,
|
||||||
header_written,
|
header_written,
|
||||||
|
timings,
|
||||||
|
padded,
|
||||||
} => {
|
} => {
|
||||||
ensure_header_written(writer, header_written, &self.disc_title)?;
|
ensure_header_written(writer, header_written, &self.disc_title, timings, padded)?;
|
||||||
frame.serialize(writer)
|
frame.serialize_ext(writer, *padded)
|
||||||
}
|
}
|
||||||
_ => Err(crate::error::Error::StreamReadOnly.into()),
|
_ => Err(crate::error::Error::StreamReadOnly.into()),
|
||||||
}
|
}
|
||||||
@@ -221,12 +378,14 @@ impl crate::pes::Stream for NetworkStream {
|
|||||||
if let Mode::Write {
|
if let Mode::Write {
|
||||||
writer,
|
writer,
|
||||||
header_written,
|
header_written,
|
||||||
|
timings,
|
||||||
|
padded,
|
||||||
} = &mut self.mode
|
} = &mut self.mode
|
||||||
{
|
{
|
||||||
// Always emit the FMKV header before shutdown, even for a zero-frame stream,
|
// Always emit the FMKV header before shutdown, even for a zero-frame stream,
|
||||||
// or the receiver's read_header() sees a clean EOF and rejects the stream
|
// or the receiver's read_header() sees a clean EOF and rejects the stream
|
||||||
// with NoMetadata.
|
// with NoMetadata.
|
||||||
ensure_header_written(writer, header_written, &self.disc_title)?;
|
ensure_header_written(writer, header_written, &self.disc_title, timings, padded)?;
|
||||||
writer.flush()?;
|
writer.flush()?;
|
||||||
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
writer.get_ref().shutdown(std::net::Shutdown::Write)?;
|
||||||
}
|
}
|
||||||
@@ -235,6 +394,49 @@ impl crate::pes::Stream for NetworkStream {
|
|||||||
fn info(&self) -> &DiscTitle {
|
fn info(&self) -> &DiscTitle {
|
||||||
&self.disc_title
|
&self.disc_title
|
||||||
}
|
}
|
||||||
|
fn track_timing(&self, track: usize) -> crate::pes::TrackTiming {
|
||||||
|
match &self.mode {
|
||||||
|
Mode::Read { meta, .. } => meta.timing(track),
|
||||||
|
Mode::Write { .. } => Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn set_track_timing(
|
||||||
|
&mut self,
|
||||||
|
track: usize,
|
||||||
|
timing: crate::pes::TrackTiming,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
match &mut self.mode {
|
||||||
|
Mode::Write {
|
||||||
|
header_written: false,
|
||||||
|
timings,
|
||||||
|
..
|
||||||
|
} => set_timing(timings, track, timing, self.disc_title.streams.len()),
|
||||||
|
// The header (which carries it) is already on the wire.
|
||||||
|
Mode::Write { .. } => Err(crate::error::Error::StreamHeaderWritten.into()),
|
||||||
|
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The sender's codec privates travel in the FMKV header (as for stdio).
|
||||||
|
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||||
|
self.disc_title.codec_privates.get(track).cloned().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record `timing` for stream `track` of a `tracks`-stream title.
|
||||||
|
pub(crate) fn set_timing(
|
||||||
|
timings: &mut Vec<crate::pes::TrackTiming>,
|
||||||
|
track: usize,
|
||||||
|
timing: crate::pes::TrackTiming,
|
||||||
|
tracks: usize,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if track >= tracks {
|
||||||
|
return Err(crate::error::Error::MuxTrackRange { track, tracks }.into());
|
||||||
|
}
|
||||||
|
if timings.len() < tracks {
|
||||||
|
timings.resize(tracks, Default::default());
|
||||||
|
}
|
||||||
|
timings[track] = timing;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
|
// NetworkStream is PES-only — no IOStream/Read/Write byte interface.
|
||||||
@@ -729,4 +931,341 @@ mod tests {
|
|||||||
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
||||||
handle.join().unwrap();
|
handle.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Stop must interrupt a receiver still waiting for its sender to connect.
|
||||||
|
#[test]
|
||||||
|
fn a_halt_interrupts_a_pending_accept() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
let h = halt.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let r = NetworkStream::accept_from_with_halt(listener, Some(h)).map(|_| ());
|
||||||
|
let _ = tx.send(r);
|
||||||
|
});
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
halt.cancel();
|
||||||
|
let r = rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.expect("accept must observe the halt");
|
||||||
|
assert!(crate::error::is_halt(&r.unwrap_err()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... and a receiver blocked on a sender that stalled mid-stream.
|
||||||
|
#[test]
|
||||||
|
fn a_halt_interrupts_a_stalled_read() {
|
||||||
|
use crate::pes;
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
let h = halt.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut ns = NetworkStream::accept_from_with_halt(listener, Some(h)).unwrap();
|
||||||
|
let _ = tx.send(pes::Stream::read(&mut ns).map(|_| ()));
|
||||||
|
});
|
||||||
|
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&sample_title());
|
||||||
|
if let Mode::Write {
|
||||||
|
writer: w,
|
||||||
|
header_written,
|
||||||
|
padded,
|
||||||
|
..
|
||||||
|
} = &mut writer.mode
|
||||||
|
{
|
||||||
|
ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap();
|
||||||
|
w.flush().unwrap();
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
halt.cancel();
|
||||||
|
let r = rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.expect("read must observe the halt");
|
||||||
|
assert!(crate::error::is_halt(&r.unwrap_err()));
|
||||||
|
drop(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A halt while the sender has connected but not finished its header.
|
||||||
|
#[test]
|
||||||
|
fn a_halt_interrupts_a_stalled_header_read() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
let h = halt.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let r = NetworkStream::accept_from_with_halt(listener, Some(h)).map(|_| ());
|
||||||
|
let _ = tx.send(r);
|
||||||
|
});
|
||||||
|
let mut sender = TcpStream::connect(addr).unwrap();
|
||||||
|
sender.write_all(b"FM").unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
halt.cancel();
|
||||||
|
let r = rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.expect("the header read must observe the halt");
|
||||||
|
assert!(crate::error::is_halt(&r.unwrap_err()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A receiver without a halt (`listen`/`accept_from`) arms keepalive too.
|
||||||
|
#[test]
|
||||||
|
fn a_receiver_without_a_halt_arms_keepalive() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let ns = NetworkStream::accept_from(listener).unwrap();
|
||||||
|
match &ns.mode {
|
||||||
|
Mode::Read { reader, .. } => socket2::SockRef::from(&reader.get_ref().stream)
|
||||||
|
.keepalive()
|
||||||
|
.unwrap(),
|
||||||
|
Mode::Write { .. } => false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&sample_title());
|
||||||
|
if let Mode::Write {
|
||||||
|
writer: w,
|
||||||
|
header_written,
|
||||||
|
padded,
|
||||||
|
..
|
||||||
|
} = &mut writer.mode
|
||||||
|
{
|
||||||
|
ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap();
|
||||||
|
w.flush().unwrap();
|
||||||
|
}
|
||||||
|
assert!(handle.join().unwrap(), "keepalive must be armed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows may drop data when SO_RCVTIMEO cancels a recv, so a halt-aware
|
||||||
|
// receiver waits on readiness instead and never arms a read timeout.
|
||||||
|
#[test]
|
||||||
|
fn a_halt_aware_receiver_sets_no_read_timeout() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
let ns = NetworkStream::accept_from_with_halt(listener, Some(halt)).unwrap();
|
||||||
|
match &ns.mode {
|
||||||
|
Mode::Read { reader, .. } => reader.get_ref().stream.read_timeout().unwrap(),
|
||||||
|
Mode::Write { .. } => None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&sample_title());
|
||||||
|
crate::pes::Stream::finish(&mut writer).unwrap();
|
||||||
|
assert_eq!(handle.join().unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a readiness report reads; a timeout or EINTR ticks; a poll failure surfaces.
|
||||||
|
#[test]
|
||||||
|
fn poll_says_read_only_when_the_socket_reports_readiness() {
|
||||||
|
use io::ErrorKind::{ConnectionReset, Interrupted, TimedOut, WouldBlock};
|
||||||
|
let ok = |n: usize, f: PollFlags| poll_says_read(Ok(n), f).unwrap();
|
||||||
|
assert!(!ok(0, PollFlags::empty()), "timeout is a tick");
|
||||||
|
assert!(!ok(1, PollFlags::empty()));
|
||||||
|
for f in [
|
||||||
|
PollFlags::IN,
|
||||||
|
PollFlags::HUP,
|
||||||
|
PollFlags::ERR,
|
||||||
|
PollFlags::NVAL,
|
||||||
|
] {
|
||||||
|
assert!(ok(1, f), "{f:?} must hand over to read");
|
||||||
|
}
|
||||||
|
assert!(!poll_says_read(Err(Interrupted.into()), PollFlags::empty()).unwrap());
|
||||||
|
for kind in [WouldBlock, TimedOut, ConnectionReset] {
|
||||||
|
let e = poll_says_read(Err(kind.into()), PollFlags::empty()).unwrap_err();
|
||||||
|
assert_eq!(e.kind(), kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sender dribbling bytes with gaps many ticks long is read byte-identically.
|
||||||
|
#[test]
|
||||||
|
fn a_slow_sender_across_many_poll_ticks_arrives_byte_identical() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let data: Vec<u8> = (0..200_000u32).map(|i| (i * 7 + i / 251) as u8).collect();
|
||||||
|
let sent = data.clone();
|
||||||
|
let sender = std::thread::spawn(move || {
|
||||||
|
let mut s = TcpStream::connect(addr).unwrap();
|
||||||
|
let mut off = 0;
|
||||||
|
for i in 0usize.. {
|
||||||
|
if off == sent.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let n = (1 + i * 37 % 4093).min(sent.len() - off);
|
||||||
|
s.write_all(&sent[off..off + n]).unwrap();
|
||||||
|
off += n;
|
||||||
|
if i % 8 == 0 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (stream, _) = listener.accept().unwrap();
|
||||||
|
let tick = std::time::Duration::from_millis(1);
|
||||||
|
let mut r = HaltRead::with_tick(stream, Some(crate::halt::Halt::new()), tick);
|
||||||
|
let mut got = Vec::new();
|
||||||
|
let mut small = [0u8; 1500];
|
||||||
|
loop {
|
||||||
|
match r.read(&mut small).unwrap() {
|
||||||
|
0 => break,
|
||||||
|
n => got.extend_from_slice(&small[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sender.join().unwrap();
|
||||||
|
assert_eq!(got.len(), data.len());
|
||||||
|
assert!(
|
||||||
|
got == data,
|
||||||
|
"stream bytes must survive the poll ticks unchanged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A halt-aware receiver arms keepalive (dead-peer detection) and sets no
|
||||||
|
// data-idle limit: a sender silent for a while is still read afterwards.
|
||||||
|
#[test]
|
||||||
|
fn a_halt_aware_receiver_uses_keepalive_not_an_idle_limit() {
|
||||||
|
use crate::pes;
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
let mut ns = NetworkStream::accept_from_with_halt(listener, Some(halt)).unwrap();
|
||||||
|
let keepalive = match &ns.mode {
|
||||||
|
Mode::Read { reader, .. } => socket2::SockRef::from(&reader.get_ref().stream)
|
||||||
|
.keepalive()
|
||||||
|
.unwrap(),
|
||||||
|
Mode::Write { .. } => false,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
keepalive,
|
||||||
|
pes::Stream::read(&mut ns).map(|f| f.map(|f| f.data)),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&sample_title());
|
||||||
|
if let Mode::Write {
|
||||||
|
writer: w,
|
||||||
|
header_written,
|
||||||
|
padded,
|
||||||
|
..
|
||||||
|
} = &mut writer.mode
|
||||||
|
{
|
||||||
|
ensure_header_written(w, header_written, &sample_title(), &[], padded).unwrap();
|
||||||
|
w.flush().unwrap();
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(700));
|
||||||
|
let frame = pes::PesFrame {
|
||||||
|
discard_padding_ns: 0,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![7; 4],
|
||||||
|
duration_ns: None,
|
||||||
|
};
|
||||||
|
pes::Stream::write(&mut writer, &frame).unwrap();
|
||||||
|
pes::Stream::finish(&mut writer).unwrap();
|
||||||
|
let (keepalive, read) = handle.join().unwrap();
|
||||||
|
assert!(keepalive, "keepalive must be armed");
|
||||||
|
assert_eq!(read.unwrap(), Some(vec![7; 4]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FMKV header's codec privates (e.g. FLAC STREAMINFO, OpusHead, avcC)
|
||||||
|
// must be exposed on the receive side, or the MKV writer drops them.
|
||||||
|
#[test]
|
||||||
|
fn the_receiver_exposes_the_header_codec_privates() {
|
||||||
|
use crate::pes;
|
||||||
|
let mut title = sample_title();
|
||||||
|
title.codec_privates = vec![Some(vec![1, 2, 3]), Some(b"OpusHead".to_vec())];
|
||||||
|
let (addr, handle) = spawn_codec_private_reader();
|
||||||
|
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&title);
|
||||||
|
pes::Stream::finish(&mut writer).unwrap();
|
||||||
|
let cps = handle.join().unwrap();
|
||||||
|
assert_eq!(cps, title.codec_privates);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_codec_private_reader() -> (
|
||||||
|
std::net::SocketAddr,
|
||||||
|
std::thread::JoinHandle<Vec<Option<Vec<u8>>>>,
|
||||||
|
) {
|
||||||
|
use crate::pes::Stream as _;
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let ns = NetworkStream::accept_from(listener).unwrap();
|
||||||
|
(0..2).map(|i| ns.codec_private(i)).collect()
|
||||||
|
});
|
||||||
|
(addr, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mkv(opus) -> network -> mkv: CodecDelay/SeekPreRoll and DiscardPadding
|
||||||
|
// survive the PES wire hop.
|
||||||
|
#[test]
|
||||||
|
fn opus_track_timing_and_padding_survive_network_to_mkv() {
|
||||||
|
use crate::pes::{self, Stream as _};
|
||||||
|
let mut title = sample_title();
|
||||||
|
title.streams.truncate(1);
|
||||||
|
title.streams[0] = Stream::Audio(AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: Codec::Opus,
|
||||||
|
channels: AudioChannels::Stereo,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: SampleRate::S48,
|
||||||
|
secondary: false,
|
||||||
|
purpose: crate::disc::LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
});
|
||||||
|
title.codec_privates = vec![Some(b"OpusHead\x01\x02".to_vec())];
|
||||||
|
let timing = pes::TrackTiming {
|
||||||
|
codec_delay_ns: 6_500_000,
|
||||||
|
seek_preroll_ns: 80_000_000,
|
||||||
|
};
|
||||||
|
let frame = pes::PesFrame {
|
||||||
|
discard_padding_ns: -2_500_000,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 0,
|
||||||
|
pts: 0,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0xFC, 1, 2, 3],
|
||||||
|
duration_ns: Some(20_000_000),
|
||||||
|
};
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let mut ns = NetworkStream::accept_from(listener).unwrap();
|
||||||
|
let got = ns.track_timing(0);
|
||||||
|
let f = ns.read().unwrap().unwrap();
|
||||||
|
(ns.info().clone(), got, f)
|
||||||
|
});
|
||||||
|
let mut w = NetworkStream::connect_vetted(&addr.to_string(), false)
|
||||||
|
.unwrap()
|
||||||
|
.meta(&title);
|
||||||
|
w.set_track_timing(0, timing).unwrap();
|
||||||
|
w.write(&frame).unwrap();
|
||||||
|
w.finish().unwrap();
|
||||||
|
let (rx_title, rx_timing, rx_frame) = handle.join().unwrap();
|
||||||
|
assert_eq!(rx_timing, timing);
|
||||||
|
assert_eq!(rx_frame.discard_padding_ns, frame.discard_padding_ns);
|
||||||
|
assert_eq!(rx_frame.data, frame.data);
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("out.mkv");
|
||||||
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
|
let mut mkv =
|
||||||
|
crate::mux::mkvstream::MkvStream::create(Box::new(file), &rx_title, None).unwrap();
|
||||||
|
mkv.set_track_timing(0, rx_timing).unwrap();
|
||||||
|
mkv.write(&rx_frame).unwrap();
|
||||||
|
mkv.finish().unwrap();
|
||||||
|
let mut back =
|
||||||
|
crate::mux::mkvstream::MkvStream::open(std::fs::File::open(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(back.track_timing(0), timing);
|
||||||
|
assert_eq!(back.read().unwrap().unwrap().discard_padding_ns, -2_500_000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub struct PipelinedPesStream {
|
|||||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||||
pid_to_track: Vec<(u16, usize)>,
|
pid_to_track: Vec<(u16, usize)>,
|
||||||
|
|
||||||
|
/// Declared BEFORE `demux_thread`: dropped first, so a worker blocked on a
|
||||||
|
/// full channel exits before `DemuxThread::drop` joins it.
|
||||||
demux_rx: Receiver<DemuxBatch>,
|
demux_rx: Receiver<DemuxBatch>,
|
||||||
/// Kept alive so dropping this stream joins the demux + producer
|
/// Kept alive so dropping this stream joins the demux + producer
|
||||||
/// workers deterministically. Never poked directly after spawn.
|
/// workers deterministically. Never poked directly after spawn.
|
||||||
@@ -412,6 +414,8 @@ impl Stream for PipelinedPesStream {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|(p, _)| *p == pid)
|
.find(|(p, _)| *p == pid)
|
||||||
.and_then(|(_, parser)| parser.codec_private())
|
.and_then(|(_, parser)| parser.codec_private())
|
||||||
|
// FLAC/Opus ES carry no init data: keep what the source header supplied.
|
||||||
|
.or_else(|| self.title.codec_privates.get(track).cloned().flatten())
|
||||||
}
|
}
|
||||||
|
|
||||||
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||||
@@ -1030,6 +1034,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A codec whose ES carries no init data (FLAC, Opus) keeps the CodecPrivate
|
||||||
|
/// the source header supplied (FMKV `m2ts://`) when its parser has none.
|
||||||
|
#[test]
|
||||||
|
fn codec_private_falls_back_to_the_source_title() {
|
||||||
|
let mut title = video_title(false);
|
||||||
|
title.codec_privates = vec![Some(b"fLaC".to_vec())];
|
||||||
|
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
|
||||||
|
0x1011,
|
||||||
|
Box::new(CountingParser {
|
||||||
|
per_pes: 0,
|
||||||
|
flush_n: 0,
|
||||||
|
cp: None,
|
||||||
|
}),
|
||||||
|
)];
|
||||||
|
let (stream, _tx) = make_stream(title, parsers, vec![(0x1011u16, 0usize)]);
|
||||||
|
assert_eq!(stream.codec_private(0).as_deref(), Some(&b"fLaC"[..]));
|
||||||
|
}
|
||||||
|
|
||||||
/// codec_private(track) returns None for a track index not present in
|
/// codec_private(track) returns None for a track index not present in
|
||||||
/// pid_to_track — no panic, no wrong-track data.
|
/// pid_to_track — no panic, no wrong-track data.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+13
-1
@@ -356,6 +356,15 @@ impl std::fmt::Debug for InputOptions {
|
|||||||
|
|
||||||
/// Open a PES input stream (produces PES frames).
|
/// Open a PES input stream (produces PES frames).
|
||||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||||
|
input_with_halt(url, opts, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `input` whose blocking network receive (`network://`) a halt can interrupt.
|
||||||
|
pub(crate) fn input_with_halt(
|
||||||
|
url: &str,
|
||||||
|
opts: &InputOptions,
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
|
) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||||
let parsed = parse_url(url);
|
let parsed = parse_url(url);
|
||||||
match parsed {
|
match parsed {
|
||||||
StreamUrl::Disc { .. } => {
|
StreamUrl::Disc { .. } => {
|
||||||
@@ -415,7 +424,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
}
|
}
|
||||||
StreamUrl::Network { ref addr } => {
|
StreamUrl::Network { ref addr } => {
|
||||||
validate_network_addr(addr)?;
|
validate_network_addr(addr)?;
|
||||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
Ok(Box::new(NetworkStream::listen_with_halt(
|
||||||
|
addr,
|
||||||
|
halt.cloned(),
|
||||||
|
)?))
|
||||||
}
|
}
|
||||||
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
|
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
|
||||||
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
|
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||||
|
|||||||
+37
-3
@@ -21,6 +21,11 @@ pub struct StdioStream {
|
|||||||
/// when no header was present — `headers_ready()` must gate on the
|
/// when no header was present — `headers_ready()` must gate on the
|
||||||
/// metadata actually being available, not merely on having looked.
|
/// metadata actually being available, not merely on having looked.
|
||||||
meta_parsed: bool,
|
meta_parsed: bool,
|
||||||
|
/// Write side: per-track decoder timing, sent in the header. Read side: the
|
||||||
|
/// header's timing.
|
||||||
|
timings: Vec<crate::pes::TrackTiming>,
|
||||||
|
/// Frames carry the DiscardPadding extension (FMKV header v2).
|
||||||
|
padded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StdioStream {
|
impl StdioStream {
|
||||||
@@ -33,6 +38,8 @@ impl StdioStream {
|
|||||||
header_written: false,
|
header_written: false,
|
||||||
header_read: false,
|
header_read: false,
|
||||||
meta_parsed: false,
|
meta_parsed: false,
|
||||||
|
timings: Vec::new(),
|
||||||
|
padded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +52,8 @@ impl StdioStream {
|
|||||||
header_written: false,
|
header_written: false,
|
||||||
header_read: false,
|
header_read: false,
|
||||||
meta_parsed: false,
|
meta_parsed: false,
|
||||||
|
timings: Vec::new(),
|
||||||
|
padded: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +63,9 @@ impl StdioStream {
|
|||||||
if let Some(w) = &mut self.writer
|
if let Some(w) = &mut self.writer
|
||||||
&& !self.header_written
|
&& !self.header_written
|
||||||
{
|
{
|
||||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
let m = meta::M2tsMeta::from_title(&self.disc_title).with_timings(&self.timings);
|
||||||
meta::write_header(w, &m)?;
|
meta::write_header(w, &m)?;
|
||||||
|
self.padded = m.frame_padding;
|
||||||
self.header_written = true;
|
self.header_written = true;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -73,6 +83,10 @@ impl StdioStream {
|
|||||||
// garbage. Ok(None) (magic mismatch / clean EOF) stays non-error (default title).
|
// garbage. Ok(None) (magic mismatch / clean EOF) stays non-error (default title).
|
||||||
if let Some(m) = meta::read_header(r)? {
|
if let Some(m) = meta::read_header(r)? {
|
||||||
self.disc_title = m.to_title();
|
self.disc_title = m.to_title();
|
||||||
|
self.timings = (0..self.disc_title.streams.len())
|
||||||
|
.map(|i| m.timing(i))
|
||||||
|
.collect();
|
||||||
|
self.padded = m.frame_padding;
|
||||||
self.meta_parsed = true;
|
self.meta_parsed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +98,7 @@ impl crate::pes::Stream for StdioStream {
|
|||||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||||
self.ensure_header_read()?;
|
self.ensure_header_read()?;
|
||||||
match &mut self.reader {
|
match &mut self.reader {
|
||||||
Some(r) => crate::pes::PesFrame::deserialize(r),
|
Some(r) => crate::pes::PesFrame::deserialize_ext(r, self.padded),
|
||||||
None => Err(crate::error::Error::StreamWriteOnly.into()),
|
None => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +108,7 @@ impl crate::pes::Stream for StdioStream {
|
|||||||
}
|
}
|
||||||
self.ensure_header_written()?;
|
self.ensure_header_written()?;
|
||||||
match &mut self.writer {
|
match &mut self.writer {
|
||||||
Some(w) => frame.serialize(w),
|
Some(w) => frame.serialize_ext(w, self.padded),
|
||||||
None => Err(crate::error::Error::StreamReadOnly.into()),
|
None => Err(crate::error::Error::StreamReadOnly.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +126,26 @@ impl crate::pes::Stream for StdioStream {
|
|||||||
&self.disc_title
|
&self.disc_title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read side: the header's timing, available once the first read parsed it.
|
||||||
|
fn track_timing(&self, track: usize) -> crate::pes::TrackTiming {
|
||||||
|
self.timings.get(track).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_track_timing(
|
||||||
|
&mut self,
|
||||||
|
track: usize,
|
||||||
|
timing: crate::pes::TrackTiming,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if self.writer.is_none() {
|
||||||
|
return Err(crate::error::Error::StreamReadOnly.into());
|
||||||
|
}
|
||||||
|
if self.header_written {
|
||||||
|
return Err(crate::error::Error::StreamHeaderWritten.into());
|
||||||
|
}
|
||||||
|
let tracks = self.disc_title.streams.len();
|
||||||
|
super::network::set_timing(&mut self.timings, track, timing, tracks)
|
||||||
|
}
|
||||||
|
|
||||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||||
// Single source of truth: the title's own codec_privates. (The
|
// Single source of truth: the title's own codec_privates. (The
|
||||||
// previous `stored_codec_privates` field was a redundant clone
|
// previous `stored_codec_privates` field was a redundant clone
|
||||||
|
|||||||
@@ -611,6 +611,37 @@ fn psi_payload_base(pkt: &[u8]) -> Option<usize> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MPEG audio layer (1-3) from the first frame header at the start of `pid`'s
|
||||||
|
// first PES in `data`; `None` when no such PES or header is in the scan window.
|
||||||
|
fn mpeg_audio_layer(data: &[u8], pid: u16) -> Option<u8> {
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset + BD_SOURCE_PACKET_BYTES <= data.len() {
|
||||||
|
if !is_resync_point(data, offset) {
|
||||||
|
offset += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pkt = &data[offset..offset + BD_SOURCE_PACKET_BYTES];
|
||||||
|
let pkt_pid = (((pkt[5] & 0x1F) as u16) << 8) | pkt[6] as u16;
|
||||||
|
if pkt_pid == pid && pkt[5] & 0x40 != 0 {
|
||||||
|
let pes = &pkt[psi_payload_base(pkt)?..];
|
||||||
|
if pes.get(..3)? != [0, 0, 1] {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let es = pes.get(9 + *pes.get(8)? as usize..)?;
|
||||||
|
let (b0, b1) = (*es.first()?, *es.get(1)?);
|
||||||
|
if b0 != 0xFF || b1 & 0xE0 != 0xE0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return match (b1 >> 1) & 0x03 {
|
||||||
|
0 => None,
|
||||||
|
l => Some(4 - l),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
offset += BD_SOURCE_PACKET_BYTES;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// Reassemble a single PSI section (PAT/PMT) for `target_pid`/`table_id` across TS-packet
|
// Reassemble a single PSI section (PAT/PMT) for `target_pid`/`table_id` across TS-packet
|
||||||
// boundaries. Returns the section bytes (from table_id) or None.
|
// boundaries. Returns the section bytes (from table_id) or None.
|
||||||
fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> {
|
fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec<u8>> {
|
||||||
@@ -767,6 +798,8 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
|||||||
// PMTs may also carry ISO/IEC 13818-1 audio stream types that are
|
// PMTs may also carry ISO/IEC 13818-1 audio stream types that are
|
||||||
// absent from Blu-ray's STN table. Keep that distinction local to TS.
|
// absent from Blu-ray's STN table. Keep that distinction local to TS.
|
||||||
let codec = match stream_type {
|
let codec = match stream_type {
|
||||||
|
// MPEG-1/2 audio covers Layers I-III; only the ES says which.
|
||||||
|
0x03 | 0x04 if mpeg_audio_layer(data, es_pid) == Some(3) => Codec::Mp3,
|
||||||
0x03 | 0x04 => Codec::Mp2,
|
0x03 | 0x04 => Codec::Mp2,
|
||||||
0x0f => Codec::Aac,
|
0x0f => Codec::Aac,
|
||||||
0x87 => Codec::Ac3Plus,
|
0x87 => Codec::Ac3Plus,
|
||||||
@@ -1416,6 +1449,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stream_type 0x03/0x04 covers Layers I-III; the layer comes from the ES.
|
||||||
|
#[test]
|
||||||
|
fn scan_streams_labels_mpeg_audio_by_the_es_layer() {
|
||||||
|
use crate::disc::{Codec, Stream};
|
||||||
|
for (header, expected) in [([0xFF, 0xFB], Codec::Mp3), ([0xFF, 0xFD], Codec::Mp2)] {
|
||||||
|
let mut pes = vec![0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||||
|
pes.extend_from_slice(&header);
|
||||||
|
pes.extend_from_slice(&[0x90, 0x64]);
|
||||||
|
let mut data = pat_packet(0x100);
|
||||||
|
data.extend(pmt_packet(0x100, &[(0x1b, 0x1011), (0x03, 0x1100)]));
|
||||||
|
data.extend(es_packet_exact(0x1100, true, &pes));
|
||||||
|
data.extend(pat_packet(0x100));
|
||||||
|
let streams = scan_streams(&data).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&streams[1], Stream::Audio(a) if a.codec == expected),
|
||||||
|
"{expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_streams_maps_lpcm_via_from_coding_type() {
|
fn scan_streams_maps_lpcm_via_from_coding_type() {
|
||||||
use crate::disc::{Codec, Stream};
|
use crate::disc::{Codec, Stream};
|
||||||
|
|||||||
+55
-24
@@ -27,6 +27,10 @@ fn is_video_pid(pid: u16) -> bool {
|
|||||||
VIDEO_PID_RANGE.contains(&pid)
|
VIDEO_PID_RANGE.contains(&pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Headroom below the first frame's PTS for frames presented before it but emitted
|
||||||
|
// after it. ISO/IEC 13818-1 §2.4.2.6 caps T-STD buffer delay at 1 s.
|
||||||
|
pub(crate) const ORIGIN_HEADROOM_NS: i64 = 1_000_000_000;
|
||||||
|
|
||||||
/// BD-TS muxer: PES frames in, 192-byte BD-TS packets out.
|
/// BD-TS muxer: PES frames in, 192-byte BD-TS packets out.
|
||||||
///
|
///
|
||||||
/// Constructed over an output writer and a slice of per-track PIDs. The `track` index passed to
|
/// Constructed over an output writer and a slice of per-track PIDs. The `track` index passed to
|
||||||
@@ -45,8 +49,8 @@ pub struct TsMuxer<W: Write> {
|
|||||||
video_codec: Vec<Codec>,
|
video_codec: Vec<Codec>,
|
||||||
/// Global PTS origin (nanoseconds), seeded by the FIRST frame of any kind
|
/// Global PTS origin (nanoseconds), seeded by the FIRST frame of any kind
|
||||||
/// (video or audio) so a single fixed origin rebases every frame and the
|
/// (video or audio) so a single fixed origin rebases every frame and the
|
||||||
/// audio/video offset is preserved. A frame earlier than the origin (e.g.
|
/// audio/video offset is preserved. It lies [`ORIGIN_HEADROOM_NS`] before
|
||||||
/// audio reordered before the seeding frame) saturates to 0.
|
/// the seeding frame; only a frame earlier than that saturates to 0.
|
||||||
base_pts_ns: Option<i64>,
|
base_pts_ns: Option<i64>,
|
||||||
/// Count of PES frames actually emitted (a frame dropped as non-key
|
/// Count of PES frames actually emitted (a frame dropped as non-key
|
||||||
/// before the first keyframe does NOT count). `finish()` returns
|
/// before the first keyframe does NOT count). `finish()` returns
|
||||||
@@ -153,10 +157,12 @@ impl<W: Write> TsMuxer<W> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the global PTS origin from the FIRST frame of ANY kind, so one fixed
|
// One origin for every track, seeded by the FIRST frame of any kind and set
|
||||||
// origin rebases every frame and the a/v offset is preserved. Video-ONLY seeding
|
// ORIGIN_HEADROOM_NS below it, so a frame emitted later but presented
|
||||||
// made pre-video audio fall back to `base = unwrap_or(pts_ns)` (own pts) → PTS 0, spacing lost.
|
// earlier (video behind its GOP) keeps its offset.
|
||||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
let base = *self
|
||||||
|
.base_pts_ns
|
||||||
|
.get_or_insert(pts_ns.saturating_sub(ORIGIN_HEADROOM_NS));
|
||||||
let pts_ns = pts_ns.saturating_sub(base);
|
let pts_ns = pts_ns.saturating_sub(base);
|
||||||
|
|
||||||
// NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend
|
// NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend
|
||||||
@@ -439,6 +445,9 @@ fn build_pes_header(pid: u16, pts_90k: Option<u64>, data_len: usize) -> Vec<u8>
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// The origin headroom in 90 kHz ticks: the seeding frame's encoded PTS.
|
||||||
|
const H: u64 = ORIGIN_HEADROOM_NS as u64 * 9 / 100_000;
|
||||||
|
|
||||||
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||||
const VIDEO_PID: u16 = 0x1011;
|
const VIDEO_PID: u16 = 0x1011;
|
||||||
|
|
||||||
@@ -747,12 +756,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn av_offset_preserved_with_audio_before_first_video() {
|
fn av_offset_preserved_with_audio_before_first_video() {
|
||||||
// Audio at t=0 before the first video keyframe at t=1s. The FIRST frame of any
|
// Audio at t=0 before the first video keyframe at t=1s. The FIRST frame of any
|
||||||
// kind fixes the origin, so audio becomes origin (PTS 0), video lands 1s=90_000
|
// kind fixes the origin, so audio lands at the headroom, video 1s=90_000
|
||||||
// ticks later. OLD video-only seeding made both fall back to own pts → collapse to 0, offset lost.
|
// ticks later. OLD video-only seeding made both fall back to own pts → collapse to 0, offset lost.
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
||||||
// Audio frame first, at PTS 0 — this seeds the origin.
|
// Audio frame first, at t=0 — this seeds the origin.
|
||||||
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
|
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Video keyframe at PTS 1s — 1s after the origin.
|
// Video keyframe at PTS 1s — 1s after the origin.
|
||||||
@@ -763,14 +772,12 @@ mod tests {
|
|||||||
let packets = parse_bd_ts(&sink);
|
let packets = parse_bd_ts(&sink);
|
||||||
let video_pts = first_pts_90k(&packets, VIDEO_PID);
|
let video_pts = first_pts_90k(&packets, VIDEO_PID);
|
||||||
let audio_pts = first_pts_90k(&packets, AUDIO_PID);
|
let audio_pts = first_pts_90k(&packets, AUDIO_PID);
|
||||||
// The leading audio frame is the origin ⇒ its relative PTS is 0.
|
// The leading audio frame seeds the origin one headroom below itself.
|
||||||
|
assert_eq!(audio_pts, H, "the first (audio) frame seeds the origin");
|
||||||
|
// Video is 1s after the audio ⇒ the audio→video offset is preserved.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
audio_pts, 0,
|
video_pts,
|
||||||
"the first (audio) frame seeds the origin at 0"
|
H + 90_000,
|
||||||
);
|
|
||||||
// Video is 1s after the origin ⇒ the audio→video offset is preserved.
|
|
||||||
assert_eq!(
|
|
||||||
video_pts, 90_000,
|
|
||||||
"video 1s after the origin must stay 90_000 ticks ahead, not collapse to 0"
|
"video 1s after the origin must stay 90_000 ticks ahead, not collapse to 0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1010,13 +1017,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pts_encoded_at_90khz_decodes_correctly() {
|
fn pts_encoded_at_90khz_decodes_correctly() {
|
||||||
// pts_ns → 90 kHz ticks = pts_ns * 9 / 100_000. 1 second (1e9 ns)
|
// pts_ns → 90 kHz ticks = pts_ns * 9 / 100_000. 1 second (1e9 ns)
|
||||||
// = 90_000 ticks. The first (base) video frame rebases to 0, so use
|
// = 90_000 ticks. The first (base) video frame lands at the headroom, so use
|
||||||
// a second frame at a known offset and check its encoded PTS.
|
// a second frame at a known offset and check its encoded PTS.
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||||
let idr = fake_hevc_nal(19, 50);
|
let idr = fake_hevc_nal(19, 50);
|
||||||
mux.write_frame(0, 0, true, &idr).unwrap(); // base = 0
|
mux.write_frame(0, 0, true, &idr).unwrap(); // seeds the origin
|
||||||
let p = fake_hevc_nal(1, 50);
|
let p = fake_hevc_nal(1, 50);
|
||||||
// +1 second relative to base.
|
// +1 second relative to base.
|
||||||
mux.write_frame(0, 1_000_000_000, false, &p).unwrap();
|
mux.write_frame(0, 1_000_000_000, false, &p).unwrap();
|
||||||
@@ -1035,7 +1042,7 @@ mod tests {
|
|||||||
| (((p[11] >> 1) as u64) << 15)
|
| (((p[11] >> 1) as u64) << 15)
|
||||||
| ((p[12] as u64) << 7)
|
| ((p[12] as u64) << 7)
|
||||||
| ((p[13] >> 1) as u64);
|
| ((p[13] >> 1) as u64);
|
||||||
assert_eq!(pts, 90_000, "1s offset encodes to 90000 ticks @ 90 kHz");
|
assert_eq!(pts, H + 90_000, "1s offset encodes to 90000 ticks @ 90 kHz");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1082,8 +1089,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn negative_relative_pts_saturates_to_zero() {
|
fn negative_relative_pts_saturates_to_zero() {
|
||||||
// A frame earlier than the origin (negative relative PTS) must encode PTS 0, not
|
// A frame earlier than the origin (negative relative PTS) must encode PTS 0, not
|
||||||
// an underflowed huge value. Video keyframe at t=2s seeds the origin; a later
|
// an underflowed huge value. Video keyframe at t=2s seeds the origin at t=1s; a
|
||||||
// audio frame at t=0 is 2s BEFORE it → -2s, which must floor to 0.
|
// later audio frame at t=0 is 1s BEFORE it, which must floor to 0.
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
{
|
{
|
||||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
||||||
@@ -1120,9 +1127,13 @@ mod tests {
|
|||||||
let packets = parse_bd_ts(&sink);
|
let packets = parse_bd_ts(&sink);
|
||||||
let pts = all_pts_90k(&packets, AUDIO_PID);
|
let pts = all_pts_90k(&packets, AUDIO_PID);
|
||||||
assert_eq!(pts.len(), 2, "two audio frames → two PTS-bearing PES");
|
assert_eq!(pts.len(), 2, "two audio frames → two PTS-bearing PES");
|
||||||
assert_eq!(pts[0], 0, "the first audio frame is the origin → 0");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pts[1], 90_000,
|
pts[0], H,
|
||||||
|
"the first audio frame sits one headroom past the origin"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pts[1],
|
||||||
|
H + 90_000,
|
||||||
"the second frame (1s later) must keep its 1s spacing, not collapse to 0"
|
"the second frame (1s later) must keep its 1s spacing, not collapse to 0"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1341,10 +1352,30 @@ mod tests {
|
|||||||
| (((p[11] >> 1) as u64) << 15)
|
| (((p[11] >> 1) as u64) << 15)
|
||||||
| ((p[12] as u64) << 7)
|
| ((p[12] as u64) << 7)
|
||||||
| ((p[13] >> 1) as u64);
|
| ((p[13] >> 1) as u64);
|
||||||
assert_eq!(decoded, 0, "base video frame stays at relative PTS 0");
|
assert_eq!(decoded, H, "base video frame stays at the headroom");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pts, big_pts_ticks,
|
pts,
|
||||||
|
H + big_pts_ticks,
|
||||||
"the high bits (29..32) of a large PTS must round-trip through encoding"
|
"the high bits (29..32) of a large PTS must round-trip through encoding"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio emitted first but presented AFTER the first video frame (the video
|
||||||
|
// parser holds its opening GOP) must not clamp that video onto the audio's PTS.
|
||||||
|
#[test]
|
||||||
|
fn video_presented_before_the_seeding_audio_keeps_its_offset() {
|
||||||
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
|
||||||
|
mux.write_frame(1, 100_000_000, false, &[0x0B, 0x77, 0x00, 0x00])
|
||||||
|
.unwrap();
|
||||||
|
let idr = fake_hevc_nal(19, 100);
|
||||||
|
mux.write_frame(0, 50_000_000, true, &idr).unwrap();
|
||||||
|
mux.finish().unwrap();
|
||||||
|
}
|
||||||
|
let packets = parse_bd_ts(&sink);
|
||||||
|
let video = first_pts_90k(&packets, VIDEO_PID);
|
||||||
|
let audio = first_pts_90k(&packets, AUDIO_PID);
|
||||||
|
assert_eq!(audio - video, 4_500, "50 ms apart, video first");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-4
@@ -57,7 +57,7 @@ pub struct PesFrame {
|
|||||||
/// preserved across network:// and stdio:// hops.
|
/// preserved across network:// and stdio:// hops.
|
||||||
pub duration_ns: Option<u64>,
|
pub duration_ns: Option<u64>,
|
||||||
/// Matroska DiscardPadding in nanoseconds: positive trims the end, negative
|
/// Matroska DiscardPadding in nanoseconds: positive trims the end, negative
|
||||||
/// trims the beginning. Local container remux metadata; not on the PES wire.
|
/// trims the beginning. On the PES wire only for FMKV header v2 streams.
|
||||||
pub discard_padding_ns: i64,
|
pub discard_padding_ns: i64,
|
||||||
/// Byte-exact source provenance of this frame's first byte, stamped at the
|
/// Byte-exact source provenance of this frame's first byte, stamped at the
|
||||||
/// demux seam. `None` for synthetic sources / the `skip_parse` path and for
|
/// demux seam. `None` for synthetic sources / the `skip_parse` path and for
|
||||||
@@ -81,6 +81,10 @@ impl PesFrame {
|
|||||||
///
|
///
|
||||||
/// `duration_ns` is encoded as `u64::MAX` when `None`, or the value when `Some`.
|
/// `duration_ns` is encoded as `u64::MAX` when `None`, or the value when `Some`.
|
||||||
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
|
pub fn serialize(&self, w: &mut dyn std::io::Write) -> std::io::Result<()> {
|
||||||
|
self.serialize_inner(w, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_inner(&self, w: &mut dyn std::io::Write, padded: bool) -> std::io::Result<()> {
|
||||||
if self.track > 255 {
|
if self.track > 255 {
|
||||||
return Err(crate::error::Error::PesTrackTooLarge { track: self.track }.into());
|
return Err(crate::error::Error::PesTrackTooLarge { track: self.track }.into());
|
||||||
}
|
}
|
||||||
@@ -98,9 +102,34 @@ impl PesFrame {
|
|||||||
w.write_all(&[if self.keyframe { 1 } else { 0 }])?;
|
w.write_all(&[if self.keyframe { 1 } else { 0 }])?;
|
||||||
w.write_all(&duration_wire.to_le_bytes())?;
|
w.write_all(&duration_wire.to_le_bytes())?;
|
||||||
w.write_all(&(self.data.len() as u32).to_le_bytes())?;
|
w.write_all(&(self.data.len() as u32).to_le_bytes())?;
|
||||||
|
if padded {
|
||||||
|
w.write_all(&self.discard_padding_ns.to_le_bytes())?;
|
||||||
|
}
|
||||||
w.write_all(&self.data)
|
w.write_all(&self.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`serialize`](Self::serialize) plus, when `padded` (FMKV header v2), an
|
||||||
|
/// 8-byte LE `discard_padding_ns` after the fixed header.
|
||||||
|
pub(crate) fn serialize_ext(
|
||||||
|
&self,
|
||||||
|
w: &mut dyn std::io::Write,
|
||||||
|
padded: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
self.serialize_inner(w, padded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`deserialize`](Self::deserialize) for a stream whose FMKV header says
|
||||||
|
/// frames carry the `discard_padding_ns` extension (`padded`).
|
||||||
|
pub(crate) fn deserialize_ext(
|
||||||
|
r: &mut dyn std::io::Read,
|
||||||
|
padded: bool,
|
||||||
|
) -> std::io::Result<Option<Self>> {
|
||||||
|
if !padded {
|
||||||
|
return Self::deserialize(r);
|
||||||
|
}
|
||||||
|
Self::deserialize_inner(r, true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Deserialize from bytes. Returns None at a clean end of stream.
|
/// Deserialize from bytes. Returns None at a clean end of stream.
|
||||||
///
|
///
|
||||||
/// A clean EOF is exactly zero bytes available before the next frame.
|
/// A clean EOF is exactly zero bytes available before the next frame.
|
||||||
@@ -108,6 +137,10 @@ impl PesFrame {
|
|||||||
/// error (`UnexpectedEof`), not silently treated as EOF — otherwise
|
/// error (`UnexpectedEof`), not silently treated as EOF — otherwise
|
||||||
/// truncated `.pes` data would be accepted as a graceful end.
|
/// truncated `.pes` data would be accepted as a graceful end.
|
||||||
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
||||||
|
Self::deserialize_inner(r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_inner(r: &mut dyn std::io::Read, padded: bool) -> std::io::Result<Option<Self>> {
|
||||||
// Probe one byte first to distinguish clean EOF from a truncated header.
|
// Probe one byte first to distinguish clean EOF from a truncated header.
|
||||||
// Loop on EINTR so a recoverable interrupted read doesn't fail — symmetric
|
// Loop on EINTR so a recoverable interrupted read doesn't fail — symmetric
|
||||||
// with read_exact's internal retry for the rest of the header and data.
|
// with read_exact's internal retry for the rest of the header and data.
|
||||||
@@ -141,6 +174,12 @@ impl PesFrame {
|
|||||||
Some(duration_wire)
|
Some(duration_wire)
|
||||||
};
|
};
|
||||||
let len = u32::from_le_bytes([header[18], header[19], header[20], header[21]]) as usize;
|
let len = u32::from_le_bytes([header[18], header[19], header[20], header[21]]) as usize;
|
||||||
|
let mut discard_padding_ns = 0;
|
||||||
|
if padded {
|
||||||
|
let mut ext = [0u8; 8];
|
||||||
|
r.read_exact(&mut ext)?;
|
||||||
|
discard_padding_ns = i64::from_le_bytes(ext);
|
||||||
|
}
|
||||||
if len > MAX_FRAME_SIZE {
|
if len > MAX_FRAME_SIZE {
|
||||||
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
|
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
|
||||||
}
|
}
|
||||||
@@ -157,7 +196,7 @@ impl PesFrame {
|
|||||||
filled += want;
|
filled += want;
|
||||||
}
|
}
|
||||||
Ok(Some(Self {
|
Ok(Some(Self {
|
||||||
discard_padding_ns: 0,
|
discard_padding_ns,
|
||||||
track,
|
track,
|
||||||
pts,
|
pts,
|
||||||
keyframe,
|
keyframe,
|
||||||
@@ -226,8 +265,9 @@ pub trait Stream: Send {
|
|||||||
TrackTiming::default()
|
TrackTiming::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set timing before the first frame is written. Sinks without this metadata
|
/// Set timing before the first frame is written. Carried by `mkv://` and the
|
||||||
/// retain their existing behavior.
|
/// FMKV `network://` / `stdio://` wire; `m2ts://` has no field for it and
|
||||||
|
/// `mp4://` carries no Opus track, so those keep the no-op default.
|
||||||
fn set_track_timing(&mut self, _track: usize, _timing: TrackTiming) -> std::io::Result<()> {
|
fn set_track_timing(&mut self, _track: usize, _timing: TrackTiming) -> std::io::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user