mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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:
+3
-1
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¶ms)?,
|
||||
// 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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user