cleanup: drop dead error variants (E9009, E9063, E9082), the io::sink and fmp4/m2ts_mux/HevcMux scaffolding, stale comments and doc links

This commit is contained in:
Matthew Jackson
2026-09-30 22:50:43 -07:00
parent 3a2c448a74
commit 96e538c559
26 changed files with 39 additions and 3872 deletions
+3 -1
View File
@@ -14,6 +14,8 @@
- **Stop is the only send bound:** `MuxOptions::send_deadline`, `NO_SEND_DEADLINE` and `halt::POLL_INTERVAL` are removed (use `halt::WAIT_SLICE`); a slow sink blocks the mux until it drains or Stop.
- `Drive::clear_halt`, `Drive::scsi_mut` and `Drive::is_unlocked` and the path-based `platform::fs_type::detect` are removed; use `Drive::attach` / `detach` / `halt` / `token` and `DiscSession::source_mut`.
- **Block sinks:** `io::BlockSink` (`write_at(lba, bytes)`, `finish(Finish::{Complete, Incomplete})`) with `IsoSink` (a sector-exact image) and `NullBlockSink`, opened from a URL by `io::open_block_sink("iso://…" | "null://")`; `write_image` writes through `IsoSink`. `io::null_device()` / `is_null_device()` name the platform's null device (`NUL` on Windows), and `Disc::mapfile_for` keys its private mapfile on it, so a whole-disc copy to `null://` works on Windows.
- Error variants no path returns any more are removed with their codes: `DiscUrlNotDirect` (E9009; `disc://` opens through `input()`), `DirImageEncrypted` (E9063; an encrypted folder is keyed like its image) and `MultipassRequiresRaw` (E9082; a multipass recovery may decrypt).
- Unused scaffolding is removed: the `io::sink` module (`SequentialSink`, `RandomAccessSink`, `LocalFileSink`, `SocketSink`, `UdpSocketSink`; `WritebackFile` no longer implements them) and the unfinished `fmp4`, `m2ts_mux` and `HevcMux` muxers.
- `DvdAudioAttr::sub_stream_id` (positional id) is removed.
- `M2tsMeta` gains `timings`, `chapters`, `content_format` and `frame_padding` (all `#[serde(default)]`); a struct-literal build of it must name them.
- `css::CrackOutcome` is `#[non_exhaustive]` and gains `Halted` and `Unreadable(Error)`; it no longer derives `Clone`. `css::crack_key` is deprecated for `crack_key_outcome`.
@@ -54,7 +56,7 @@
- `halt`: waits re-check every `WAIT_SLICE` (20 ms); `Liveness`, `StallTimer`, `join_within`, `spawn_drive_holder`. `halt` is available with only the `scsi` feature.
- `NetworkStream::listen_with_halt` / `accept_from_with_halt`; `Event::BytesDurable { bytes, total }` while the output flushes; `io::{ArtifactLock, durable_sync_file, FlushProgress}` (`<final>.lock` sidecar; E9073 `TimedOut` after 30 s with no change); `Pipeline::{set_op_token, progress}`, `Sink::close_stopped` (a consumer closing after Stop keeps `*.partial`); `ISO_MUX_BATCH_SECTORS` is public.
- MPEG-2 multichannel extension audio (DVD `0xD0|n`) is a dependent track (`AudioStream::is_mp2_extension`) carried in the IR and over FMKV; sinks with no mapping list it in `MuxOutcome::undelivered_streams`, `mp4://` skips it (`Mp4SkipReason::Mp2Extension`). MPEG-1/2 audio (PES `0xC0|n`) is demuxed on DVD.
- Errors: E9085 `MuxBatchSectorsZero`, E6020 `ImageEndsBeforeRead`, E6021 `BusStreamUnmapped`, E6022 `ImageScoped`, E6023 XPL too large, E7031 `AacsKeyFileUnreadable`, E7032 `WholeDiscKeyMissing`, E7033 `AacsNoUsableHostCert`, E7034 `AacsVidNeedsDisc`, E7100-E7108 MKB variant faults, E9071 `StreamClosed`, E9072 `StreamHeaderWritten`, E9073 `TimedOut { op }`, E9074, E9075, and the remux/preflight set E9077 `RemuxVerifyFailed { kind, path }` (`E9077: <kind> <path>`, or `runtime-mismatch <have>/<want>`), E9078 `MuxIncomplete`, E9079 `RemuxStagingInvalid`, E9080 `StagedCopySizeMismatch`, E9081 `WorkerLost { op }`, E9082 `MultipassRequiresRaw`, E9083 `StreamLanguageUnknown`, E9084 `RemuxTargetExists`.
- Errors: E9085 `MuxBatchSectorsZero`, E6020 `ImageEndsBeforeRead`, E6021 `BusStreamUnmapped`, E6022 `ImageScoped`, E6023 XPL too large, E7031 `AacsKeyFileUnreadable`, E7032 `WholeDiscKeyMissing`, E7033 `AacsNoUsableHostCert`, E7034 `AacsVidNeedsDisc`, E7100-E7108 MKB variant faults, E9071 `StreamClosed`, E9072 `StreamHeaderWritten`, E9073 `TimedOut { op }`, E9074, E9075, and the remux/preflight set E9077 `RemuxVerifyFailed { kind, path }` (`E9077: <kind> <path>`, or `runtime-mismatch <have>/<want>`), E9078 `MuxIncomplete`, E9079 `RemuxStagingInvalid`, E9080 `StagedCopySizeMismatch`, E9081 `WorkerLost { op }`, E9083 `StreamLanguageUnknown`, E9084 `RemuxTargetExists`.
- `whole_disc::{whole_disc_reader, WholeDiscReader}`: the decrypting reader for whole-disc / image to ISO copies; it keys every stream file no title plays (probed at its first unit and up to 32 across it) and blanks, warns and counts the units of a stream file no held key opens (E7032 only when no file opens at all, a source faults, or no key is held). `ScanOptions::raw_copy` is kept but has no effect: an unreadable `Unit_Key_RO.inf` never fails the scan.
- `sector::bus_removal::{UnmappedStreamFile, ensure_image_debussable}`, `BusMap::unmapped`, `SectorSource::unmapped_stream_files`, `Disc::{mkv_staging_ranges, stream_content_ranges, inputs_with_samples}`, `DecryptingSectorSource::{blanked_units, clear_unit_base}`, `DiscPresence` / `disc_presence(path)`, `Playlist::duration_ticks`.
- `probe_mkv` / `probe_mkv_with_cues` read a Matroska file's Info and Tracks (and last Cues time) without reading a cluster; `parse_freemkv_version`.
+1 -1
View File
@@ -1697,7 +1697,7 @@ pub struct DriveCredentials {
/// Options for disc scanning.
///
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
/// out-of-band through [`KeyRing::resolve`](crate::keys::KeyRing::resolve). The
/// out-of-band through [`KeyRing::acquire`](crate::keys::KeyRing::acquire). The
/// only scan input is the optional drive credentials for the live-drive
/// authenticated handshake.
#[derive(Default)]
+3 -39
View File
@@ -171,7 +171,6 @@ pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
pub const E_PES_INVALID_MAGIC: u16 = 9006;
pub const E_ISO_TOO_LARGE: u16 = 9007;
pub const E_NO_METADATA: u16 = 9008;
pub const E_DISC_URL_NOT_DIRECT: u16 = 9009;
/// `--raw` given with a `dir://` destination (raw + decrypted-tree is
/// a contradiction; raw bytes go to `iso://`).
pub const E_DIR_RAW_REJECTED: u16 = 9019;
@@ -211,10 +210,6 @@ pub const E_DIR_IMAGE_SSIF_UNSUPPORTED: u16 = 9061;
/// the end of the file that must precede it. Carries the offending file's disc
/// path. Also raised for an IFO too short to resolve its placement offsets.
pub const E_DIR_IMAGE_PLACEMENT: u16 = 9062;
/// A `dir://` SOURCE folder still carries live AACS-encrypted content: it has
/// an `AACS/` directory AND the sampled content units are genuinely scrambled.
/// `dir://` sources are decrypted-folder only.
pub const E_DIR_IMAGE_ENCRYPTED: u16 = 9063;
/// A `dir://` SOURCE folder holds no disc structure the image synthesizer
/// understands (no `BDMV/`, no `VIDEO_TS/`).
pub const E_DIR_IMAGE_UNSUPPORTED_TREE: u16 = 9064;
@@ -327,8 +322,6 @@ pub const E_REMUX_STAGING_INVALID: u16 = 9079;
pub const E_STAGED_COPY_SIZE_MISMATCH: u16 = 9080;
/// A remux worker thread (`op`: "copy", "verify") was lost before it reported.
pub const E_WORKER_LOST: u16 = 9081;
/// A multipass rip was asked to decrypt: multipass recovers a raw whole-disc image.
pub const E_MULTIPASS_REQUIRES_RAW: u16 = 9082;
/// A requested audio/subtitle language tag resolves to no known language.
pub const E_STREAM_LANGUAGE_UNKNOWN: u16 = 9083;
/// A remux target already exists and replacing it was not requested.
@@ -778,10 +771,6 @@ pub enum Error {
path: String,
},
NoMetadata,
/// `disc://` URLs aren't openable through `input()` — callers must use
/// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This
/// is a structural API constraint, not a parse failure.
DiscUrlNotDirect,
/// A non-empty `HEVCDecoderConfigurationRecord` (hvcC) was supplied to
/// a muxer but failed to parse into any VPS/SPS/PPS NAL — emitting the
/// stream without parameter sets would yield an undecodable result.
@@ -893,8 +882,6 @@ pub enum Error {
DirImagePlacement {
path: String,
},
/// A `dir://` SOURCE folder still carries live AACS-encrypted content.
DirImageEncrypted,
/// A `dir://` SOURCE folder holds no recognized disc structure.
DirImageUnsupportedTree,
/// A file in a `dir://` SOURCE folder changed between plan and read.
@@ -974,8 +961,6 @@ pub enum Error {
WorkerLost {
op: &'static str,
},
/// See [`E_MULTIPASS_REQUIRES_RAW`].
MultipassRequiresRaw,
/// `tag` is the caller's unresolvable language tag, verbatim.
StreamLanguageUnknown {
tag: String,
@@ -1121,7 +1106,6 @@ impl Error {
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
Error::NoMetadata => E_NO_METADATA,
Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT,
Error::HevcParamParse => E_HEVC_PARAM_PARSE,
Error::MuxTrackRange { .. } => E_MUX_TRACK_RANGE,
Error::Fmp4Unimplemented => E_FMP4_UNIMPLEMENTED,
@@ -1144,7 +1128,6 @@ impl Error {
Error::DirWriteFailed { .. } => E_DIR_WRITE_FAILED,
Error::DirImageSsifUnsupported => E_DIR_IMAGE_SSIF_UNSUPPORTED,
Error::DirImagePlacement { .. } => E_DIR_IMAGE_PLACEMENT,
Error::DirImageEncrypted => E_DIR_IMAGE_ENCRYPTED,
Error::DirImageUnsupportedTree => E_DIR_IMAGE_UNSUPPORTED_TREE,
Error::DirNameTooLong { .. } => E_DIR_NAME_TOO_LONG,
Error::DirImageFanout { .. } => E_DIR_IMAGE_FANOUT,
@@ -1162,7 +1145,6 @@ impl Error {
Error::RemuxStagingInvalid => E_REMUX_STAGING_INVALID,
Error::StagedCopySizeMismatch { .. } => E_STAGED_COPY_SIZE_MISMATCH,
Error::WorkerLost { .. } => E_WORKER_LOST,
Error::MultipassRequiresRaw => E_MULTIPASS_REQUIRES_RAW,
Error::StreamLanguageUnknown { .. } => E_STREAM_LANGUAGE_UNKNOWN,
Error::RemuxTargetExists { .. } => E_REMUX_TARGET_EXISTS,
Error::MuxBatchSectorsZero => E_MUX_BATCH_SECTORS_ZERO,
@@ -1426,9 +1408,6 @@ impl From<Error> for std::io::Error {
8000..=8999 => std::io::ErrorKind::Other,
9000..=9001 => std::io::ErrorKind::Unsupported,
9002..=9008 => std::io::ErrorKind::InvalidInput,
// 9009 DiscUrlNotDirect: structurally unsupported entry point,
// not a parse failure — caller used the wrong API.
9009 => std::io::ErrorKind::Unsupported,
// 9010 HevcParamParse: malformed hvcC payload.
9010 => std::io::ErrorKind::InvalidData,
// 9011 MuxTrackRange: caller passed a bad track index.
@@ -1497,7 +1476,6 @@ impl From<Error> for std::io::Error {
// tree, too large, name too long, fan-out overflow) — input properties.
E_DIR_IMAGE_SSIF_UNSUPPORTED
| E_DIR_IMAGE_PLACEMENT
| E_DIR_IMAGE_ENCRYPTED
| E_DIR_IMAGE_UNSUPPORTED_TREE
| E_DIR_IMAGE_TOO_LARGE
| E_DIR_NAME_TOO_LONG
@@ -1509,10 +1487,9 @@ impl From<Error> for std::io::Error {
E_TIMED_OUT => std::io::ErrorKind::TimedOut,
// Remux/engine codes keep the kinds their io::Error texts carried.
E_REMUX_VERIFY_FAILED | E_STAGED_COPY_SIZE_MISMATCH => std::io::ErrorKind::InvalidData,
E_REMUX_STAGING_INVALID
| E_MULTIPASS_REQUIRES_RAW
| E_STREAM_LANGUAGE_UNKNOWN
| E_MUX_BATCH_SECTORS_ZERO => std::io::ErrorKind::InvalidInput,
E_REMUX_STAGING_INVALID | E_STREAM_LANGUAGE_UNKNOWN | E_MUX_BATCH_SECTORS_ZERO => {
std::io::ErrorKind::InvalidInput
}
E_REMUX_TARGET_EXISTS => std::io::ErrorKind::AlreadyExists,
_ => std::io::ErrorKind::Other,
};
@@ -1830,7 +1807,6 @@ mod tests {
}
.code(),
Error::MapfileInvalid { kind: "hex" }.code(),
Error::DiscUrlNotDirect.code(),
Error::ExtentNotUnitAligned.code(),
Error::M2tsPacketMalformed.code(),
Error::DiscCapacityMalformed.code(),
@@ -1847,7 +1823,6 @@ mod tests {
Error::DirWriteFailed { errno: Some(28) }.code(),
Error::DirImageSsifUnsupported.code(),
Error::DirImagePlacement { path: "x".into() }.code(),
Error::DirImageEncrypted.code(),
Error::DirImageUnsupportedTree.code(),
Error::DirImageFileChanged { path: "x".into() }.code(),
Error::DirImageTooLarge.code(),
@@ -1893,7 +1868,6 @@ mod tests {
Error::RemuxStagingInvalid.code(),
Error::StagedCopySizeMismatch { have: 0, want: 1 }.code(),
Error::WorkerLost { op: "copy" }.code(),
Error::MultipassRequiresRaw.code(),
Error::StreamLanguageUnknown { tag: "x".into() }.code(),
Error::RemuxTargetExists { path: "x".into() }.code(),
];
@@ -1954,7 +1928,6 @@ mod tests {
},
E_IMAGE_ENDS_BEFORE_READ,
),
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
// Both CSS no-key verdicts: numeric-only Display, no English.
(Error::CssKeyMissing, E_CSS_KEY_MISSING),
@@ -2105,8 +2078,6 @@ mod tests {
mapped(Error::MapfileInvalid { kind: "hex" }),
ErrorKind::InvalidData
);
// 9009 special-cased to Unsupported
assert_eq!(mapped(Error::DiscUrlNotDirect), ErrorKind::Unsupported);
// 9021 special-cased to InvalidData
assert_eq!(mapped(Error::M2tsPacketMalformed), ErrorKind::InvalidData);
// 9047 DiscCapacityMalformed → InvalidData
@@ -2321,7 +2292,6 @@ mod tests {
(Error::StreamWriteOnly, E_STREAM_WRITE_ONLY),
(Error::PesInvalidMagic, E_PES_INVALID_MAGIC),
(Error::NoMetadata, E_NO_METADATA),
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
(Error::HevcParamParse, E_HEVC_PARAM_PARSE),
(Error::Fmp4Unimplemented, E_FMP4_UNIMPLEMENTED),
(Error::DemuxThreadPanicked, E_DEMUX_THREAD_PANICKED),
@@ -2543,12 +2513,6 @@ mod tests {
"E9081: verify",
ErrorKind::Other,
),
(
Error::MultipassRequiresRaw,
9082,
"E9082",
ErrorKind::InvalidInput,
),
(
Error::StreamLanguageUnknown {
tag: "xx\nyy".into(),
-1
View File
@@ -15,7 +15,6 @@ pub mod file_sector_source;
mod flush;
pub mod fsync;
pub mod image_writer;
pub mod sink;
mod writeback;
mod writeback_file;
+1 -2
View File
@@ -3,8 +3,7 @@
//! The `libc` crate doesn't expose these symbols across all macOS SDK versions, so we define
//! them locally with values from `/usr/include/sys/fcntl.h`.
//!
//! [`crate::io::writeback_file`] and `crate::io::sink::preallocate`
//! both depend on these constants and the `fstore_t` layout.
//! [`crate::io::writeback_file`] depends on these constants and the `fstore_t` layout.
/// `fcntl(F_PREALLOCATE)` command number from `sys/fcntl.h`.
pub(crate) const F_PREALLOCATE: libc::c_int = 42;
-211
View File
@@ -1,211 +0,0 @@
//! `LocalFileSink` — `BufWriter<File>` for the common local-disk case.
//!
//! 4 MiB internal `BufWriter` coalesces small per-PES muxer writes into
//! kernel-page-aligned flushes. `Seek` flushes the buffer first so a
//! seek can't leapfrog buffered data and corrupt the file — required
//! for MKV's seek-back patches (cluster size, Cues, segment header).
//!
//! [`SequentialSink`](super::SequentialSink) is implemented explicitly
//! so `finish()` flushes and `fsync`s even through a `dyn` trait object;
//! [`RandomAccessSink`](super::RandomAccessSink) is over the `Seek` impl.
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Seek, SeekFrom, Write};
use std::path::Path;
use super::preallocate;
use super::{RandomAccessSink, SequentialSink};
const BUFFER_BYTES: usize = 4 * 1024 * 1024;
/// Random-access write sink for local disks.
///
/// Wraps a `BufWriter<File>` with a 4 MiB internal buffer and forwards
/// `Write`/`Seek` so any call site that previously held a `File` or
/// `WritebackFile` can drop this in. `finish()` flushes the buffer and
/// `sync_all`s the underlying file before the caller drops it.
///
/// Opens the file `create + truncate + read + write`; `read` allows
/// reusing the handle for a post-mux verification re-read. On Linux,
/// `Self::with_size_hint` additionally pre-reserves extents via `fallocate`.
pub struct LocalFileSink {
inner: BufWriter<File>,
}
impl LocalFileSink {
/// Open `path` for writing, truncating any existing contents.
pub fn create(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
Ok(Self {
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
})
}
/// Like [`Self::create`] but additionally calls the per-OS
/// preallocate path with `size_bytes`. On Linux this is
/// `fallocate(FALLOC_FL_KEEP_SIZE)` so the on-disk extents are
/// reserved up front (reducing fragmentation for big sequential
/// muxer output); on other OSes it is a no-op today. Failures
/// from the preallocate call are non-fatal — the file is still
/// returned, just without the size reservation.
pub fn with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
preallocate::preallocate(&file, size_bytes);
Ok(Self {
inner: BufWriter::with_capacity(BUFFER_BYTES, file),
})
}
/// Drain the internal buffer and `fsync` the underlying file.
/// Idempotent with `Drop` (the `BufWriter` also flushes on drop;
/// this call additionally surfaces fsync errors to the caller).
/// [`SequentialSink::finish`](super::SequentialSink::finish)
/// delegates here so the durable flush happens through a trait
/// object too.
pub fn sync_all(&mut self) -> io::Result<()> {
self.inner.flush()?;
self.inner.get_ref().sync_all()
}
}
impl SequentialSink for LocalFileSink {
/// Flush the 4 MiB `BufWriter` and `fsync` the file. Overriding the
/// trait default is what makes a `dyn SequentialSink` / `dyn
/// RandomAccessSink` `finish()` actually durable instead of a no-op.
fn finish(&mut self) -> io::Result<()> {
self.sync_all()
}
}
impl RandomAccessSink for LocalFileSink {}
impl Write for LocalFileSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.inner.write(buf)
}
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
self.inner.write_all(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
impl Seek for LocalFileSink {
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
// Flush before seeking so buffered bytes land at the offset
// they were written for, not the new one.
self.inner.flush()?;
self.inner.get_mut().seek(from)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn write_seek_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("rt.bin");
let mut s = LocalFileSink::create(&p).unwrap();
s.write_all(b"AAAA").unwrap();
s.write_all(b"BBBB").unwrap();
// Seek back over the second word and overwrite.
s.seek(SeekFrom::Start(4)).unwrap();
s.write_all(b"CCCC").unwrap();
s.sync_all().unwrap();
drop(s);
let mut f = File::open(&p).unwrap();
let mut got = Vec::new();
f.read_to_end(&mut got).unwrap();
assert_eq!(&got[..], b"AAAACCCC");
}
#[test]
fn drop_flushes() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("drop.bin");
{
let mut s = LocalFileSink::create(&p).unwrap();
s.write_all(b"buffered").unwrap();
// No explicit flush / sync_all — BufWriter drop runs the
// flush and the file should land on disk.
}
let bytes = std::fs::read(&p).unwrap();
assert_eq!(&bytes[..], b"buffered");
}
#[test]
fn with_size_hint_creates_writable_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("sz.bin");
let mut s = LocalFileSink::with_size_hint(&p, 64 * 1024).unwrap();
s.write_all(b"hint-ok").unwrap();
s.sync_all().unwrap();
drop(s);
let bytes = std::fs::read(&p).unwrap();
assert_eq!(&bytes[..], b"hint-ok");
}
// ── Added hardening tests ───────────────────────────────────────
// `create` must truncate: pre-seed a long file, recreate via the sink
// with a shorter payload, and confirm the old tail is gone.
#[test]
fn create_truncates_existing_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("trunc.bin");
std::fs::write(&p, vec![0xFFu8; 4096]).unwrap();
let mut s = LocalFileSink::create(&p).unwrap();
s.write_all(b"short").unwrap();
s.sync_all().unwrap();
drop(s);
let bytes = std::fs::read(&p).unwrap();
assert_eq!(
bytes.len(),
5,
"create must truncate the pre-existing 4096 bytes"
);
assert_eq!(&bytes, b"short");
}
// Seek must flush the BufWriter first so buffered bytes land at their
// intended offset, not the post-seek one. Removing the flush in `seek`
// would corrupt the file by writing the buffered bytes at the wrong offset.
#[test]
fn seek_flushes_buffer_before_moving() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("seek-flush.bin");
let mut s = LocalFileSink::create(&p).unwrap();
// These bytes sit in the 4 MiB BufWriter, unflushed.
s.write_all(b"HEAD").unwrap();
// Seek forward to offset 10; the buffered HEAD must be flushed
// to offset 0 BEFORE the position moves.
s.seek(SeekFrom::Start(10)).unwrap();
s.write_all(b"TAIL").unwrap();
s.sync_all().unwrap();
drop(s);
let bytes = std::fs::read(&p).unwrap();
assert_eq!(
&bytes[0..4],
b"HEAD",
"buffered head landed at the wrong offset"
);
assert_eq!(&bytes[10..14], b"TAIL");
}
}
-123
View File
@@ -1,123 +0,0 @@
//! Output-sink trait split for the buffering architecture.
//!
//! [`SequentialSink`] — anything you can `Write` to in order (no seek; sockets, pipes,
//! M2TS/fMP4/HEVC-ES) — and [`RandomAccessSink`] — `SequentialSink` plus a working `Seek`
//! (local/NFS files; needed for backpatch: MKV cluster sizes, Cues, MP4 moov-at-end). The muxer
//! is generic over whichever it requires, so muxing MKV to a socket is a compile error.
//! Buffering policy belongs to the concrete sink.
use std::io::{Seek, Write};
mod local_file;
mod preallocate;
mod socket;
pub use local_file::LocalFileSink;
pub use socket::{SocketSink, UdpSocketSink};
/// Sequential-only write destination. Sockets, pipes, append-only
/// stores. No seek. Implementations own their write buffering — the
/// trait does not impose or hide any buffering of its own.
///
/// `finish` drains any internal buffering and signals end-of-stream to the transport
/// (close-write on a socket, flush + fsync on a buffered file, etc.). The default impl flushes
/// via [`Write::flush`]; every concrete sink here overrides it with its own finalisation. There
/// is deliberately no blanket `impl SequentialSink for T`.
pub trait SequentialSink: Write + Send {
fn finish(&mut self) -> std::io::Result<()> {
self.flush()
}
}
/// Random-access write destination. Local files, NFS files, anything
/// with a working `Seek`. Inherits the `SequentialSink` contract — a
/// random-access sink is always usable as a sequential sink.
pub trait RandomAccessSink: SequentialSink + Seek {}
#[cfg(test)]
mod tests {
use super::*;
// Type-level assertion: the concrete sinks satisfy the trait
// objects. These functions never run; they just have to type-check.
fn _assert_is_sequential(_: &mut dyn SequentialSink) {}
fn _assert_is_random_access(_: &mut dyn RandomAccessSink) {}
#[test]
fn concrete_sinks_satisfy_traits() {
let dir = tempfile::tempdir().unwrap();
// `LocalFileSink` is a random-access (and thus sequential) sink.
let mut s = LocalFileSink::create(&dir.path().join("b.bin")).unwrap();
_assert_is_sequential(&mut s);
_assert_is_random_access(&mut s);
// `WritebackFile` ditto, via its explicit per-type impls.
let mut wf = crate::io::WritebackFile::create(&dir.path().join("c.bin")).unwrap();
_assert_is_sequential(&mut wf);
_assert_is_random_access(&mut wf);
}
// Regression test for the silent-no-op finish() bug.
#[test]
fn finish_through_trait_object_flushes_local_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("trait-finish.bin");
let sink = LocalFileSink::create(&p).unwrap();
// Box as the trait object the production path uses.
let mut boxed: Box<dyn SequentialSink> = Box::new(sink);
boxed.write_all(b"buffered-tail").unwrap();
// finish() through the vtable must drain the 4 MiB BufWriter and
// fsync; the bytes must be visible to a separate reader BEFORE
// we drop the sink (drop-flush must not be what saves us).
boxed.finish().unwrap();
let bytes = std::fs::read(&p).unwrap();
assert_eq!(&bytes[..], b"buffered-tail");
}
// ── Added hardening tests ───────────────────────────────────────
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
// Minimal SequentialSink that does NOT override finish(), so it exercises the trait's
// default impl.
struct FlushTracker {
flushed: Arc<AtomicBool>,
bytes: Arc<AtomicUsize>,
}
impl Write for FlushTracker {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.bytes.fetch_add(buf.len(), Ordering::SeqCst);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.flushed.store(true, Ordering::SeqCst);
Ok(())
}
}
// Uses the DEFAULT finish() — deliberately no override.
impl SequentialSink for FlushTracker {}
#[test]
fn default_finish_flushes() {
let flushed = Arc::new(AtomicBool::new(false));
let bytes = Arc::new(AtomicUsize::new(0));
let mut sink = FlushTracker {
flushed: flushed.clone(),
bytes: bytes.clone(),
};
sink.write_all(b"abc").unwrap();
assert!(
!flushed.load(Ordering::SeqCst),
"flush should not run before finish"
);
sink.finish().unwrap();
assert!(
flushed.load(Ordering::SeqCst),
"default SequentialSink::finish must call Write::flush"
);
assert_eq!(bytes.load(Ordering::SeqCst), 3);
}
}
-23
View File
@@ -1,23 +0,0 @@
//! Linux `fallocate(FALLOC_FL_KEEP_SIZE)` preallocation.
//!
//! `KEEP_SIZE` reserves extents without changing the apparent file
//! length, which matches the muxer's expectation that writes still grow
//! the file naturally.
use std::fs::File;
use std::os::unix::io::AsRawFd;
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
let fd = file.as_raw_fd();
// Clamp to the signed `off_t` range fallocate expects; an unchecked
// `as i64` cast would wrap a >= 2^63 size to a negative length that
// fallocate rejects with EINVAL (silent no-op).
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX);
// FALLOC_FL_KEEP_SIZE = 0x01.
let rc = unsafe { libc::fallocate(fd, libc::FALLOC_FL_KEEP_SIZE, 0, len) };
tracing::debug!(
target: "mux",
"LocalFileSink fallocate size_hint={size_bytes} rc={rc} ok={}",
rc == 0
);
}
-41
View File
@@ -1,41 +0,0 @@
//! macOS `F_PREALLOCATE` extent reservation.
//!
//! `fcntl(F_PREALLOCATE)` with `F_ALLOCATECONTIG | F_ALLOCATEALL` first
//! (prefer a contiguous run but accept scattered extents to satisfy the
//! full length) and fall back to `F_ALLOCATEALL` alone on failure.
//! Reported file size is unchanged — the muxer's writes still grow it.
use std::fs::File;
use std::os::unix::io::AsRawFd;
use crate::io::platform_macos::{
F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore,
};
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
let fd = file.as_raw_fd();
// Clamp to the signed `off_t` range; an unchecked `as off_t` cast
// would wrap a >= 2^63 size to a negative length.
let len = i64::try_from(size_bytes).unwrap_or(i64::MAX) as libc::off_t;
let mut store = Fstore {
// Prefer contiguous but accept scattered extents. Without F_ALLOCATEALL
// the first attempt is best-effort and can return rc=0 with a partial
// allocation, so the fallback below would never fire. Matches writeback_file/macos.rs.
fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL,
fst_posmode: F_PEOFPOSMODE,
fst_offset: 0,
fst_length: len,
fst_bytesalloc: 0,
};
let mut rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
if rc == -1 {
// Fall back to non-contiguous only.
store.fst_flags = F_ALLOCATEALL;
rc = unsafe { libc::fcntl(fd, F_PREALLOCATE, &mut store as *mut Fstore) };
}
tracing::debug!(
target: "mux",
"LocalFileSink F_PREALLOCATE size_hint={size_bytes} rc={rc} bytesalloc={}",
store.fst_bytesalloc
);
}
-27
View File
@@ -1,27 +0,0 @@
//! Per-OS extent preallocation. Best-effort; failures are logged at
//! debug and otherwise swallowed because the file is still usable
//! without the size reservation — only large-file fragmentation gets
//! marginally worse.
use std::fs::File;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
mod other;
#[cfg(target_os = "linux")]
use linux::preallocate_impl;
#[cfg(target_os = "macos")]
use macos::preallocate_impl;
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
use other::preallocate_impl;
/// Reserve `size_bytes` of disk space for `file`'s on-disk extents.
/// Reported file size is unchanged — writes still grow the file
/// naturally; only the allocator's extent map is primed.
pub(super) fn preallocate(file: &File, size_bytes: u64) {
preallocate_impl(file, size_bytes);
}
-10
View File
@@ -1,10 +0,0 @@
//! Fallback preallocate impl. No-op.
use std::fs::File;
pub(super) fn preallocate_impl(_file: &File, size_bytes: u64) {
tracing::debug!(
target: "mux",
"LocalFileSink preallocate size_hint={size_bytes} skipped (no platform impl)"
);
}
-343
View File
@@ -1,343 +0,0 @@
//! TCP / UDP socket sinks (sequential-only).
//!
//! [`SocketSink`] wraps a `TcpStream` in a 1 MiB `BufWriter`; `finish()` flushes then
//! `shutdown(Write)`s for a clean end-of-stream. [`UdpSocketSink`] wraps a connected
//! `UdpSocket`; each `write` emits one datagram and `finish()` is a no-op. Neither implements
//! `Seek`, so neither satisfies [`RandomAccessSink`].
//!
//! [`SequentialSink`]: super::SequentialSink
//! [`RandomAccessSink`]: super::RandomAccessSink
use std::io::{self, BufWriter, Write};
use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
use super::SequentialSink;
// `BufWriter` capacity for `SocketSink`. 1 MiB matches the typical kernel send-buffer ceiling.
const TCP_BUF_CAPACITY: usize = 1024 * 1024;
/// Sequential-only sink over a TCP connection.
///
/// Wraps a `BufWriter<TcpStream>`; the inner `TcpStream` is kept as a
/// clone so [`finish`](Self::finish) can call `shutdown(Write)` after
/// flushing the buffer (the buffered writer doesn't expose the socket
/// directly).
pub struct SocketSink {
/// Buffered write half. All payload bytes go through this.
buf: BufWriter<TcpStream>,
/// Shutdown handle — clone of the socket inside `buf`. Used only by
/// `finish()` for `shutdown(Write)`; never read or written through.
shutdown_handle: TcpStream,
}
impl SocketSink {
/// Open a TCP connection to `addr` and wrap it for sequential
/// writing. `sndbuf_bytes`, when present, is forwarded to
/// `setsockopt(SO_SNDBUF)` as a kernel hint — the OS may clamp it.
///
/// `addr` accepts anything `ToSocketAddrs` does: `"192.0.2.1:1234"`,
/// `("host", 1234)`, a `SocketAddr`, etc.
pub fn connect<A: ToSocketAddrs>(addr: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
let stream = TcpStream::connect(addr)?;
// `set_nodelay(true)` keeps small writes (TS packet trains, fMP4 moof headers)
// from sitting in Nagle's algorithm; BufWriter already absorbs syscall overhead.
// A latency hint, not a correctness requirement — a rejecting platform must not fail connect.
let _ = stream.set_nodelay(true);
if let Some(n) = sndbuf_bytes {
set_send_buffer(&stream, n)?;
}
let shutdown_handle = stream.try_clone()?;
Ok(Self {
buf: BufWriter::with_capacity(TCP_BUF_CAPACITY, stream),
shutdown_handle,
})
}
}
impl Write for SocketSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buf.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.buf.flush()
}
}
impl SequentialSink for SocketSink {
// Drains the BufWriter and shuts down writes so a `dyn SequentialSink`
// `finish()` sends the buffered tail and EOF instead of dropping them.
fn finish(&mut self) -> io::Result<()> {
self.buf.flush()?;
// `shutdown(Write)` signals clean EOF to the peer. Errors here
// are non-fatal — the connection may have already been torn down
// by the peer — but we surface them so callers can log.
self.shutdown_handle.shutdown(Shutdown::Write)
}
}
/// Sequential-only sink over a connected UDP socket.
///
/// Each [`write`](Write::write) call sends exactly one datagram. The
/// caller is responsible for splitting payload at packet boundaries —
/// for MPEG-TS this means 7 × 188 = 1316 bytes per datagram, the
/// industry standard for MPEG-TS-over-UDP. No buffering happens here;
/// adding it would silently merge datagrams.
///
/// `finish()` is a no-op: UDP has no end-of-stream marker. Closing the
/// socket happens on drop.
pub struct UdpSocketSink {
socket: UdpSocket,
}
impl UdpSocketSink {
/// Bind a local UDP socket to an ephemeral port and `connect` it to
/// `peer`. `connect` doesn't open a connection — it just fixes the
/// peer address so subsequent `send` calls don't need to repeat it,
/// and so receive-side filtering rejects packets from other sources.
///
/// `sndbuf_bytes`, when present, is a hint to `SO_SNDBUF`.
pub fn connect<A: ToSocketAddrs>(peer: A, sndbuf_bytes: Option<usize>) -> io::Result<Self> {
// Resolve the peer first so the local bind matches its address family:
// binding `0.0.0.0:0` (IPv4) then connecting to an IPv6 peer fails
// with EAFNOSUPPORT.
let peer_addr = peer
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::from(io::ErrorKind::AddrNotAvailable))?;
let bind_addr = match peer_addr {
SocketAddr::V4(_) => "0.0.0.0:0",
SocketAddr::V6(_) => "[::]:0",
};
// Bind to the matching wildcard / any port. The kernel picks an
// ephemeral source port and the source IP at first send.
let socket = UdpSocket::bind(bind_addr)?;
socket.connect(peer_addr)?;
if let Some(n) = sndbuf_bytes {
set_udp_send_buffer(&socket, n)?;
}
Ok(Self { socket })
}
}
impl Write for UdpSocketSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// `send` writes the entire datagram or fails — no partial sends
// for UDP. Match `Write::write`'s contract by reporting bytes
// accepted.
self.socket.send(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl SequentialSink for UdpSocketSink {
// No-op: `write` already sent each datagram unbuffered, and UDP has
// no end-of-stream marker to shut down.
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
}
// ── Platform `SO_SNDBUF` tuning ─────────────────────────────────────────
// std doesn't expose `SO_SNDBUF`; drop to libc on Linux + macOS. Other
// targets silently ignore the hint — socket still works, kernel picks its own size.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn set_send_buffer(stream: &TcpStream, bytes: usize) -> io::Result<()> {
use std::os::unix::io::AsRawFd;
setsockopt_sndbuf(stream.as_raw_fd(), bytes)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn set_udp_send_buffer(socket: &UdpSocket, bytes: usize) -> io::Result<()> {
use std::os::unix::io::AsRawFd;
setsockopt_sndbuf(socket.as_raw_fd(), bytes)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn set_send_buffer(_stream: &TcpStream, _bytes: usize) -> io::Result<()> {
// Non-Linux-non-macOS targets aren't in Cargo.toml's libc dep list; silently
// ignore the hint rather than failing the connect (intentionally no
// "sndbuf applied" signal — this is a hint, not a guarantee).
Ok(())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn set_udp_send_buffer(_socket: &UdpSocket, _bytes: usize) -> io::Result<()> {
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn setsockopt_sndbuf(fd: std::os::unix::io::RawFd, bytes: usize) -> io::Result<()> {
// Clamp into c_int range; SO_SNDBUF takes an `int` argument.
let want: libc::c_int = bytes.try_into().unwrap_or(libc::c_int::MAX);
let ret = unsafe {
libc::setsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_SNDBUF,
&want as *const _ as *const libc::c_void,
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
)
};
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::net::{TcpListener, UdpSocket};
use std::thread;
/// Bind a listener, accept on a thread, return (listener_addr,
/// accepted-bytes future via JoinHandle).
#[test]
fn socket_sink_round_trips_bytes() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let accept = thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = Vec::new();
sock.read_to_end(&mut buf).unwrap();
buf
});
let mut sink = SocketSink::connect(addr, Some(256 * 1024)).unwrap();
// Write enough to overflow the BufWriter at least once, then a
// tail that lives in the buffer until `finish` flushes.
let big: Vec<u8> = (0..(2 * TCP_BUF_CAPACITY))
.map(|i| (i & 0xff) as u8)
.collect();
sink.write_all(&big).unwrap();
sink.write_all(b"tail\n").unwrap();
sink.finish().unwrap();
drop(sink);
let received = accept.join().unwrap();
assert_eq!(received.len(), big.len() + 5);
assert_eq!(&received[..big.len()], &big[..]);
assert_eq!(&received[big.len()..], b"tail\n");
}
#[test]
fn socket_sink_coerces_to_sequential_sink() {
// Compile-time assertion via dyn — if this ever started
// satisfying `RandomAccessSink`, the trait split would be broken.
fn _assert_seq(_: &mut dyn super::super::SequentialSink) {}
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let _accept = thread::spawn(move || {
let _ = listener.accept();
});
let mut sink = SocketSink::connect(addr, None).unwrap();
_assert_seq(&mut sink);
// Only the positive coercion is checked: `RandomAccessSink` impls are explicit
// (local_file, writeback_file), so a socket needs a deliberate new impl + `Seek`.
}
#[test]
fn udp_socket_sink_binds_ipv6_for_an_ipv6_peer() {
let Ok(receiver) = UdpSocket::bind("[::1]:0") else {
return; // host has no IPv6 loopback
};
receiver
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let mut sink = UdpSocketSink::connect(receiver.local_addr().unwrap(), None).unwrap();
sink.write_all(&[7, 8, 9]).unwrap();
let mut buf = [0u8; 8];
let n = receiver.recv(&mut buf).unwrap();
assert_eq!(&buf[..n], &[7, 8, 9]);
}
#[test]
fn udp_socket_sink_delivers_datagrams() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
receiver
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let addr = receiver.local_addr().unwrap();
let mut sink = UdpSocketSink::connect(addr, Some(128 * 1024)).unwrap();
sink.write_all(&[1, 2, 3, 4, 5]).unwrap();
sink.write_all(&[9, 9, 9]).unwrap();
sink.finish().unwrap();
let mut buf = [0u8; 64];
let n1 = receiver.recv(&mut buf).unwrap();
assert_eq!(&buf[..n1], &[1, 2, 3, 4, 5]);
let n2 = receiver.recv(&mut buf).unwrap();
assert_eq!(&buf[..n2], &[9, 9, 9]);
}
// ── Added hardening tests ───────────────────────────────────────
// `finish` must `shutdown(Write)` so the peer's `read_to_end` sees EOF instead of blocking
// forever.
#[test]
fn finish_signals_eof_to_peer() {
use std::sync::mpsc;
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = Vec::new();
// Returns only when the peer half-closes (shutdown Write).
sock.read_to_end(&mut buf).unwrap();
let _ = tx.send(buf);
});
let mut sink = SocketSink::connect(addr, None).unwrap();
sink.write_all(b"unflushed-tail").unwrap();
sink.finish().unwrap();
// read_to_end must complete because finish() shut down writes.
let received = rx
.recv_timeout(Duration::from_secs(3))
.expect("peer never saw EOF — finish() did not shutdown(Write)");
assert_eq!(received, b"unflushed-tail");
}
// UDP `write` must emit one datagram per call, no coalescing.
#[test]
fn udp_write_is_one_datagram_per_call() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
receiver
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let addr = receiver.local_addr().unwrap();
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
// Distinct lengths so a merge would be detectable.
let n_a = sink.write(&[0xAA; 10]).unwrap();
let n_b = sink.write(&[0xBB; 20]).unwrap();
assert_eq!(n_a, 10);
assert_eq!(n_b, 20);
let mut buf = [0u8; 256];
let first = receiver.recv(&mut buf).unwrap();
assert_eq!(first, 10, "first datagram must be exactly 10 bytes");
assert!(buf[..first].iter().all(|&b| b == 0xAA));
let second = receiver.recv(&mut buf).unwrap();
assert_eq!(second, 20, "second datagram must be exactly 20 bytes");
assert!(buf[..second].iter().all(|&b| b == 0xBB));
}
// UDP `finish` is a documented no-op; must not error.
#[test]
fn udp_finish_is_noop_ok() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
let addr = receiver.local_addr().unwrap();
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
assert!(sink.finish().is_ok());
// A second finish is equally harmless.
assert!(sink.finish().is_ok());
}
}
-64
View File
@@ -370,17 +370,6 @@ impl Seek for WritebackFile {
}
}
impl super::sink::SequentialSink for WritebackFile {
// Same work as sync_all(); implemented explicitly (no blanket impl) so
// a `dyn SequentialSink`/`dyn RandomAccessSink` finish() finalises +
// fsyncs instead of hitting a no-op default.
fn finish(&mut self) -> io::Result<()> {
self.sync_all()
}
}
impl super::sink::RandomAccessSink for WritebackFile {}
impl Drop for WritebackFile {
fn drop(&mut self) {
// Run the pipeline's tail finalize (WAIT_AFTER + DONTNEED); otherwise a drop
@@ -476,59 +465,6 @@ mod tests {
assert_eq!(read_back(&p), b"onetwothree");
}
// finish() through a `dyn RandomAccessSink` must dispatch to
// WritebackFile's override (finalize + durable_sync), not a no-op.
#[test]
fn finish_through_trait_object_persists() {
use crate::io::sink::RandomAccessSink;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("finish-dyn.bin");
let w = WritebackFile::create(&p).unwrap();
let mut boxed: Box<dyn RandomAccessSink> = Box::new(w);
boxed.write_all(b"durable-tail").unwrap();
boxed.finish().unwrap();
assert_eq!(read_back(&p), b"durable-tail");
}
// The page cache serves the bytes back whether or not a durable sync ran, so count the
// final flush: `finish()` on a boxed sink must reach it (the trait default does not).
#[test]
fn finish_through_trait_object_runs_the_durable_sync() {
use crate::io::sink::RandomAccessSink;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Default)]
struct CountFinish(AtomicUsize);
impl FlushOps for CountFinish {
fn chunk(&self, _: &File) -> io::Result<()> {
Ok(())
}
fn range(&self, _: &File, _: u64, _: u64) -> Option<io::Result<()>> {
None
}
fn finish(&self, _: &File) -> io::Result<()> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn sample(&self) -> Option<u64> {
None
}
}
let ops = Arc::new(CountFinish::default());
let w = WritebackFile::with_flush_ops(
tempfile::tempfile().unwrap(),
ops.clone(),
FlushTiming::default(),
)
.unwrap();
let mut boxed: Box<dyn RandomAccessSink> = Box::new(w);
boxed.write_all(b"durable-tail").unwrap();
boxed.finish().unwrap();
assert!(
ops.0.load(Ordering::SeqCst) > 0,
"finish() skipped the final flush"
);
}
// A writeback error the pipeline latched (Linux: a failed WAIT_AFTER already
// consumed it, so fsync returns 0) must fail sync_all and every later write.
#[test]
+1 -1
View File
@@ -2,7 +2,7 @@
//! stream file (per-codec ES, PGS `.sup`, VobSub `.idx`/`.sub`, LPCM raw PCM),
//! plus a chapters file and per-audio-track delay metadata.
//!
//! This is a write-only [`crate::pes::Stream`] that routes each frame's payload to the file for
//! This is a write-only [`crate::pes::PesSink`] that routes each frame's payload to the file for
//! `frame.track`, post-processing where the codec's internal `Frame` form differs from the
//! on-disk ES form (HEVC/H.264 Annex-B, PGS `.sup`, VobSub `.idx`).
-729
View File
@@ -1,729 +0,0 @@
//! Fragmented MP4 muxer — **STUB**: fragment emission is not implemented.
//!
//! Goal: ISO/IEC 14496-12 fragmented MP4 (`ftyp` + `moov` init segment, then a sequence of
//! `moof+mdat` media fragments) targeting a
//! [`SequentialSink`](crate::io::sink::SequentialSink). DASH-friendly, no Cues backpatch. Emits
//! the init segment via [`Fmp4Mux::write_init_segment`]; [`Fmp4Mux::write_video`] returns
//! [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented).
use std::io::{self, Write};
// Box type literals — four-character codes per ISO/IEC 14496-12 §4.2.
const FTYP: [u8; 4] = *b"ftyp";
const MOOV: [u8; 4] = *b"moov";
const MVHD: [u8; 4] = *b"mvhd";
const TRAK: [u8; 4] = *b"trak";
const TKHD: [u8; 4] = *b"tkhd";
const MDIA: [u8; 4] = *b"mdia";
const MDHD: [u8; 4] = *b"mdhd";
const HDLR: [u8; 4] = *b"hdlr";
const MINF: [u8; 4] = *b"minf";
const VMHD: [u8; 4] = *b"vmhd";
const DINF: [u8; 4] = *b"dinf";
const DREF: [u8; 4] = *b"dref";
const URL_: [u8; 4] = *b"url ";
const STBL: [u8; 4] = *b"stbl";
const STSD: [u8; 4] = *b"stsd";
const STTS: [u8; 4] = *b"stts";
const STSC: [u8; 4] = *b"stsc";
const STSZ: [u8; 4] = *b"stsz";
const STCO: [u8; 4] = *b"stco";
const MVEX: [u8; 4] = *b"mvex";
const TREX: [u8; 4] = *b"trex";
/// Default movie timescale — 90 kHz lines up with MPEG-TS PTS and the
/// HEVC SPS `vui_time_scale` for film content, simplifying the math
/// when fragment emission lands.
const MOVIE_TIMESCALE: u32 = 90_000;
/// Video track ID. fMP4 init segments conventionally use track_ID=1
/// for the primary video track; a single-track DASH representation has
/// no reason to deviate.
const VIDEO_TRACK_ID: u32 = 1;
/// Fragmented MP4 muxer — stub.
///
/// See the module-level doc comment for what is and isn't shipped in
/// this stub. Fragment emission is not implemented:
/// [`write_video`](Self::write_video) returns
/// [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented)
/// rather than discarding media.
pub struct Fmp4Mux<W: Write> {
writer: W,
header_written: bool,
/// hvcC bytes, if provided. Embedded in the `moov.…stsd.hvc1.hvcC`
/// box once the emission path lands.
#[allow(dead_code)]
codec_private: Option<Vec<u8>>,
}
impl<W: Write> Fmp4Mux<W> {
pub fn new(writer: W) -> Self {
Self {
writer,
header_written: false,
codec_private: None,
}
}
/// Provide the `HEVCDecoderConfigurationRecord` for the video track.
/// The stub stores it but doesn't yet embed it in `moov` — that's
/// part of the unimplemented emission path.
pub fn set_video_codec_private(&mut self, hvcc: Vec<u8>) {
self.codec_private = Some(hvcc);
}
/// Emit the init segment (`ftyp` + `moov`) once. Idempotent — a
/// second call is a no-op. Lets a consumer that just wants the
/// container shape obtain a valid (if sample-less) init segment.
pub fn write_init_segment(&mut self) -> io::Result<()> {
if self.header_written {
return Ok(());
}
let ftyp = build_ftyp();
let moov = build_moov();
self.writer.write_all(&ftyp)?;
self.writer.write_all(&moov)?;
self.header_written = true;
Ok(())
}
/// Write one video PES frame.
///
/// **Stub:** `moof`/`mdat` emission is not implemented. To avoid
/// silently dropping media (and avoid unbounded buffering), this
/// emits the init segment on the first call and then returns
/// [`Error::Fmp4Unimplemented`](crate::error::Error::Fmp4Unimplemented).
/// No frame bytes are buffered or written.
pub fn write_video(&mut self, _pts_ns: i64, _keyframe: bool, _data: &[u8]) -> io::Result<()> {
self.write_init_segment()?;
Err(crate::error::Error::Fmp4Unimplemented.into())
}
/// Flush the underlying writer.
pub fn finish(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
/// Build the `ftyp` box. `major_brand = "iso6"`, `minor_version = 1`,
/// compatible brands `iso6 dash msdh hvc1` — the same conservative set
/// shaka-packager uses for HEVC-in-fMP4 outputs.
fn build_ftyp() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(b"iso6");
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(b"iso6");
body.extend_from_slice(b"dash");
body.extend_from_slice(b"msdh");
body.extend_from_slice(b"hvc1");
wrap_box(&FTYP, &body)
}
/// Build the `moov` box — minimal skeleton. Single video trak, no
/// hvcC inside stsd yet (stub: hvcC/hvc1 stsd entry not yet implemented).
fn build_moov() -> Vec<u8> {
let mvhd = build_mvhd();
let trak = build_video_trak();
let mvex = build_mvex();
let mut body = Vec::new();
body.extend_from_slice(&mvhd);
body.extend_from_slice(&trak);
body.extend_from_slice(&mvex);
wrap_box(&MOOV, &body)
}
fn build_mvhd() -> Vec<u8> {
// Version 0, 100 bytes total body. Fields per ISO/IEC 14496-12 §8.2.2.
let mut body = Vec::new();
body.extend_from_slice(&[0, 0, 0, 0]); // version + flags
body.extend_from_slice(&0u32.to_be_bytes()); // creation_time
body.extend_from_slice(&0u32.to_be_bytes()); // modification_time
body.extend_from_slice(&MOVIE_TIMESCALE.to_be_bytes());
body.extend_from_slice(&0u32.to_be_bytes()); // duration = 0 (fragmented)
body.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0
body.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0
body.extend_from_slice(&[0u8; 2]); // reserved
body.extend_from_slice(&[0u8; 8]); // reserved
// 3x3 identity transformation matrix in 16.16 fixed point.
for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] {
body.extend_from_slice(&v.to_be_bytes());
}
body.extend_from_slice(&[0u8; 24]); // pre_defined[6]
body.extend_from_slice(&2u32.to_be_bytes()); // next_track_ID (1 reserved for video)
wrap_box(&MVHD, &body)
}
fn build_video_trak() -> Vec<u8> {
let tkhd = build_tkhd();
let mdia = build_mdia();
let mut body = Vec::new();
body.extend_from_slice(&tkhd);
body.extend_from_slice(&mdia);
wrap_box(&TRAK, &body)
}
fn build_tkhd() -> Vec<u8> {
let mut body = Vec::new();
// version=0 | flags=0x000007 (track_enabled | in_movie | in_preview)
body.extend_from_slice(&[0, 0, 0, 7]);
body.extend_from_slice(&0u32.to_be_bytes()); // creation_time
body.extend_from_slice(&0u32.to_be_bytes()); // modification_time
body.extend_from_slice(&VIDEO_TRACK_ID.to_be_bytes());
body.extend_from_slice(&[0u8; 4]); // reserved
body.extend_from_slice(&0u32.to_be_bytes()); // duration
body.extend_from_slice(&[0u8; 8]); // reserved
body.extend_from_slice(&0u16.to_be_bytes()); // layer
body.extend_from_slice(&0u16.to_be_bytes()); // alternate_group
body.extend_from_slice(&0u16.to_be_bytes()); // volume (video=0)
body.extend_from_slice(&[0u8; 2]); // reserved
// 3x3 identity matrix.
for v in [0x1_0000u32, 0, 0, 0, 0x1_0000, 0, 0, 0, 0x4000_0000] {
body.extend_from_slice(&v.to_be_bytes());
}
// width / height in 16.16 fixed point — placeholder; replace with
// SPS-derived dimensions (and matching stsd visual width/height) when
// fragment emission lands.
body.extend_from_slice(&(1920u32 << 16).to_be_bytes());
body.extend_from_slice(&(1080u32 << 16).to_be_bytes());
wrap_box(&TKHD, &body)
}
fn build_mdia() -> Vec<u8> {
let mdhd = build_mdhd();
let hdlr = build_hdlr_vide();
let minf = build_minf();
let mut body = Vec::new();
body.extend_from_slice(&mdhd);
body.extend_from_slice(&hdlr);
body.extend_from_slice(&minf);
wrap_box(&MDIA, &body)
}
fn build_mdhd() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0, 0, 0, 0]); // version + flags
body.extend_from_slice(&0u32.to_be_bytes()); // creation_time
body.extend_from_slice(&0u32.to_be_bytes()); // modification_time
body.extend_from_slice(&MOVIE_TIMESCALE.to_be_bytes());
body.extend_from_slice(&0u32.to_be_bytes()); // duration
// language: 'und' in 5-bit-per-char ISO 639-2 packed (bit 15 = 0).
body.extend_from_slice(&[0x55, 0xC4]);
body.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
wrap_box(&MDHD, &body)
}
fn build_hdlr_vide() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0, 0, 0, 0]); // version + flags
body.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
body.extend_from_slice(b"vide");
body.extend_from_slice(&[0u8; 12]); // reserved
body.extend_from_slice(b"VideoHandler\0");
wrap_box(&HDLR, &body)
}
fn build_minf() -> Vec<u8> {
let vmhd = build_vmhd();
let dinf = build_dinf();
let stbl = build_stbl();
let mut body = Vec::new();
body.extend_from_slice(&vmhd);
body.extend_from_slice(&dinf);
body.extend_from_slice(&stbl);
wrap_box(&MINF, &body)
}
fn build_vmhd() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0, 0, 0, 1]); // version + flags=1
body.extend_from_slice(&0u16.to_be_bytes()); // graphicsmode
body.extend_from_slice(&[0u8; 6]); // opcolor
wrap_box(&VMHD, &body)
}
fn build_dinf() -> Vec<u8> {
let mut dref_body = Vec::new();
dref_body.extend_from_slice(&[0, 0, 0, 0]);
dref_body.extend_from_slice(&1u32.to_be_bytes()); // entry_count
// url with flags=1 (self-contained) and zero name.
let url_body = [0u8, 0, 0, 1];
dref_body.extend_from_slice(&wrap_box(&URL_, &url_body));
let dref = wrap_box(&DREF, &dref_body);
wrap_box(&DINF, &dref)
}
fn build_stbl() -> Vec<u8> {
// Stub stsd: zero entries. Must land together with build_mvex's trex —
// once hvc1+hvcC is added here (entry_count=1), trex's
// default_sample_description_index=1 becomes valid.
let mut stsd_body = Vec::new();
stsd_body.extend_from_slice(&[0, 0, 0, 0]);
stsd_body.extend_from_slice(&0u32.to_be_bytes()); // entry_count
let stsd = wrap_box(&STSD, &stsd_body);
// Empty stts/stsc/stsz/stco — fragmented init has no samples here.
let stts = wrap_box(&STTS, &[0, 0, 0, 0, 0, 0, 0, 0]); // version+flags, count=0
let stsc = wrap_box(&STSC, &[0, 0, 0, 0, 0, 0, 0, 0]);
let stsz = wrap_box(
&STSZ,
&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // version+flags, sample_size=0, count=0
);
let stco = wrap_box(&STCO, &[0, 0, 0, 0, 0, 0, 0, 0]);
let mut body = Vec::new();
body.extend_from_slice(&stsd);
body.extend_from_slice(&stts);
body.extend_from_slice(&stsc);
body.extend_from_slice(&stsz);
body.extend_from_slice(&stco);
wrap_box(&STBL, &body)
}
fn build_mvex() -> Vec<u8> {
// trex: track_ID=1, default_sample_description_index=1, others=0.
// dsdi=1 is only valid once build_stbl's stsd has the matching hvc1
// entry (entry_count=1) — keep the two in sync.
let mut trex_body = Vec::new();
trex_body.extend_from_slice(&[0, 0, 0, 0]); // version + flags
trex_body.extend_from_slice(&VIDEO_TRACK_ID.to_be_bytes());
trex_body.extend_from_slice(&1u32.to_be_bytes()); // default_sample_description_index
trex_body.extend_from_slice(&0u32.to_be_bytes()); // default_sample_duration
trex_body.extend_from_slice(&0u32.to_be_bytes()); // default_sample_size
trex_body.extend_from_slice(&0u32.to_be_bytes()); // default_sample_flags
let trex = wrap_box(&TREX, &trex_body);
wrap_box(&MVEX, &trex)
}
// Wrap a box body in `[size:u32-BE][type:4]`; oversized boxes (>4 GiB) need the 64-bit
// large-size extension, not generated in the stub.
fn wrap_box(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
let total = body.len() + 8;
debug_assert!(total <= u32::MAX as usize, "fMP4 box exceeds u32 size");
let size = u32::try_from(total).unwrap_or(u32::MAX);
let mut out = Vec::with_capacity(total);
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(box_type);
out.extend_from_slice(body);
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Decode the first box's size + type from `buf`.
fn read_box_header(buf: &[u8]) -> (u32, [u8; 4]) {
let size = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
let bt = [buf[4], buf[5], buf[6], buf[7]];
(size, bt)
}
#[test]
fn init_segment_starts_with_ftyp_then_moov() {
let mut sink: Vec<u8> = Vec::new();
let mut mux = Fmp4Mux::new(&mut sink);
mux.write_init_segment().unwrap();
mux.finish().unwrap();
drop(mux);
let (ftyp_size, ftyp_type) = read_box_header(&sink);
assert_eq!(&ftyp_type, b"ftyp");
assert!(ftyp_size >= 24, "ftyp too small: {ftyp_size}");
let (moov_size, moov_type) = read_box_header(&sink[ftyp_size as usize..]);
assert_eq!(&moov_type, b"moov");
assert!(moov_size > 100, "moov skeleton too small: {moov_size}");
// Stub guarantee: no media bytes after the init segment.
let total = ftyp_size as usize + moov_size as usize;
assert_eq!(sink.len(), total, "stub leaked media bytes past moov");
}
#[test]
fn write_video_reports_unimplemented_and_buffers_nothing() {
// write_video must NOT silently accept-and-drop media: it emits the
// init segment, then signals that fragment emission is unimplemented.
let mut sink: Vec<u8> = Vec::new();
let mut mux = Fmp4Mux::new(&mut sink);
let err = mux.write_video(0, true, &[0xDE; 4096]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
mux.finish().unwrap();
drop(mux);
// Only the init segment (ftyp + moov) was written — no media bytes.
let (ftyp_size, _) = read_box_header(&sink);
let (moov_size, _) = read_box_header(&sink[ftyp_size as usize..]);
assert_eq!(sink.len(), ftyp_size as usize + moov_size as usize);
}
#[test]
fn moov_contains_trak_mvex() {
let mut buf: Vec<u8> = Vec::new();
let mut mux2 = Fmp4Mux::new(&mut buf);
mux2.write_init_segment().unwrap();
mux2.finish().unwrap();
drop(mux2);
// Find moov payload start.
let (ftyp_size, _) = read_box_header(&buf);
let moov_start = ftyp_size as usize;
let (moov_size, _) = read_box_header(&buf[moov_start..]);
let moov_payload = &buf[moov_start + 8..moov_start + moov_size as usize];
// Scan for the trak and mvex four-CC anywhere in the moov payload.
let has_trak = moov_payload.windows(4).any(|w| w == b"trak");
let has_mvex = moov_payload.windows(4).any(|w| w == b"mvex");
assert!(has_trak, "moov missing trak");
assert!(has_mvex, "moov missing mvex");
}
// ISO/IEC 14496-12 box-tree invariants: every box is [size:u32-BE][type:4]
// [body], size covers the header too. A wrong size silently desyncs every
// ISO BMFF/DASH parser, so these tests walk the tree byte-exactly.
/// Walk a flat sequence of top-level boxes, returning
/// (type, box_start, box_total_size). Asserts each declared size lands
/// exactly on a box boundary (no overlap, no gap, no overrun).
fn walk_boxes(buf: &[u8]) -> Vec<([u8; 4], usize, usize)> {
let mut out = Vec::new();
let mut pos = 0;
while pos + 8 <= buf.len() {
let (size, bt) = read_box_header(&buf[pos..]);
let size = size as usize;
assert!(size >= 8, "box {bt:?} size {size} < 8-byte header");
assert!(
pos + size <= buf.len(),
"box {bt:?} at {pos} size {size} overruns buffer {}",
buf.len()
);
out.push((bt, pos, size));
pos += size;
}
assert_eq!(pos, buf.len(), "boxes did not tile the buffer exactly");
out
}
/// Find the immediate child box of the given type within a container's
/// payload (the bytes after the 8-byte header). Returns the child's full
/// box slice. Recurses one level only.
fn child<'a>(container_payload: &'a [u8], want: &[u8; 4]) -> Option<&'a [u8]> {
let mut pos = 0;
while pos + 8 <= container_payload.len() {
let (size, bt) = read_box_header(&container_payload[pos..]);
let size = size as usize;
if size < 8 || pos + size > container_payload.len() {
return None;
}
if &bt == want {
return Some(&container_payload[pos..pos + size]);
}
pos += size;
}
None
}
fn init_segment() -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
let mut mux = Fmp4Mux::new(&mut buf);
mux.write_init_segment().unwrap();
mux.finish().unwrap();
drop(mux);
buf
}
#[test]
fn init_segment_box_sizes_tile_exactly() {
// Top level must be exactly [ftyp][moov] with no slack.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let types: Vec<[u8; 4]> = boxes.iter().map(|(t, _, _)| *t).collect();
assert_eq!(types, vec![*b"ftyp", *b"moov"]);
}
#[test]
fn ftyp_major_brand_and_compatible_brands() {
// ISO/IEC 14496-12 §4.3 ftyp layout: major_brand(4)+minor_version(4)+
// compatible_brands[]. A dropped/reordered brand breaks DASH negotiation.
let buf = init_segment();
let (ftyp_size, _) = read_box_header(&buf);
let body = &buf[8..ftyp_size as usize];
assert_eq!(&body[0..4], b"iso6", "major_brand");
assert_eq!(
u32::from_be_bytes([body[4], body[5], body[6], body[7]]),
1,
"minor_version"
);
// Remaining bytes are 4-byte compatible brands.
let brands = &body[8..];
assert_eq!(brands.len() % 4, 0, "compatible_brands must be 4-byte each");
let set: Vec<&[u8]> = brands.chunks(4).collect();
assert!(set.contains(&&b"iso6"[..]));
assert!(set.contains(&&b"dash"[..]));
assert!(set.contains(&&b"msdh"[..]));
assert!(set.contains(&&b"hvc1"[..]), "HEVC brand required for hvc1");
}
#[test]
fn moov_child_order_is_mvhd_trak_mvex() {
// §8.1: moov contains mvhd then track(s) then mvex (for fragmented).
// Order matters for some strict parsers; assert the exact child
// sequence rather than mere presence.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let children = walk_boxes(moov_payload);
let types: Vec<[u8; 4]> = children.iter().map(|(t, _, _)| *t).collect();
assert_eq!(types, vec![*b"mvhd", *b"trak", *b"mvex"]);
}
#[test]
fn mvhd_timescale_and_next_track_id() {
// §8.2.2 mvhd v0: version+flags(4) creation(4) modification(4)
// timescale(4) duration(4) ... next_track_ID(4). Stub uses 90000 Hz
// and next_track_ID=2 (track 1 reserved for video).
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let mvhd = child(moov_payload, b"mvhd").expect("mvhd present");
let body = &mvhd[8..]; // skip box header
assert_eq!(&body[0..4], &[0, 0, 0, 0], "mvhd version 0, flags 0");
// timescale is at body offset 12 (after version+flags, creation, mod).
let timescale = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
assert_eq!(timescale, MOVIE_TIMESCALE);
assert_eq!(timescale, 90_000, "spec-fixed default timescale");
// next_track_ID is the last 4 bytes of the body.
let n = body.len();
let next_id = u32::from_be_bytes([body[n - 4], body[n - 3], body[n - 2], body[n - 1]]);
assert_eq!(next_id, 2, "next_track_ID must exceed the sole track ID");
}
#[test]
fn trex_references_video_track_id() {
// §8.8.3 trex: track_ID must match the trak's track_ID (1) so the
// fragment defaults bind to the right track. A mismatch would make
// every future moof default-sample lookup target a non-existent track.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let mvex = child(moov_payload, b"mvex").expect("mvex");
let trex = child(&mvex[8..], b"trex").expect("trex");
let body = &trex[8..];
// version+flags(4), then track_ID(4).
let track_id = u32::from_be_bytes([body[4], body[5], body[6], body[7]]);
assert_eq!(track_id, VIDEO_TRACK_ID);
assert_eq!(track_id, 1);
// default_sample_description_index(4) must be 1 (points at stsd entry 1).
let dsdi = u32::from_be_bytes([body[8], body[9], body[10], body[11]]);
assert_eq!(dsdi, 1);
}
#[test]
fn tkhd_track_id_matches_trex() {
// §8.3.2 tkhd: the track_ID field (after version+flags, creation,
// modification) must equal VIDEO_TRACK_ID and the trex track_ID, or the
// fragment defaults never bind. tkhd is moov.trak.tkhd.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let trak = child(moov_payload, b"trak").expect("trak");
let tkhd = child(&trak[8..], b"tkhd").expect("tkhd");
let body = &tkhd[8..];
// version(1)+flags(3), creation(4), modification(4), then track_ID(4).
let track_id = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
assert_eq!(track_id, VIDEO_TRACK_ID, "tkhd track_ID must match trex");
// flags = 0x000007 (enabled | in_movie | in_preview), §8.3.1.
assert_eq!(&body[0..4], &[0, 0, 0, 7]);
}
#[test]
fn stbl_present_with_empty_sample_tables() {
// Fragmented init segment carries no samples in moov: stsd entry_count
// is 0 and stts/stsc/stsz/stco are empty (current stub state). If stsd
// ever gains an hvc1 entry, trex's dsdi=1 becomes meaningful — see build_mvex.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let trak = child(moov_payload, b"trak").expect("trak");
let mdia = child(&trak[8..], b"mdia").expect("mdia");
let minf = child(&mdia[8..], b"minf").expect("minf");
let stbl = child(&minf[8..], b"stbl").expect("stbl");
let stsd = child(&stbl[8..], b"stsd").expect("stsd");
let body = &stsd[8..];
// version+flags(4), entry_count(4).
let entry_count = u32::from_be_bytes([body[4], body[5], body[6], body[7]]);
assert_eq!(entry_count, 0, "stub stsd has no sample entries yet");
// All of stts/stsc/stsz/stco must be present children of stbl.
for fourcc in [b"stts", b"stsc", b"stsz", b"stco"] {
assert!(
child(&stbl[8..], fourcc).is_some(),
"stbl missing {:?}",
std::str::from_utf8(fourcc).unwrap()
);
}
}
#[test]
fn hdlr_declares_video_handler() {
// §8.4.3 hdlr: handler_type must be 'vide' for a video track, else
// players won't route the track to the video decoder. Path:
// moov.trak.mdia.hdlr; handler_type is at body offset 8.
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let trak = child(moov_payload, b"trak").expect("trak");
let mdia = child(&trak[8..], b"mdia").expect("mdia");
let hdlr = child(&mdia[8..], b"hdlr").expect("hdlr");
let body = &hdlr[8..];
// version+flags(4), pre_defined(4), handler_type(4).
assert_eq!(&body[8..12], b"vide", "handler_type must be 'vide'");
}
// mdhd (§8.4.2), vmhd (§12.1.2), dinf/dref (§8.7.1-2) are mandatory
// in a video track's media tree; none is read back by this crate,
// so only the spec-fixed field values constrain them here.
#[test]
fn mdhd_vmhd_and_dinf_carry_the_values_the_spec_fixes() {
let buf = init_segment();
let boxes = walk_boxes(&buf);
let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap();
let moov_payload = &buf[moov_start + 8..moov_start + moov_size];
let trak = child(moov_payload, b"trak").expect("trak");
let mdia = child(&trak[8..], b"mdia").expect("mdia");
let minf = child(&mdia[8..], b"minf").expect("minf");
// ── mdhd. Version 0 layout: vflags(4) creation(4) modification(4)
// timescale(4) duration(4) language(2) pre_defined(2) = 24 bytes.
let mdhd = child(&mdia[8..], b"mdhd").expect("mdia must carry mdhd");
let body = &mdhd[8..];
assert_eq!(body[0], 0, "mdhd version 0 (32-bit times)");
assert_eq!(
body.len(),
24,
"version-0 mdhd body: vflags(4) creation(4) modification(4) timescale(4) duration(4) language(2) pre_defined(2)"
);
let media_ts = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
assert_ne!(
media_ts, 0,
"every fragment's tfdt is expressed in this timescale; zero is a divide-by-zero"
);
// The mvhd timescale is the movie clock; this single-track init has one
// clock, so the media timescale must agree with it rather than carry an
// independent copy that can drift.
let mvhd = child(moov_payload, b"mvhd").expect("mvhd");
let mvhd_body = &mvhd[8..];
let movie_ts =
u32::from_be_bytes([mvhd_body[12], mvhd_body[13], mvhd_body[14], mvhd_body[15]]);
assert_eq!(media_ts, movie_ts, "media clock must match the movie clock");
// language: ISO 639-2/T packed as three 5-bit values, bit 15 = 0 (§8.4.2).
let packed = u16::from_be_bytes([body[20], body[21]]);
assert_eq!(packed & 0x8000, 0, "language pad bit must be 0");
let lang: String = (0..3)
.map(|i| (((packed >> (10 - 5 * i)) & 0x1F) as u8 + 0x60) as char)
.collect();
assert_eq!(lang, "und", "unknown language is 'und', not empty or 'eng'");
// ── vmhd. §12.1.2 fixes flags to 1 and the stub picks the neutral
// compositing mode: graphicsmode 0 (copy) with a zero opcolor.
let vmhd = child(&minf[8..], b"vmhd").expect("minf must carry vmhd");
let body = &vmhd[8..];
assert_eq!(body[0], 0, "vmhd version 0");
assert_eq!(
u32::from_be_bytes([0, body[1], body[2], body[3]]),
1,
"vmhd flags must be 1"
);
assert_eq!(body.len(), 12, "vmhd body: vflags(4) mode(2) opcolor(6)");
assert_eq!(
u16::from_be_bytes([body[4], body[5]]),
0,
"graphicsmode copy"
);
assert_eq!(&body[6..12], &[0u8; 6], "opcolor is black");
// ── dinf > dref > 'url ' with flags 1: the media lives in this same
// file/segment, so no external location follows (§8.7.2).
let dinf = child(&minf[8..], b"dinf").expect("minf must carry dinf");
let dref = child(&dinf[8..], b"dref").expect("dinf must carry dref");
let body = &dref[8..];
assert_eq!(
u32::from_be_bytes([body[4], body[5], body[6], body[7]]),
1,
"dref entry_count"
);
let url = child(&body[8..], b"url ").expect("dref must carry a 'url ' entry");
assert_eq!(
u32::from_be_bytes([0, url[9], url[10], url[11]]),
1,
"url flags=1 means self-contained"
);
assert_eq!(
url.len(),
12,
"a self-contained url is header(8) + vflags(4) and no location string"
);
}
#[test]
fn wrap_box_size_includes_header() {
// §4.2: a box's size field counts the full box including the 8-byte
// header. A body of N bytes yields size N+8 and the type at offset 4.
let body = [0xAAu8; 13];
let boxed = wrap_box(b"test", &body);
assert_eq!(boxed.len(), 13 + 8);
let (size, bt) = read_box_header(&boxed);
assert_eq!(size as usize, 13 + 8, "size must include the 8-byte header");
assert_eq!(&bt, b"test");
assert_eq!(&boxed[8..], &body);
// Empty body → just the 8-byte header.
let empty = wrap_box(b"free", &[]);
assert_eq!(empty.len(), 8);
assert_eq!(
u32::from_be_bytes([empty[0], empty[1], empty[2], empty[3]]),
8
);
}
#[test]
fn write_init_segment_is_idempotent() {
// The doc contract says a second write_init_segment is a no-op. A
// regression that re-emitted ftyp+moov would produce two init segments
// and corrupt the stream.
let mut buf: Vec<u8> = Vec::new();
let mut mux = Fmp4Mux::new(&mut buf);
mux.write_init_segment().unwrap();
mux.write_init_segment().unwrap(); // second call must be a no-op
mux.finish().unwrap();
drop(mux);
// Exactly one ftyp + one moov.
let boxes = walk_boxes(&buf);
let ftyp_count = boxes.iter().filter(|(t, _, _)| t == b"ftyp").count();
let moov_count = boxes.iter().filter(|(t, _, _)| t == b"moov").count();
assert_eq!(ftyp_count, 1, "second write_init_segment must be a no-op");
assert_eq!(moov_count, 1);
}
#[test]
fn write_video_after_init_still_unimplemented_and_no_media() {
// Even after init segment emission, write_video must keep returning
// Unimplemented and append no media bytes (no moof/mdat), so a caller
// can't be fooled into thinking the call succeeded.
let mut buf: Vec<u8> = Vec::new();
let mut mux = Fmp4Mux::new(&mut buf);
mux.write_init_segment().unwrap();
let err = mux.write_video(0, true, &[0u8; 8]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
mux.finish().unwrap();
drop(mux);
// Still only ftyp + moov.
let boxes = walk_boxes(&buf);
let types: Vec<[u8; 4]> = boxes.iter().map(|(t, _, _)| *t).collect();
assert_eq!(types, vec![*b"ftyp", *b"moov"]);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! `fvi://` sink — freemkv's own native video-index output.
//!
//! A write-only [`crate::pes::Stream`] that emits one machine-readable
//! A [`crate::pes::PesSink`] that emits one machine-readable
//! *video-index* record per coded picture of the title's primary video
//! track, instead of muxing frames into a container.
//!
+6 -257
View File
@@ -1,108 +1,10 @@
//! HEVC (H.265) elementary stream muxer — Annex B byte stream.
//!
//! Consumes [`PesFrame`](crate::pes::PesFrame)s for a single video track and writes them as a
//! raw `.hevc` / `.h265` Annex B byte stream with no container framing.
//!
//! Sequential-only — no Cues, no backpatch. Target sink is any
//! [`SequentialSink`](crate::io::sink::SequentialSink): file, socket,
//! pipe, anything `Write + Send`.
use std::io::{self, Write};
//! H.264 / HEVC parameter-set helpers: decoder configuration records (`avcC` / `hvcC`) and
//! length-prefixed NAL units as an Annex B byte stream, for the elementary-stream sinks and
//! the TS muxer.
/// Annex B 4-byte start code.
pub(crate) const START_CODE: [u8; 4] = [0x00, 0x00, 0x00, 0x01];
/// HEVC NAL unit type bits live in `(byte0 >> 1) & 0x3F` in Annex B.
/// We don't filter NAL types here — the muxer is format-only — but we
/// keep the constant as documentation of the field layout.
#[allow(dead_code)]
const HEVC_NAL_TYPE_MASK: u8 = 0x3F;
/// Streaming HEVC Annex B muxer.
///
/// One instance per output stream. Tracks whether parameter sets have
/// already been emitted so they're written exactly once at the head of
/// the stream, per the Annex B convention of ITU-T H.265 / ISO/IEC
/// 23008-2 (parameter sets precede the coded slices they govern).
pub struct HevcMux<W: Write> {
writer: W,
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
/// on the first `write_frame` so callers can set it after
/// construction but before the first frame.
codec_private: Option<Vec<u8>>,
/// Set once VPS/SPS/PPS have been written to the stream. Subsequent
/// frames write only their own NAL units.
params_written: bool,
}
impl<W: Write> HevcMux<W> {
/// Construct over `writer`. The muxer does not impose any extra
/// buffering of its own — the sink owns its write buffering policy
/// (see [`LocalFileSink`](crate::io::sink::LocalFileSink) and
/// [`SocketSink`](crate::io::sink::SocketSink)).
pub fn new(writer: W) -> Self {
Self {
writer,
codec_private: None,
params_written: false,
}
}
/// Provide the `HEVCDecoderConfigurationRecord` (hvcC) so the muxer
/// can prepend VPS/SPS/PPS Annex B NALs at stream start. Optional —
/// if the PES frames already carry inline parameter sets (some
/// upstream demuxers do this), skipping this call is fine.
pub fn set_codec_private(&mut self, data: Vec<u8>) {
self.codec_private = Some(data);
}
/// Write one PES frame (= one access unit) as Annex B NAL units.
///
/// Input may be either length-prefixed (`[u32-BE len][NAL bytes]` repeated, the form
/// libfreemkv's HEVC parser emits) — converted to Annex B — or already Annex B (a buffer
/// beginning with a start code), passed through unchanged.
///
/// `_pts_ns` is accepted for symmetry with other muxers but ignored
/// — Annex B has no timing layer.
pub fn write_frame(&mut self, _pts_ns: i64, data: &[u8]) -> io::Result<()> {
if !self.params_written {
// Mark written *before* the write: on a write error, a later
// re-entry must not re-emit VPS/SPS/PPS on top of bytes the sink
// already received. Callers discard the mux on any write error.
self.params_written = true;
if let Some(cp) = &self.codec_private {
match hvcc_to_annex_b(cp) {
Some(params) => self.writer.write_all(&params)?,
// A non-empty hvcC yielding no NAL is a caller contract
// violation: emitting without VPS/SPS/PPS is undecodable.
None if !cp.is_empty() => {
return Err(crate::error::Error::HevcParamParse.into());
}
None => {}
}
}
}
// The hvcC declares the NAL length-prefix width (ISO/IEC 14496-15
// §8.3.3.1.2 `lengthSizeMinusOne + 1`). Assuming 4 for a source that
// declares 1 or 2 emits the raw prefixed bytes with no start codes.
let length_size = nal_length_size(crate::disc::Codec::Hevc, self.codec_private.as_deref());
let annex_b = if starts_with_start_code(data) {
data.to_vec()
} else {
let mut out = Vec::with_capacity(data.len() + (data.len() / 32));
append_length_prefixed_as_annex_b_sized(&mut out, data, length_size);
out
};
self.writer.write_all(&annex_b)
}
/// Flush the underlying writer. No trailer NAL is needed — an Annex
/// B stream ends whenever the file/socket ends.
pub fn finish(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
// Convert a HEVCDecoderConfigurationRecord (hvcC) into Annex B NAL units. Single source of
// truth for hvcC -> Annex B across all muxers (HEVC ES, BD-TS, standard MPEG-TS) — do not
// reimplement.
@@ -164,6 +66,7 @@ fn hvcc_parse(hvcc: &[u8]) -> (Option<Vec<u8>>, bool) {
// Convert length-prefixed NALs ([u32-BE len][NAL] repeated) to Annex B; already-Annex-B input
// passes through unchanged. Truncation policy (shared across muxers): drop truncated trailing
// NAL.
#[cfg(test)]
pub(crate) fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
// Probe for a leading Annex B start code before attempting to parse
// length prefixes: `00 00 00 01` would otherwise parse as length 1.
@@ -197,6 +100,7 @@ pub(crate) fn nal_length_size(codec: crate::disc::Codec, record: Option<&[u8]>)
// Annex B form of length-prefixed `data`, written into caller-owned `out` to avoid an
// allocation on hot per-frame paths. Non-length-prefixed input is appended unchanged (assumed
// already Annex B).
#[cfg(test)]
pub(crate) fn append_length_prefixed_as_annex_b(out: &mut Vec<u8>, data: &[u8]) {
append_length_prefixed_as_annex_b_sized(out, data, DEFAULT_NAL_LENGTH_SIZE);
}
@@ -252,6 +156,7 @@ pub(crate) fn append_length_prefixed_as_annex_b_sized(
/// Whether `data` begins with a 4-byte (`00 00 00 01`) or 3-byte
/// (`00 00 01`) Annex B start code.
#[cfg(test)]
fn starts_with_start_code(data: &[u8]) -> bool {
data.starts_with(&START_CODE) || data.starts_with(&[0x00, 0x00, 0x01])
}
@@ -437,17 +342,6 @@ mod tests {
assert_eq!(&annex_b[..], &want[..]);
}
#[test]
fn write_frame_errors_on_unparseable_non_empty_hvcc() {
// A non-empty hvcC that yields no NAL must surface an error
// instead of silently producing a parameter-set-less stream.
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
mux.set_codec_private(vec![0xDE, 0xAD]); // too short to be valid hvcC
let err = mux.write_frame(0, &[]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn zero_length_nal_is_skipped_not_bare_start_code() {
// A zero-length prefix between two real NALs must be skipped, not
@@ -744,151 +638,6 @@ mod tests {
assert!(!starts_with_start_code(&[]));
}
// --- HevcMux: params-once + error semantics ---
#[test]
fn mux_empty_codec_private_emits_no_params() {
// An EMPTY (not absent) hvcC: hvcc_to_annex_b returns None, but cp is
// empty so it's NOT a contract violation → no error, just no params.
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
mux.set_codec_private(Vec::new());
let mut frame = 2u32.to_be_bytes().to_vec();
frame.extend_from_slice(&[0xAA, 0xBB]);
mux.write_frame(0, &frame).unwrap();
mux.finish().unwrap();
// Only the frame NAL, no parameter sets.
let mut want = START_CODE.to_vec();
want.extend_from_slice(&[0xAA, 0xBB]);
assert_eq!(sink, want);
}
#[test]
fn mux_no_codec_private_writes_frames_only() {
// No hvcC set at all: frames pass through, no params, no error.
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
let mut frame = 2u32.to_be_bytes().to_vec();
frame.extend_from_slice(&[0xAA, 0xBB]);
mux.write_frame(0, &frame).unwrap();
let mut want = START_CODE.to_vec();
want.extend_from_slice(&[0xAA, 0xBB]);
assert_eq!(sink, want);
}
#[test]
fn mux_params_not_re_emitted_after_unparseable_error() {
// params_written is set BEFORE the write, so after an error on the first
// frame, a retry must NOT re-emit params (the comment's invariant).
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
mux.set_codec_private(vec![0xDE, 0xAD]); // unparseable, non-empty → error
assert!(mux.write_frame(0, &[]).is_err(), "first frame errors");
// A second frame must not retry the (already-marked) params.
let mut frame = 2u32.to_be_bytes().to_vec();
frame.extend_from_slice(&[0xAA, 0xBB]);
mux.write_frame(0, &frame).unwrap();
// Sink holds only the frame NAL — no parameter bytes, no duplication.
let mut want = START_CODE.to_vec();
want.extend_from_slice(&[0xAA, 0xBB]);
assert_eq!(sink, want, "params not re-emitted after the error");
}
#[test]
fn mux_annex_b_frame_passes_through() {
// A frame already in Annex B (leading start code) is written verbatim,
// not re-framed as length-prefixed.
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
let frame = [0x00, 0x00, 0x00, 0x01, 0x26, 0x01, 0xDE];
mux.write_frame(0, &frame).unwrap();
assert_eq!(sink, frame);
}
#[test]
fn mux_writes_params_then_frames() {
// Build hvcC with one SPS to verify params-once semantics.
let mut hvcc = vec![0u8; 22];
hvcc.push(1);
hvcc.push(33);
hvcc.extend_from_slice(&1u16.to_be_bytes());
hvcc.extend_from_slice(&3u16.to_be_bytes());
hvcc.extend_from_slice(&[0x42, 0x01, 0x01]);
let mut frame_data = Vec::new();
frame_data.extend_from_slice(&2u32.to_be_bytes());
frame_data.extend_from_slice(&[0xAA, 0xBB]);
let mut sink: Vec<u8> = Vec::new();
let mut mux = HevcMux::new(&mut sink);
mux.set_codec_private(hvcc);
mux.write_frame(0, &frame_data).unwrap();
// Second frame — no SPS re-emission.
mux.write_frame(40_000_000, &frame_data).unwrap();
mux.finish().unwrap();
// SPS NAL (7 bytes) + 2× frame NAL (6 bytes) = 19 bytes.
assert_eq!(sink.len(), 7 + 6 + 6);
// Start codes at offsets 0 (SPS), 7 (frame1), 13 (frame2).
assert_eq!(&sink[0..4], &START_CODE);
assert_eq!(&sink[7..11], &START_CODE);
assert_eq!(&sink[13..17], &START_CODE);
assert_eq!(sink[4], 0x42);
assert_eq!(sink[11], 0xAA);
assert_eq!(sink[17], 0xAA);
}
// A sink that records only what actually reaches it, so "was flush called"
// is MEASURED rather than assumed. Its own `flush` is a no-op — the
// intermediate `BufWriter` must be told to hand its bytes over.
#[derive(Clone, Default)]
struct SharedSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl SharedSink {
fn bytes(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
impl Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// `finish()` is the ONLY thing that pushes a buffered sink's tail to the
// file: a `finish` that skipped the flush truncates every `.hevc` output
// by up to a whole sink buffer, producing a file with a missing last GOP.
#[test]
fn finish_flushes_the_buffered_sink_or_the_stream_tail_is_lost() {
let sink = SharedSink::default();
let mut mux = HevcMux::new(io::BufWriter::new(sink.clone()));
// One length-prefixed NAL: [len:u32-BE][NAL bytes] → Annex B.
let nal = [0x26u8, 0x01, 0xAA, 0xBB]; // (0x26 >> 1) = 19 = IDR_W_RADL
let mut frame = (nal.len() as u32).to_be_bytes().to_vec();
frame.extend_from_slice(&nal);
mux.write_frame(0, &frame).unwrap();
assert!(
sink.bytes().is_empty(),
"the fixture must actually buffer, or this test proves nothing"
);
mux.finish().unwrap();
let out = sink.bytes();
let mut expected = START_CODE.to_vec();
expected.extend_from_slice(&nal);
assert_eq!(
out, expected,
"finish must deliver the whole Annex B stream to the sink"
);
}
#[test]
fn avcc_truncated_pps_keeps_sps() {
// numPPS=1 but the PPS body runs past the end: SPS is salvaged.
File diff suppressed because it is too large Load Diff
-378
View File
@@ -1,378 +0,0 @@
//! 188-byte MPEG-TS packet builder + writer.
//!
//! Internal helper for `super::M2tsMux`. Not public API — exposes raw
//! byte layout so the parent module can compose PSI / PCR / PES bytes
//! without each caller re-implementing the 188-byte boundary math.
use crate::error::Error;
use std::io::{self, Write};
use crate::consts::TS_PACKET_BYTES;
/// Header is 4 bytes, leaving 184 bytes for the adaptation field area
/// plus payload. With a 1-byte `adaptation_field_length` prefix the
/// field body + stuffing can be at most 183 bytes.
const MAX_AF_LEN: usize = TS_PACKET_BYTES - 4 - 1;
const SYNC_BYTE: u8 = 0x47;
const STUFF_BYTE: u8 = 0xFF;
// One TS packet under construction, backed by a fixed 188-byte array with
// a write cursor (no per-packet heap allocation). pad_to_188() fills to
// 188 bytes; otherwise the caller must fill it exactly.
pub(super) struct Packet {
buf: [u8; TS_PACKET_BYTES],
len: usize,
}
impl Packet {
pub(super) fn new() -> Self {
Self {
buf: [0u8; TS_PACKET_BYTES],
len: 0,
}
}
/// Push a byte, saturating at the packet boundary. The boundary is
/// never reached by the sole caller (mod.rs sizes every field to sum
/// to 188); the bound prevents a future caller from corrupting memory.
fn push(&mut self, b: u8) {
if self.len < TS_PACKET_BYTES {
self.buf[self.len] = b;
self.len += 1;
}
}
fn extend(&mut self, bytes: &[u8]) {
let n = bytes.len().min(TS_PACKET_BYTES - self.len);
self.buf[self.len..self.len + n].copy_from_slice(&bytes[..n]);
self.len += n;
}
// Write the 4-byte TS packet header.
pub(super) fn set_header(
&mut self,
pid: u16,
payload_unit_start: bool,
has_payload: bool,
has_adaptation: bool,
cc: u8,
) {
self.len = 0;
self.push(SYNC_BYTE);
let pus_bit = if payload_unit_start { 0x40 } else { 0 };
// transport_error_indicator(1)=0 | payload_unit_start(1) | transport_priority(1)=0 | PID(5 high)
self.push(pus_bit | ((pid >> 8) as u8 & 0x1F));
self.push(pid as u8);
// transport_scrambling_control(2)=0 | adaptation_field_control(2) | continuity_counter(4)
let afc = match (has_adaptation, has_payload) {
(false, false) => 0b00, // reserved — should not happen
(false, true) => 0b01, // payload only
(true, false) => 0b10, // adaptation only
(true, true) => 0b11, // both
};
self.push((afc << 4) | (cc & 0x0F));
}
// Append the adaptation field after the header: `body` is the field body (flags + optional
// PCR etc.), `stuffing` is the trailing 0xFF count.
pub(super) fn append_adaptation(&mut self, body: &[u8], stuffing: usize) -> io::Result<()> {
let af_len = body.len() + stuffing;
if af_len > MAX_AF_LEN {
return Err(Error::M2tsPacketMalformed.into());
}
self.push(af_len as u8);
self.extend(body);
for _ in 0..stuffing {
self.push(STUFF_BYTE);
}
Ok(())
}
// Append payload bytes. Errors with M2tsPacketMalformed rather than
// silently truncating if this would push the packet past 188 bytes —
// overflow is a muxer invariant break.
pub(super) fn append_payload(&mut self, payload: &[u8]) -> io::Result<()> {
if self.len + payload.len() > TS_PACKET_BYTES {
return Err(Error::M2tsPacketMalformed.into());
}
self.extend(payload);
Ok(())
}
// Pad the packet to exactly 188 bytes with 0xFF — for PSI emit paths
// where the section is much smaller than 184 bytes. Payload-carrying
// packets reserve stuffing room via append_adaptation instead.
pub(super) fn pad_to_188(&mut self) {
while self.len < TS_PACKET_BYTES {
self.push(STUFF_BYTE);
}
}
pub(super) fn bytes(&self) -> &[u8] {
&self.buf[..self.len]
}
pub(super) fn len(&self) -> usize {
self.len
}
}
// Writer for assembled TS packets. Owns the sink and writes each
// 188-byte packet straight through with no buffering of its own — wrap
// the sink in a BufWriter if buffering is needed.
pub(super) struct PacketWriter<W: Write> {
inner: W,
}
impl<W: Write> PacketWriter<W> {
pub(super) fn new(inner: W) -> Self {
Self { inner }
}
pub(super) fn write_packet(&mut self, packet: &Packet) -> io::Result<()> {
let bytes = packet.bytes();
// Hard check, not a debug_assert: a non-188-byte packet would
// corrupt the transport stream, so refuse to write it in any
// build rather than emitting a short/long packet silently.
if bytes.len() != TS_PACKET_BYTES {
return Err(Error::M2tsPacketMalformed.into());
}
self.inner.write_all(bytes)
}
pub(super) fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pad_fills_to_188() {
let mut p = Packet::new();
p.set_header(0x100, true, true, false, 0);
p.append_payload(&[1, 2, 3]).unwrap();
p.pad_to_188();
assert_eq!(p.bytes().len(), 188);
assert_eq!(p.bytes()[0], SYNC_BYTE);
// After 4-byte header + 3 payload, byte 7 starts stuffing.
assert_eq!(p.bytes()[7], STUFF_BYTE);
}
#[test]
fn append_adaptation_rejects_overflow() {
let mut p = Packet::new();
p.set_header(0x100, true, true, true, 0);
// body(1) + stuffing(MAX_AF_LEN) = MAX_AF_LEN + 1 > MAX_AF_LEN.
let err = p.append_adaptation(&[0x00], MAX_AF_LEN).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn append_payload_rejects_overflow() {
let mut p = Packet::new();
p.set_header(0x100, true, true, false, 0);
// 4-byte header + 185 payload = 189 > 188.
let err = p.append_payload(&[0u8; 185]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn write_packet_rejects_short_packet() {
let mut p = Packet::new();
p.set_header(0x100, true, true, false, 0);
p.append_payload(&[1, 2, 3]).unwrap(); // only 7 bytes, not padded
let mut sink: Vec<u8> = Vec::new();
let mut w = PacketWriter::new(&mut sink);
let err = w.write_packet(&p).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert!(sink.is_empty(), "short packet must not be written");
}
#[test]
fn header_pid_round_trips() {
let mut p = Packet::new();
p.set_header(0x1ABC, false, true, false, 0xA);
let pid = u16::from_be_bytes([p.bytes()[1] & 0x1F, p.bytes()[2]]);
assert_eq!(pid, 0x1ABC);
assert_eq!(p.bytes()[3] & 0x0F, 0xA);
}
// ════════════════════════════════════════════════════════════════════
// Added hardening tests
// ════════════════════════════════════════════════════════════════════
#[test]
fn header_sync_byte_and_pusi_bit() {
// ISO 13818-1: sync_byte 0x47 at byte 0; PUSI is bit 6 of byte 1.
let mut p = Packet::new();
p.set_header(0x0100, true, true, false, 0);
assert_eq!(p.bytes()[0], SYNC_BYTE);
assert_eq!(p.bytes()[1] & 0x40, 0x40, "PUSI set");
// transport_error_indicator (bit 7) and priority (bit 5) clear.
assert_eq!(p.bytes()[1] & 0x80, 0, "TEI clear");
assert_eq!(p.bytes()[1] & 0x20, 0, "transport_priority clear");
let mut p2 = Packet::new();
p2.set_header(0x0100, false, true, false, 0);
assert_eq!(p2.bytes()[1] & 0x40, 0, "PUSI clear when not a unit start");
}
#[test]
fn header_afc_bits_per_combination() {
// adaptation_field_control (bits 5:4 of byte 3), ISO 13818-1
// Table 2-5: 01 payload only, 10 AF only, 11 both, 00 reserved.
let cases = [
(false, true, 0b01u8),
(true, false, 0b10),
(true, true, 0b11),
(false, false, 0b00),
];
for (af, pl, want) in cases {
let mut p = Packet::new();
p.set_header(0x0100, true, pl, af, 0);
assert_eq!((p.bytes()[3] >> 4) & 0x03, want, "AFC for af={af} pl={pl}");
}
}
#[test]
fn append_adaptation_length_byte_matches_written_bytes() {
// The adaptation_field_length byte must equal body+stuffing — the
// written length and declared length must agree or a decoder
// misframes the payload.
let mut p = Packet::new();
p.set_header(0x0100, true, true, true, 0);
p.append_adaptation(&[0x10, 0xAA, 0xBB], 4).unwrap(); // 3 body + 4 stuffing
// byte 4 is the length byte.
assert_eq!(p.bytes()[4], 3 + 4, "length byte = body+stuffing");
// body bytes follow.
assert_eq!(&p.bytes()[5..8], &[0x10, 0xAA, 0xBB]);
// stuffing bytes are 0xFF.
assert_eq!(&p.bytes()[8..12], &[0xFF; 4]);
}
#[test]
fn append_adaptation_at_exact_max_succeeds() {
// MAX_AF_LEN (183) is the largest legal adaptation field body+stuff.
// Exactly MAX_AF_LEN must succeed; the boundary itself is valid.
let mut p = Packet::new();
p.set_header(0x0100, true, true, true, 0);
assert!(p.append_adaptation(&[0x00], MAX_AF_LEN - 1).is_ok());
assert_eq!(p.bytes()[4] as usize, MAX_AF_LEN);
}
#[test]
fn append_payload_at_exact_boundary_fills_188() {
// 4-byte header + 184 payload = exactly 188 (no AF). The boundary
// must be accepted, not rejected.
let mut p = Packet::new();
p.set_header(0x0100, true, true, false, 0);
assert!(p.append_payload(&[0xAB; 184]).is_ok());
assert_eq!(p.len(), 188);
}
#[test]
fn pad_to_188_is_idempotent_when_already_full() {
// Padding a packet that already reached 188 bytes must not grow it
// past 188 (the push() bound prevents overflow).
let mut p = Packet::new();
p.set_header(0x0100, true, true, false, 0);
p.append_payload(&[0xAB; 184]).unwrap();
assert_eq!(p.len(), 188);
p.pad_to_188();
assert_eq!(p.len(), 188, "no growth past 188");
}
#[test]
fn write_packet_rejects_long_packet() {
// A packet whose len somehow exceeds 188 must be refused (the writer
// checks exact equality). We can't push past 188 (push saturates),
// so test the under-188 rejection path which the writer guards.
let mut p = Packet::new();
p.set_header(0x0100, true, true, false, 0);
p.append_payload(&[1, 2, 3, 4, 5]).unwrap(); // 9 bytes, not 188
let mut sink: Vec<u8> = Vec::new();
let mut w = PacketWriter::new(&mut sink);
assert!(w.write_packet(&p).is_err());
assert!(sink.is_empty());
}
#[test]
fn write_packet_accepts_exactly_188() {
// A correctly-sized 188-byte packet must be written through verbatim.
let mut p = Packet::new();
p.set_header(0x0100, true, true, false, 0);
p.append_payload(&[0x5A; 184]).unwrap();
let mut sink: Vec<u8> = Vec::new();
{
let mut w = PacketWriter::new(&mut sink);
w.write_packet(&p).unwrap();
}
assert_eq!(sink.len(), 188);
assert_eq!(sink[0], SYNC_BYTE);
}
#[test]
fn pid_high_bits_masked_to_13_bits() {
// PID is 13 bits. Bits above 0x1FFF must not leak into the
// transport_priority / PUSI / TEI bits of byte 1.
let mut p = Packet::new();
// 0xE100 has bits set above the 13-bit PID range.
p.set_header(0xE100, false, true, false, 0);
assert_eq!(
p.bytes()[1] & 0xE0,
0,
"top 3 bits of byte1 are flags, not PID"
);
let pid = u16::from_be_bytes([p.bytes()[1] & 0x1F, p.bytes()[2]]);
assert_eq!(pid, 0xE100 & 0x1FFF, "PID masked to 13 bits");
}
// Records only what actually reaches it, so "was flush called" is
// MEASURED not assumed. Its own flush is a no-op — the point is that
// the intermediate BufWriter must be told to hand bytes over.
#[derive(Clone, Default)]
struct SharedSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl SharedSink {
fn bytes(&self) -> Vec<u8> {
self.0.lock().unwrap().clone()
}
}
impl Write for SharedSink {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// PacketWriter buffers nothing itself, so flush() is the only thing that empties the sink's
// buffer — skipping it truncates the stream mid-packet.
#[test]
fn flush_delivers_the_buffered_packets_to_the_sink() {
let sink = SharedSink::default();
let mut w = PacketWriter::new(io::BufWriter::new(sink.clone()));
let mut p = Packet::new();
p.set_header(0x1011, true, true, false, 3);
p.append_payload(&[0x11, 0x22, 0x33]).unwrap();
p.pad_to_188();
w.write_packet(&p).unwrap();
assert!(
sink.bytes().is_empty(),
"the fixture must actually buffer, or this test proves nothing"
);
w.flush().unwrap();
let out = sink.bytes();
assert_eq!(out.len(), TS_PACKET_BYTES, "one whole 188-byte TS packet");
assert_eq!(out[0], SYNC_BYTE, "ISO/IEC 13818-1 sync_byte 0x47");
assert_eq!(out, p.bytes(), "the bytes written are the packet's own");
}
}
+4 -15
View File
@@ -1,9 +1,6 @@
//! Stream-based I/O pipeline. Two muxer families live here:
//!
//! 1. **Bidirectional PES streams** (`disc`, `mkv`, `m2ts`, `network`, `stdio`, `null`) implement the [`crate::pes::Stream`] interface: read a format → PES frames, or write PES frames → a format.
//! 2. **Write-only sequential-sink muxers** (`fmp4`, `hevc`, `m2ts_mux`) consume PES frames and write a container to a `SequentialSink`; they do not implement the read loop below.
//!
//! The bidirectional family is driven like this:
//! The PES half of the pipeline: inputs implement [`crate::pes::PesSource`] (read a format →
//! PES frames), outputs [`crate::pes::PesSink`] (write PES frames → a format). Every input
//! scheme opens through [`open_source`] / [`input`], every output through [`output`].
//!
//! ```text
//! let mut input = input("iso://Disc.iso", &opts)?;
@@ -15,7 +12,7 @@
//! output.finish()?;
//! ```
//!
//! For disc→ISO (raw sector copy), use `freemkv_engine::recovery::copy` instead.
//! A whole-disc copy (`iso://`, `dir://`) is a block chain: see [`crate::io::open_block_sink`].
// Public modules — types here are intentionally part of the consumable API.
pub mod driver;
@@ -51,16 +48,8 @@ pub(crate) mod m2ts;
pub mod meta;
pub(crate) mod meta_sink;
// ── Sequential-sink muxers ── write-only PES → `SequentialSink`. `pub(crate)`+
// `allow(dead_code)`: `fmp4` is a STUB (pub would lock a half-built type into
// v1.0); `m2ts_mux`/`hevc` are sink-split scaffolding (production: `tsmux`/`mkv`).
pub(crate) mod fit;
#[allow(dead_code)]
pub(crate) mod fmp4;
#[allow(dead_code)]
pub(crate) mod hevc;
#[allow(dead_code)]
pub(crate) mod m2ts_mux;
pub(crate) mod mkv;
pub(crate) mod mkvstream;
pub(crate) mod mp4;
+1 -1
View File
@@ -1,5 +1,5 @@
//! `mpg://`: an ISO/IEC 13818-1 program stream written from the PES IR (mpg-output-design
//! v5 §2, L2), one more `pes::Stream` sink.
//! v5 §2, L2), one more `PesSink`.
//!
//! The pack layer is regenerated: packs, SCR, mux rate, system header, PSM, PES headers
//! and DTS. ES bytes are kept (DVD LPCM is re-packed losslessly, G8); PTS is kept exactly,
+1 -1
View File
@@ -1,7 +1,7 @@
//! `PipelinedPesStream` — the read-side of the freemkv mux highway.
//!
//! Given a [`crate::mux::demux_thread::DemuxThread`] and a set of codec
//! parsers, this struct implements [`crate::pes::Stream`] by running codec
//! parsers, this struct implements [`crate::pes::PesSource`] by running codec
//! parse on the caller's thread and emitting `PesFrame`s one at a time.
//!
//! ```text
+8 -12
View File
@@ -759,11 +759,11 @@ pub(crate) fn output_with(
StreamUrl::Stdio => Ok(Box::new(StdioStream::output(title))),
StreamUrl::Null => Ok(Box::new(NullStream::new(title))),
StreamUrl::Disc { .. } => Err(crate::error::Error::StreamReadOnly.into()),
StreamUrl::Iso { .. } => Err(crate::error::Error::StreamReadOnly.into()),
// `dir://` is NOT a PES sink — it writes raw decrypted files, not
// muxed frames. A stray `dir://` here fails loudly, like `iso://`.
// The CLI routes a `dir://` dest to `Disc::extract_tree` first.
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamReadOnly.into()),
// `iso://` and `dir://` are block outputs, not PES sinks: a whole-disc copy writes
// them (`crate::io::open_block_sink`, `Disc::extract_tree`).
StreamUrl::Iso { .. } | StreamUrl::Dir { .. } => {
Err(crate::error::Error::StreamReadOnly.into())
}
// `demux://` with default options. The CLI constructs `DemuxSink`
// directly (with parsed flags) before reaching here; this arm
// covers the bare `output()` call with the default option set.
@@ -1019,8 +1019,8 @@ fn iso_pipeline_tail(
// undetectable by the tile check. Fall back to timestamps if mismatched.
let feed_matches_spans = extents == full_extents;
// The mux does not tally decrypt-quality misses (`lost_bytes()` is read-loss
// only); a missing key is an up-front resolve failure. Wrong-substream fix
// below: re-route declared AC-3 audio onto correct `0x8x` sub-streams.
// only); a missing key is an up-front resolve failure. The DVD AC-3 probe below is
// diagnostics only (routing is the scanner's PGC AST_CTL map).
let mut title = title;
if !feed_matches_spans {
tracing::info!(
@@ -1477,11 +1477,7 @@ mod tests {
)
.err()
.expect("no such drive");
assert_ne!(
crate::error::error_code(&err),
Some(crate::error::E_DISC_URL_NOT_DIRECT),
"got {err}"
);
assert_ne!(err.kind(), std::io::ErrorKind::Unsupported, "got {err}");
}
/// null:// is write-only per the table — input() must reject it with
+2 -1
View File
@@ -32,6 +32,7 @@ pub(crate) fn is_video_pid(pid: u16) -> bool {
// 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.
#[cfg(test)]
pub(crate) const ORIGIN_HEADROOM_NS: i64 = 1_000_000_000;
// The same headroom in 90 kHz ticks: 1 s is exactly 90 000 ticks.
const ORIGIN_HEADROOM_TICKS: i64 = 90_000;
@@ -88,7 +89,7 @@ pub struct TsMuxer<W: Write> {
video_codec: Vec<Codec>,
/// Global PTS origin in integer 90 kHz ticks, seeded by the FIRST frame of any
/// kind (video or audio) so a single fixed origin rebases every frame and the
/// audio/video offset is preserved. It lies [`ORIGIN_HEADROOM_NS`] (90 000 ticks)
/// audio/video offset is preserved. It lies `ORIGIN_HEADROOM_TICKS` (1 s, 90 000 ticks)
/// before the seeding frame's tick; only a frame earlier than that saturates to 0.
origin_ticks: Option<i64>,
/// Count of PES frames actually emitted (a frame dropped as non-key
+6 -6
View File
@@ -24,7 +24,7 @@ use std::sync::Arc;
/// calls it once and keeps nothing; it stays `Send + Sync` without requiring
/// `KeySource: Send`.
///
/// [`KeyRing::resolve`]: crate::keys::KeyRing::resolve
/// [`KeyRing::resolve`]: crate::keys::KeyRing::acquire
pub type KeySourceFactory = Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>;
/// Which optical device a [`DiscSession`] should open.
@@ -147,7 +147,7 @@ impl DiscSession {
/// [`Self::open`] under the caller's op token (stop design §2.2): the drive checks
/// `halt` on every CDB, a Stop during the bring-up ends it `Halted` (the handle is
/// closed), and the session keeps the token for [`Self::scan_with`] and
/// [`Self::resolve_key_set`].
/// [`Self::acquire_keys`].
pub fn open_with(target: DeviceTarget, spec: KeySpec, halt: &Halt) -> Result<DiscSession> {
let drive = match target {
DeviceTarget::Path(ref path) => Drive::open_with(path, halt)?,
@@ -233,7 +233,7 @@ impl DiscSession {
}
/// Report the op's forward progress to `p` (T29): every drive CDB (as
/// [`Drive::attach_progress`]) and every key-source call in [`Self::resolve_key_set`].
/// [`Drive::attach_progress`]) and every key-source call in [`Self::acquire_keys`].
pub fn attach_progress(&mut self, p: &Liveness) {
if let Some(drive) = self.drive.as_mut() {
drive.attach_progress(p);
@@ -435,7 +435,7 @@ impl DiscSession {
/// Stage the owned drive as the session's boxed sector source so a live
/// single-pass mux can drive it through
/// [`MuxSource::Session`](crate::mux::MuxSource::Session). Moves the `Drive`
/// [`Source::from_session`](crate::mux::Source::from_session). Moves the `Drive`
/// (itself a [`SectorSource`]) into the `reader` slot; the cached
/// [`Self::device_path`] keeps the device name available afterward. A no-op
/// if the drive was already staged or moved out.
@@ -463,7 +463,7 @@ impl DiscSession {
/// Take the staged sector source out of the session by mutable borrow,
/// leaving `None` behind. Used by [`crate::mux::mux_with_keys`]'s
/// [`MuxSource::Session`](crate::mux::MuxSource::Session) arm, which drives
/// [`Source::from_session`](crate::mux::Source::from_session) arm, which drives
/// the mux from `&mut DiscSession` and so cannot consume the whole session.
/// A second call (or a call before the reader is staged) returns `None`, and
/// the driver maps that to a clean error rather than a panic (see Q2 of the
@@ -517,7 +517,7 @@ impl DiscSession {
/// [`Disc`] together with a reusable [`SectorSource`] over the same file.
///
/// This is the file-backed counterpart to [`DiscSession::scan`]: it opens a
/// [`FileSectorSource`], reads its capacity, and runs [`Disc::scan_image`].
/// [`crate::sector::FileSectorSource`], reads its capacity, and runs [`Disc::scan_image`].
/// No SCSI, no handshake, no key resolution beyond what `opts` already
/// carries. The returned reader is a fresh handle at the start of the image,
/// reusable by callers that need to sample ciphertext or feed a mux.
+1 -4
View File
@@ -591,11 +591,8 @@ const ALLOW_BASELINE: &[(&str, &str, usize)] = &[
("src/mpls.rs", r#"allow(dead_code)"#, 2),
("src/mux/demux_sink.rs", r#"allow(dead_code)"#, 2),
("src/mux/demux_thread.rs", r#"allow(dead_code)"#, 1),
("src/mux/fmp4/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/hevc/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/m2ts_mux/mod.rs", r#"allow(dead_code)"#, 1),
("src/mux/mkv.rs", r#"allow(clippy::too_many_arguments)"#, 3),
("src/mux/mod.rs", r#"allow(dead_code)"#, 6),
("src/mux/mod.rs", r#"allow(dead_code)"#, 3),
("src/mux/pipelined_stream.rs", r#"allow(dead_code)"#, 1),
(
"src/mux/resolve.rs",