diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ce7b558..b6854ae 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: mp4forge Version description: Which version are you using? - placeholder: "0.5.0" + placeholder: "0.6.0" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eacf07..c81d85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.6.0 (April 26, 2026) + +- Added an additive Tokio-based `async` feature for the library, covering seekable async traversal, extraction, typed codec decode and encode, writer flows, rewrite flows, probe surfaces, and top-level `sidx` helpers while keeping the CLI on the established synchronous path +- Strengthened the async rollout so the supported library paths are multithread-safe under normal `tokio::spawn` use for independent-file workloads, with dedicated worker-thread and concurrent-file parity coverage +- Added a focused Tokio async example, updated crate and README guidance for the supported async scope, and preserved the default sync build without changing the existing non-async public API +- Expanded regression and comparison-backed parity coverage to lock sync-versus-async behavior across shared MP4, fragmented, encrypted, and metadata-rich fixture sets + # 0.5.0 (April 25, 2026) - Added first-class encrypted metadata coverage for typed `senc`, typed `sgpd(seig)`, resolved sample-encryption helpers, and broader encrypted fragmented fixture coverage across extraction, rewrite, and probe flows diff --git a/Cargo.toml b/Cargo.toml index b1d92b9..aa5e372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mp4forge" -version = "0.5.0" +version = "0.6.0" edition = "2024" rust-version = "1.88" authors = ["bakgio"] @@ -18,11 +18,13 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = [] +async = ["dep:tokio"] serde = ["dep:serde"] [dependencies] serde = { version = "1", features = ["derive"], optional = true } terminal_size = "0.4" +tokio = { version = "1.52.1", features = ["fs", "io-util", "rt", "rt-multi-thread", "macros"], optional = true } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index ac98030..8c1de03 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ ```toml [dependencies] -mp4forge = "0.5.0" +mp4forge = "0.6.0" # With optional features: -# mp4forge = { version = "0.5.0", features = ["serde"] } +# mp4forge = { version = "0.6.0", features = ["async"] } +# mp4forge = { version = "0.6.0", features = ["serde"] } ``` Install the CLI from crates.io: @@ -49,9 +50,14 @@ The published crate includes both the library and the `mp4forge` binary from `sr ## Feature Flags -`mp4forge` keeps the default dependency surface minimal and currently exposes one optional public -feature flag: +`mp4forge` keeps the default dependency surface minimal and currently exposes these optional public +feature flags: +- `async`: enables the additive library-side async I/O surface for seekable readers and writers. + This rollout is Tokio-based, expects a Tokio runtime in the caller, targets seekable + `AsyncRead + AsyncSeek` and `AsyncWrite + AsyncSeek` inputs and outputs, supports normal + multithreaded `tokio::spawn` usage for the supported library paths, and keeps the current CLI on + the existing sync path. - `serde`: derives `Serialize` and `Deserialize` for the reusable public report structs under `mp4forge::cli::probe` and `mp4forge::cli::dump`, along with their nested public codec-detail, media-characteristics, `FieldValue`, and `FourCc` data. This is intended for library-side report @@ -101,7 +107,7 @@ field-order hints. Pass `-detail light` for a lighter-weight probe that skips pe per-chunk, bitrate, and IDR aggregation, or use `mp4forge::probe::ProbeOptions` from the library when you need the same control programmatically. -> See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage patterns. +> See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage patterns, including the Tokio-based async library example behind the optional `async` feature. ## License diff --git a/examples/probe_async.rs b/examples/probe_async.rs new file mode 100644 index 0000000..19fe8f1 --- /dev/null +++ b/examples/probe_async.rs @@ -0,0 +1,74 @@ +#[cfg(feature = "async")] +use std::env; +#[cfg(feature = "async")] +use std::error::Error; +#[cfg(feature = "async")] +use std::io; + +#[cfg(feature = "async")] +use mp4forge::probe::probe_async; +#[cfg(feature = "async")] +use tokio::fs::File; + +#[cfg(feature = "async")] +type ExampleError = Box; + +#[cfg(feature = "async")] +#[tokio::main(flavor = "multi_thread")] +async fn main() { + if let Err(error) = run().await { + eprintln!("{error}"); + std::process::exit(1); + } +} + +#[cfg(feature = "async")] +async fn run() -> Result<(), ExampleError> { + let input_paths = env::args().skip(1).collect::>(); + if input_paths.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: probe_async INPUT.mp4 [MORE.mp4 ...]", + ) + .into()); + } + + let mut handles = Vec::new(); + for input_path in input_paths { + handles.push(tokio::spawn(async move { probe_file(input_path).await })); + } + + for handle in handles { + handle + .await + .map_err(|error| io::Error::other(format!("probe task failed: {error}")))??; + } + + Ok(()) +} + +#[cfg(not(feature = "async"))] +fn main() { + eprintln!( + "enable the async feature: cargo run --example probe_async --features async -- INPUT.mp4 [MORE.mp4 ...]" + ); + std::process::exit(1); +} + +#[cfg(feature = "async")] +async fn probe_file(input_path: String) -> Result<(), ExampleError> { + let mut file = File::open(&input_path).await?; + let summary = probe_async(&mut file).await?; + + println!("file: {input_path}"); + println!(" fast start: {}", summary.fast_start); + println!(" track num: {}", summary.tracks.len()); + for track in &summary.tracks { + println!( + " track {} codec {:?} encrypted {}", + track.track_id, track.codec, track.encrypted + ); + } + + Ok(()) +} diff --git a/src/async_io.rs b/src/async_io.rs new file mode 100644 index 0000000..946b106 --- /dev/null +++ b/src/async_io.rs @@ -0,0 +1,32 @@ +//! Tokio-based async I/O traits for the library-side async surface. +//! +//! The existing sync APIs remain the default path in `mp4forge`. The first async rollout is +//! intentionally limited to seekable library readers and writers such as Tokio file handles or +//! in-memory buffers. The CLI continues to use the sync surface. + +/// Tokio async read trait used by the library-side async surface. +pub use tokio::io::AsyncRead; +/// Tokio async seek trait used by the library-side async surface. +pub use tokio::io::AsyncSeek; +/// Tokio async write trait used by the library-side async surface. +pub use tokio::io::AsyncWrite; + +/// Async reader alias for seekable library inputs. +/// +/// The first async rollout targets inputs that support both asynchronous reads and random-access +/// seeks. Non-seekable streams are intentionally excluded from this initial surface, and the +/// additive async reader path requires `Send` so callers can move independent file work onto Tokio +/// worker threads. +pub trait AsyncReadSeek: AsyncRead + AsyncSeek + Unpin + Send {} + +impl AsyncReadSeek for T where T: AsyncRead + AsyncSeek + Unpin + Send {} + +/// Async writer alias for seekable library outputs. +/// +/// `mp4forge` write flows backfill box headers after payload bytes are written, so the async write +/// surface also requires seek support instead of treating outputs as one-way streams. The async +/// writer path also requires `Send` so independent write jobs can move across Tokio worker +/// threads. +pub trait AsyncWriteSeek: AsyncWrite + AsyncSeek + Unpin + Send {} + +impl AsyncWriteSeek for T where T: AsyncWrite + AsyncSeek + Unpin + Send {} diff --git a/src/bitio.rs b/src/bitio.rs index a066aa1..4470203 100644 --- a/src/bitio.rs +++ b/src/bitio.rs @@ -2,6 +2,12 @@ use std::io::{self, ErrorKind, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + +#[cfg(feature = "async")] +use crate::async_io::{AsyncRead, AsyncSeek, AsyncWrite}; + /// Error text returned when byte-oriented access is attempted on an unaligned stream. pub const INVALID_ALIGNMENT_MESSAGE: &str = "invalid alignment"; /// Error text returned when a caller requests more bits than the provided buffer holds. @@ -91,6 +97,97 @@ impl Seek for BitReader { } } +/// Async bit-level reader used by the additive Tokio-based codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +#[derive(Debug)] +pub struct AsyncBitReader { + inner: R, + octet: u8, + remaining_bits: u8, +} + +#[cfg(feature = "async")] +impl AsyncBitReader { + /// Creates an async bit reader around an existing async byte reader. + pub const fn new(inner: R) -> Self { + Self { + inner, + octet: 0, + remaining_bits: 0, + } + } + + /// Returns `true` when the next read starts on a byte boundary. + pub const fn is_aligned(&self) -> bool { + self.remaining_bits == 0 + } +} + +#[cfg(feature = "async")] +impl AsyncBitReader { + /// Reads `width` bits and packs them into a big-endian byte vector. + pub async fn read_bits(&mut self, width: usize) -> io::Result> { + let byte_len = width.div_ceil(8); + let bit_offset = (byte_len * 8) - width; + let mut data = vec![0_u8; byte_len]; + + for index in 0..width { + if self.read_bit().await? { + let bit_index = bit_offset + index; + let byte_index = bit_index / 8; + let within_byte = 7 - (bit_index % 8); + data[byte_index] |= 1 << within_byte; + } + } + + Ok(data) + } + + /// Reads a single bit from the stream. + pub async fn read_bit(&mut self) -> io::Result { + if self.remaining_bits == 0 { + let mut buf = [0_u8; 1]; + self.inner.read_exact(&mut buf).await?; + self.octet = buf[0]; + self.remaining_bits = 8; + } + + self.remaining_bits -= 1; + Ok((self.octet >> self.remaining_bits) & 0x01 != 0) + } + + /// Reads aligned bytes into `buf`. + pub async fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> { + if !self.is_aligned() { + return Err(invalid_alignment()); + } + self.inner.read_exact(buf).await.map(|_| ()) + } +} + +#[cfg(feature = "async")] +impl AsyncBitReader { + /// Returns the current byte position. + pub async fn stream_position(&mut self) -> io::Result { + if !self.is_aligned() { + return Err(invalid_alignment()); + } + self.inner.stream_position().await + } + + /// Seeks to `pos` and clears any pending bit alignment state. + pub async fn seek(&mut self, pos: SeekFrom) -> io::Result { + if matches!(pos, SeekFrom::Current(_)) && !self.is_aligned() { + return Err(invalid_alignment()); + } + + let next = self.inner.seek(pos).await?; + self.remaining_bits = 0; + Ok(next) + } +} + /// Writes arbitrary-width bit slices while preserving byte-alignment state. #[derive(Debug)] pub struct BitWriter { @@ -174,6 +271,94 @@ impl Write for BitWriter { } } +/// Async bit-level writer used by the additive Tokio-based codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +#[derive(Debug)] +pub struct AsyncBitWriter { + inner: W, + octet: u8, + written_bits: u8, +} + +#[cfg(feature = "async")] +impl AsyncBitWriter { + /// Creates an async bit writer around an existing async byte writer. + pub const fn new(inner: W) -> Self { + Self { + inner, + octet: 0, + written_bits: 0, + } + } + + /// Returns `true` when the next write starts on a byte boundary. + pub const fn is_aligned(&self) -> bool { + self.written_bits == 0 + } +} + +#[cfg(feature = "async")] +impl AsyncBitWriter { + /// Writes the least-significant `width` bits from `data` to the stream. + pub async fn write_bits(&mut self, data: &[u8], width: usize) -> io::Result<()> { + let total_bits = data.len() * 8; + if width > total_bits { + return Err(io::Error::new( + ErrorKind::InvalidInput, + INVALID_BIT_WIDTH_MESSAGE, + )); + } + + for index in (total_bits - width)..total_bits { + let byte_index = index / 8; + let within_byte = 7 - (index % 8); + self.write_bit((data[byte_index] >> within_byte) & 0x01 != 0) + .await?; + } + + Ok(()) + } + + /// Writes a single bit to the stream. + pub async fn write_bit(&mut self, bit: bool) -> io::Result<()> { + if bit { + self.octet |= 1 << (7 - self.written_bits); + } + self.written_bits += 1; + + if self.written_bits == 8 { + self.inner.write_all(&[self.octet]).await?; + self.octet = 0; + self.written_bits = 0; + } + + Ok(()) + } + + /// Writes aligned bytes from `buf`. + pub async fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + if !self.is_aligned() { + return Err(invalid_alignment()); + } + self.inner.write_all(buf).await + } + + /// Flushes the wrapped writer. + pub async fn flush(&mut self) -> io::Result<()> { + self.inner.flush().await + } + + /// Returns the wrapped writer once all pending bits have been flushed. + pub fn into_inner(self) -> io::Result { + if !self.is_aligned() { + return Err(invalid_alignment()); + } + + Ok(self.inner) + } +} + fn invalid_alignment() -> io::Error { io::Error::new(ErrorKind::InvalidInput, INVALID_ALIGNMENT_MESSAGE) } diff --git a/src/boxes/iso14496_12.rs b/src/boxes/iso14496_12.rs index 6e91a87..98e0c76 100644 --- a/src/boxes/iso14496_12.rs +++ b/src/boxes/iso14496_12.rs @@ -3,10 +3,17 @@ use std::io::{Cursor, SeekFrom, Write}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::iso23001_7::{ Senc, decode_senc_payload, encode_senc_payload, render_senc_samples_display, }; use crate::boxes::{AnyTypeBox, BoxLookupContext, BoxRegistry}; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; use crate::codec::{ ANY_VERSION, CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, @@ -5695,6 +5702,35 @@ impl MutableBox for Meta { Ok(()) } + + #[cfg(feature = "async")] + fn before_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result<(), CodecError>> { + Box::pin(async move { + self.quicktime_headerless = false; + if payload_size < 4 { + return Ok(()); + } + + // Headerless metadata starts directly with the first child box type instead of the + // full-box prefix. + let start = reader.stream_position().await?; + let mut prefix = [0_u8; 4]; + reader.read_exact(&mut prefix).await?; + reader.seek(SeekFrom::Start(start)).await?; + + if prefix.iter().any(|byte| *byte != 0) { + self.quicktime_headerless = true; + self.full_box.version = 0; + self.full_box.flags = 0; + } + + Ok(()) + }) + } } impl Meta { diff --git a/src/boxes/metadata.rs b/src/boxes/metadata.rs index fb74310..d49acc9 100644 --- a/src/boxes/metadata.rs +++ b/src/boxes/metadata.rs @@ -2,7 +2,11 @@ use std::io::{SeekFrom, Write}; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::{AnyTypeBox, BoxLookupContext, BoxRegistry}; +#[cfg(feature = "async")] +use crate::codec::CodecFuture; use crate::codec::{ CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, read_exact_vec_untrusted, @@ -2274,6 +2278,16 @@ impl MutableBox for NumberedMetadataItem { self.layout = NumberedMetadataLayout::InlineFields; Ok(()) } + + #[cfg(feature = "async")] + fn before_unmarshal_async<'a>( + &'a mut self, + _reader: &'a mut dyn AsyncReadSeek, + _payload_size: u64, + ) -> CodecFuture<'a, Result<(), crate::codec::CodecError>> { + self.layout = NumberedMetadataLayout::InlineFields; + Box::pin(async { Ok(()) }) + } } impl AnyTypeBox for NumberedMetadataItem { diff --git a/src/boxes/mod.rs b/src/boxes/mod.rs index f2f6cec..294f6da 100644 --- a/src/boxes/mod.rs +++ b/src/boxes/mod.rs @@ -244,7 +244,7 @@ impl ResolvedRegistration { } /// Registry that maps box identifiers to descriptor-backed constructors. -#[derive(Default)] +#[derive(Clone, Default)] pub struct BoxRegistry { entries: BTreeMap, contextual_entries: BTreeMap>, diff --git a/src/codec.rs b/src/codec.rs index 96e81e4..e02331e 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -3,9 +3,21 @@ use std::any::Any; use std::error::Error; use std::fmt; +#[cfg(feature = "async")] +use std::future::Future; use std::io::{self, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use std::io::Cursor; +#[cfg(feature = "async")] +use std::pin::Pin; + +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; use crate::bitio::{BitReader, BitWriter}; use crate::boxes::{BoxLookupContext, BoxRegistry}; @@ -20,6 +32,11 @@ pub trait ReadSeek: Read + Seek {} impl ReadSeek for T where T: Read + Seek {} +/// Boxed future used by the additive Tokio-based codec hook surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub type CodecFuture<'a, T> = Pin + Send + 'a>>; + pub(crate) fn untrusted_prealloc_hint(count: usize) -> usize { count.min(MAX_UNTRUSTED_PREALLOC) } @@ -40,6 +57,26 @@ where Ok(data) } +#[cfg(feature = "async")] +pub(crate) async fn read_exact_vec_untrusted_async( + reader: &mut R, + len: usize, +) -> io::Result> +where + R: AsyncReadSeek + ?Sized, +{ + let mut data = Vec::with_capacity(untrusted_prealloc_hint(len)); + let mut chunk = [0_u8; 4096]; + let mut remaining = len; + while remaining != 0 { + let to_read = remaining.min(chunk.len()); + reader.read_exact(&mut chunk[..to_read]).await?; + data.extend_from_slice(&chunk[..to_read]); + remaining -= to_read; + } + Ok(data) +} + /// Box-specific overrides used by the generic descriptor codec. pub trait FieldHooks { /// Returns a dynamic bit width for `name` when the descriptor requests one. @@ -122,6 +159,18 @@ pub trait MutableBox: ImmutableBox { Ok(()) } + /// Runs before descriptor-driven decode on the Tokio-based async surface for boxes that must + /// inspect payload bytes first. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + fn before_unmarshal_async<'a>( + &'a mut self, + _reader: &'a mut dyn AsyncReadSeek, + _payload_size: u64, + ) -> CodecFuture<'a, Result<(), CodecError>> { + Box::pin(async { Ok(()) }) + } + /// Sets `flag` in the stored flag word. fn add_flag(&mut self, flag: u32) { self.set_flags(self.flags() | flag); @@ -625,6 +674,11 @@ fn select_hooks<'a>( hooks.unwrap_or(owner) } +#[cfg(feature = "async")] +fn erase_sync_hooks(hooks: Option<&(dyn FieldHooks + Sync)>) -> Option<&dyn FieldHooks> { + hooks.map(|hooks| hooks as &dyn FieldHooks) +} + /// Owned field value transferred between descriptor code and concrete boxes. #[cfg_attr( feature = "serde", @@ -740,6 +794,29 @@ pub trait CodecBox: MutableBox + FieldValueRead + FieldValueWrite { ) -> Result, CodecError> { Ok(None) } + + /// Encodes the full payload manually through the additive Tokio-based async codec surface when + /// the generic descriptor path is not expressive enough. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + fn custom_marshal_async<'a>( + &'a self, + _writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async { Ok(None) }) + } + + /// Decodes the full payload manually through the additive Tokio-based async codec surface when + /// the generic descriptor path is not expressive enough. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + fn custom_unmarshal_async<'a>( + &'a mut self, + _reader: &'a mut dyn AsyncReadSeek, + _payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async { Ok(None) }) + } } /// Object-safe view of the descriptor-backed box surface. @@ -762,6 +839,38 @@ pub trait CodecDescription: MutableBox + FieldValueRead + FieldValueWrite { reader: &mut dyn ReadSeek, payload_size: u64, ) -> Result, CodecError>; + + /// Runs before descriptor-driven decode on the Tokio-based async surface for boxes that must + /// inspect payload bytes first. + #[cfg(feature = "async")] + fn before_unmarshal_async<'a>( + &'a mut self, + _reader: &'a mut dyn AsyncReadSeek, + _payload_size: u64, + ) -> CodecFuture<'a, Result<(), CodecError>> { + Box::pin(async { Ok(()) }) + } + + /// Encodes the full payload manually through the additive Tokio-based async codec surface when + /// the generic descriptor path is not expressive enough. + #[cfg(feature = "async")] + fn custom_marshal_async<'a>( + &'a self, + _writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async { Ok(None) }) + } + + /// Decodes the full payload manually through the additive Tokio-based async codec surface when + /// the generic descriptor path is not expressive enough. + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + _reader: &'a mut dyn AsyncReadSeek, + _payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + Box::pin(async { Ok(None) }) + } } impl CodecDescription for T @@ -791,6 +900,32 @@ where ) -> Result, CodecError> { ::custom_unmarshal(self, reader, payload_size) } + + #[cfg(feature = "async")] + fn before_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result<(), CodecError>> { + ::before_unmarshal_async(self, reader, payload_size) + } + + #[cfg(feature = "async")] + fn custom_marshal_async<'a>( + &'a self, + writer: &'a mut dyn AsyncWriteSeek, + ) -> CodecFuture<'a, Result, CodecError>> { + ::custom_marshal_async(self, writer) + } + + #[cfg(feature = "async")] + fn custom_unmarshal_async<'a>( + &'a mut self, + reader: &'a mut dyn AsyncReadSeek, + payload_size: u64, + ) -> CodecFuture<'a, Result, CodecError>> { + ::custom_unmarshal_async(self, reader, payload_size) + } } /// Runtime-erased descriptor-backed box that still supports downcasting. @@ -1036,6 +1171,21 @@ where marshal_codec(writer, src, hooks) } +/// Encodes a concrete box payload through the additive Tokio-based async codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn marshal_async( + writer: &mut W, + src: &B, + hooks: Option<&(dyn FieldHooks + Sync)>, +) -> Result +where + W: AsyncWriteSeek, + B: CodecBox + Sync, +{ + marshal_codec_async_typed(writer, src, hooks).await +} + /// Encodes a runtime-erased descriptor-backed box payload into `writer`. pub fn marshal_dyn( writer: W, @@ -1048,6 +1198,21 @@ where marshal_codec(writer, src, hooks) } +/// Encodes a runtime-erased descriptor-backed box payload through the additive Tokio-based async +/// codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn marshal_dyn_async( + writer: &mut W, + src: &dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, +) -> Result +where + W: AsyncWriteSeek, +{ + marshal_codec_async(writer, src, hooks).await +} + fn marshal_codec( writer: W, src: &dyn CodecDescription, @@ -1077,6 +1242,45 @@ where Ok(encoder.written_bits / 8) } +#[cfg(feature = "async")] +async fn marshal_codec_async_typed( + writer: &mut W, + src: &B, + hooks: Option<&(dyn FieldHooks + Sync)>, +) -> Result +where + W: AsyncWriteSeek, + B: CodecBox + Sync, +{ + if let Some(written) = src.custom_marshal_async(writer).await? { + return Ok(written); + } + + let mut payload = Vec::new(); + let written = marshal_codec(&mut payload, src, erase_sync_hooks(hooks))?; + writer.write_all(&payload).await?; + Ok(written) +} + +#[cfg(feature = "async")] +async fn marshal_codec_async( + writer: &mut W, + src: &dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, +) -> Result +where + W: AsyncWriteSeek, +{ + if let Some(written) = src.custom_marshal_async(writer).await? { + return Ok(written); + } + + let mut payload = Vec::new(); + let written = marshal_codec(&mut payload, src, hooks)?; + writer.write_all(&payload).await?; + Ok(written) +} + /// Decodes a concrete box payload from `reader`. pub fn unmarshal( reader: &mut R, @@ -1091,6 +1295,22 @@ where unmarshal_codec(reader, payload_size, dst, hooks) } +/// Decodes a concrete box payload through the additive Tokio-based async codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn unmarshal_async( + reader: &mut R, + payload_size: u64, + dst: &mut B, + hooks: Option<&(dyn FieldHooks + Sync)>, +) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Send, +{ + unmarshal_codec_async_typed(reader, payload_size, dst, hooks).await +} + /// Decodes a runtime-erased descriptor-backed box payload from `reader`. pub fn unmarshal_dyn( reader: &mut R, @@ -1104,6 +1324,22 @@ where unmarshal_codec(reader, payload_size, dst, hooks) } +/// Decodes a runtime-erased descriptor-backed box payload through the additive Tokio-based async +/// codec surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn unmarshal_dyn_async( + reader: &mut R, + payload_size: u64, + dst: &mut dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, +) -> Result +where + R: AsyncReadSeek, +{ + unmarshal_codec_async(reader, payload_size, dst, hooks).await +} + /// Constructs and decodes a box using the registry entry for `box_type`. pub fn unmarshal_any( reader: &mut R, @@ -1125,6 +1361,31 @@ where ) } +/// Constructs and decodes a box through the additive Tokio-based async codec surface using the +/// registry entry for `box_type`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn unmarshal_any_async( + reader: &mut R, + payload_size: u64, + box_type: FourCc, + registry: &BoxRegistry, + hooks: Option<&dyn FieldHooks>, +) -> Result<(Box, u64), CodecError> +where + R: AsyncReadSeek, +{ + unmarshal_any_with_context_async( + reader, + payload_size, + box_type, + registry, + BoxLookupContext::new(), + hooks, + ) + .await +} + /// Constructs and decodes a box using the registration active for `box_type` in `context`. pub fn unmarshal_any_with_context( reader: &mut R, @@ -1144,6 +1405,35 @@ where Ok((boxed, read)) } +/// Constructs and decodes a box through the additive Tokio-based async codec surface using the +/// registration active for `box_type` in `context`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn unmarshal_any_with_context_async( + reader: &mut R, + payload_size: u64, + box_type: FourCc, + registry: &BoxRegistry, + context: BoxLookupContext, + hooks: Option<&dyn FieldHooks>, +) -> Result<(Box, u64), CodecError> +where + R: AsyncReadSeek, +{ + let payload = read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size).map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, + ) + .await?; + + let mut boxed = registry + .new_box_with_context(box_type, context) + .ok_or(CodecError::UnknownBoxType { box_type })?; + let mut payload_reader = Cursor::new(payload); + let read = unmarshal_dyn(&mut payload_reader, payload_size, boxed.as_mut(), hooks)?; + Ok((boxed, read)) +} + fn unmarshal_codec( reader: &mut R, payload_size: u64, @@ -1180,6 +1470,167 @@ where } } +#[cfg(feature = "async")] +async fn unmarshal_codec_async_typed( + reader: &mut R, + payload_size: u64, + dst: &mut B, + hooks: Option<&(dyn FieldHooks + Sync)>, +) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Send, +{ + let start = reader.stream_position().await?; + let original_version = dst.version(); + let original_flags = dst.flags(); + dst.set_version(ANY_VERSION); + dst.before_unmarshal_async(reader, payload_size).await?; + + let result = if let Some(read) = dst.custom_unmarshal_async(reader, payload_size).await? { + Ok(read) + } else { + reader.seek(SeekFrom::Start(start)).await?; + let payload = read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size) + .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, + ) + .await?; + let mut payload_reader = Cursor::new(payload); + let result = if let Some(read) = dst.custom_unmarshal(&mut payload_reader, payload_size)? { + Ok(read) + } else { + let mut decoder = Decoder::new(&mut payload_reader, payload_size, dst.box_type()); + decoder + .decode_box(dst, erase_sync_hooks(hooks)) + .map(|read_bits| read_bits / 8) + }; + + let consumed = Seek::stream_position(&mut payload_reader)?; + match result { + Ok(read_bytes) => { + let next = start.checked_add(consumed).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "async payload offset overflowed u64", + ) + })?; + reader.seek(SeekFrom::Start(next)).await?; + Ok(read_bytes) + } + Err(error @ CodecError::UnsupportedVersion { .. }) => { + reader.seek(SeekFrom::Start(start)).await?; + dst.set_version(original_version); + dst.set_flags(original_flags); + Err(error) + } + Err(error) => { + let next = start.checked_add(consumed).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "async payload offset overflowed u64", + ) + })?; + reader.seek(SeekFrom::Start(next)).await?; + Err(error) + } + } + }; + + match result { + Ok(read_bytes) => Ok(read_bytes), + Err(error @ CodecError::UnsupportedVersion { .. }) => { + reader.seek(SeekFrom::Start(start)).await?; + dst.set_version(original_version); + dst.set_flags(original_flags); + Err(error) + } + Err(error) => Err(error), + } +} + +#[cfg(feature = "async")] +async fn unmarshal_codec_async( + reader: &mut R, + payload_size: u64, + dst: &mut dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, +) -> Result +where + R: AsyncReadSeek, +{ + let start = reader.stream_position().await?; + let original_version = dst.version(); + let original_flags = dst.flags(); + dst.set_version(ANY_VERSION); + CodecDescription::before_unmarshal_async(dst, reader, payload_size).await?; + + let result = if let Some(read) = dst.custom_unmarshal_async(reader, payload_size).await? { + Ok(read) + } else { + // The first async codec landing keeps the existing descriptor logic intact by decoding from + // an in-memory cursor after the async pre-decode hook has inspected the payload. + reader.seek(SeekFrom::Start(start)).await?; + let payload = read_exact_vec_untrusted_async( + reader, + usize::try_from(payload_size) + .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, + ) + .await?; + let mut payload_reader = Cursor::new(payload); + let result = if let Some(read) = dst.custom_unmarshal(&mut payload_reader, payload_size)? { + Ok(read) + } else { + let mut decoder = Decoder::new(&mut payload_reader, payload_size, dst.box_type()); + decoder + .decode_box(dst, hooks) + .map(|read_bits| read_bits / 8) + }; + + let consumed = Seek::stream_position(&mut payload_reader)?; + match result { + Ok(read_bytes) => { + let next = start.checked_add(consumed).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "async payload offset overflowed u64", + ) + })?; + reader.seek(SeekFrom::Start(next)).await?; + Ok(read_bytes) + } + Err(error @ CodecError::UnsupportedVersion { .. }) => { + reader.seek(SeekFrom::Start(start)).await?; + dst.set_version(original_version); + dst.set_flags(original_flags); + Err(error) + } + Err(error) => { + let next = start.checked_add(consumed).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "async payload offset overflowed u64", + ) + })?; + reader.seek(SeekFrom::Start(next)).await?; + Err(error) + } + } + }; + + match result { + Ok(read_bytes) => Ok(read_bytes), + Err(error @ CodecError::UnsupportedVersion { .. }) => { + reader.seek(SeekFrom::Start(start)).await?; + dst.set_version(original_version); + dst.set_flags(original_flags); + Err(error) + } + Err(error) => Err(error), + } +} + struct Encoder { writer: BitWriter, written_bits: u64, diff --git a/src/extract.rs b/src/extract.rs index 4b6c820..a491a7f 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -8,16 +8,27 @@ use std::any::type_name; use std::error::Error; use std::fmt; use std::io::{self, Cursor, Read, Seek}; +#[cfg(feature = "async")] +use std::sync::{Arc, Mutex}; use crate::BoxInfo; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::boxes::{BoxRegistry, default_registry}; use crate::codec::{CodecBox, CodecError, DynCodecBox, unmarshal_any_with_context}; use crate::header::HeaderError; +#[cfg(feature = "async")] +use crate::walk::{ + AsyncWalkFuture, AsyncWalkHandle, AsyncWalkVisitor, + walk_structure_from_box_with_registry_async, walk_structure_with_registry_async, +}; use crate::walk::{ BoxPath, PathMatch, WalkControl, WalkError, WalkHandle, walk_structure_from_box_with_registry, walk_structure_with_registry, }; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; /// Header metadata paired with a decoded runtime box payload. /// @@ -203,6 +214,352 @@ where extract_boxes_payload_bytes_with_registry(reader, parent, paths, ®istry) } +/// Extracts every box that matches `path` through the additive Tokio-based async surface and +/// returns the matching header metadata. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_box_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = [path]; + extract_boxes_async(reader, parent.as_ref(), &paths).await +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface and returns the matching header metadata. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + validate_paths(&paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths, + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, &parent, ®istry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, ®istry, visitor).await?; + } + + let matches = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + + Ok(matches.into_iter().map(|matched| matched.info).collect()) +} + +/// Extracts every box that matches `path` through the additive Tokio-based async surface and +/// decodes the payloads. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_box_with_payload_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = [path]; + extract_boxes_with_payload_async(reader, parent.as_ref(), &paths).await +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface and decodes the payloads. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_with_payload_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + validate_paths(&paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths, + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, &parent, ®istry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, ®istry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut staged = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload_bytes = + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; + staged.push((matched, payload_bytes)); + } + + let mut matches = Vec::with_capacity(staged.len()); + for (matched, payload_bytes) in staged { + let payload = decode_payload_from_bytes(&matched, ®istry, &payload_bytes)?; + matches.push(ExtractedBox { + info: matched.info, + payload, + }); + } + + Ok(matches) +} + +/// Extracts every box that matches `path` through the additive Tokio-based async surface, decodes +/// the payloads, and clones them as `T`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_box_as_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + T: CodecBox + Clone + 'static, +{ + let parent = parent.copied(); + let paths = [path]; + extract_boxes_as_async(reader, parent.as_ref(), &paths).await +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface, decodes the payloads, and clones them as `T`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_as_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result, ExtractError> +where + R: AsyncReadSeek, + T: CodecBox + Clone + 'static, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + validate_paths(&paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths, + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, &parent, ®istry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, ®istry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut staged = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload_bytes = + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; + staged.push((matched, payload_bytes)); + } + + let mut payloads = Vec::with_capacity(staged.len()); + for (matched, payload_bytes) in staged { + let payload = decode_payload_from_bytes(&matched, ®istry, &payload_bytes)?; + let typed = payload + .as_ref() + .as_any() + .downcast_ref::() + .cloned() + .ok_or_else(|| ExtractError::UnexpectedPayloadType { + path: matched.path.clone(), + box_type: matched.info.box_type(), + offset: matched.info.offset(), + expected_type: type_name::(), + })?; + payloads.push(typed); + } + + Ok(payloads) +} + +/// Extracts every box that matches `path` through the additive Tokio-based async surface and +/// returns each match as exact serialized bytes, including the original box header. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_box_bytes_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = [path]; + extract_boxes_bytes_async(reader, parent.as_ref(), &paths).await +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface and returns each match as exact serialized bytes, including the original box header. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_bytes_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + validate_paths(&paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths, + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, &parent, ®istry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, ®istry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut extracted = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + extracted.push( + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::FullBox).await?, + ); + } + + Ok(extracted) +} + +/// Extracts every box that matches `path` through the additive Tokio-based async surface and +/// returns each matched payload as exact on-disk bytes. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_box_payload_bytes_async( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = [path]; + extract_boxes_payload_bytes_async(reader, parent.as_ref(), &paths).await +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface and returns each matched payload as exact on-disk bytes. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_payload_bytes_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + let parent = parent.copied(); + let paths = paths.to_vec(); + let registry = default_registry(); + validate_paths(&paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths, + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, &parent, ®istry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, ®istry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut extracted = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + extracted.push( + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?, + ); + } + + Ok(extracted) +} + /// Extracts every box that matches `path`, decodes the payloads, and clones them as `T` from an /// in-memory MP4 byte slice. /// @@ -346,11 +703,318 @@ where Ok(payloads) } +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface using `registry` and returns the matching header metadata. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_with_registry_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + let matches = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + + Ok(matches.into_iter().map(|matched| matched.info).collect()) +} + +/// Extracts every box that matches any path in `paths`, then decodes the payloads through the +/// additive Tokio-based async surface with `registry`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_with_payload_with_registry_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut staged = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload_bytes = + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; + staged.push((matched, payload_bytes)); + } + + let mut matches = Vec::with_capacity(staged.len()); + for (matched, payload_bytes) in staged { + let payload = decode_payload_from_bytes(&matched, registry, &payload_bytes)?; + matches.push(ExtractedBox { + info: matched.info, + payload, + }); + } + + Ok(matches) +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface using `registry` and returns each match as exact serialized bytes, including the +/// original box header. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_bytes_with_registry_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut extracted = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + extracted.push( + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::FullBox).await?, + ); + } + + Ok(extracted) +} + +/// Extracts every box that matches any path in `paths` through the additive Tokio-based async +/// surface using `registry` and returns each matched payload as exact on-disk bytes. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_payload_bytes_with_registry_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result>, ExtractError> +where + R: AsyncReadSeek, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut extracted = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + extracted.push( + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?, + ); + } + + Ok(extracted) +} + +/// Extracts every box that matches any path in `paths`, decodes the payloads through the additive +/// Tokio-based async surface with `registry`, and clones them as `T`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn extract_boxes_as_with_registry_async( + reader: &mut R, + parent: Option<&BoxInfo>, + paths: &[BoxPath], + registry: &BoxRegistry, +) -> Result, ExtractError> +where + R: AsyncReadSeek, + T: CodecBox + Clone + 'static, +{ + validate_paths(paths)?; + if paths.is_empty() { + return Ok(Vec::new()); + } + + let matches = Arc::new(Mutex::new(Vec::new())); + let visitor = AsyncMatchCollector { + has_parent: parent.is_some(), + paths: paths.to_vec(), + matches: Arc::clone(&matches), + }; + + if let Some(parent) = parent { + walk_structure_from_box_with_registry_async(reader, parent, registry, visitor).await?; + } else { + walk_structure_with_registry_async(reader, registry, visitor).await?; + } + + let matched_boxes = Arc::try_unwrap(matches) + .map_err(|_| io::Error::other("async match collector remained shared"))? + .into_inner() + .map_err(|_| io::Error::other("async match collector poisoned"))?; + let mut staged = Vec::with_capacity(matched_boxes.len()); + + for matched in matched_boxes { + let payload_bytes = + read_matched_bytes_async(reader, matched.info, ExtractedByteRange::Payload).await?; + staged.push((matched, payload_bytes)); + } + + let mut payloads = Vec::with_capacity(staged.len()); + for (matched, payload_bytes) in staged { + let payload = decode_payload_from_bytes(&matched, registry, &payload_bytes)?; + let typed = payload + .as_ref() + .as_any() + .downcast_ref::() + .cloned() + .ok_or_else(|| ExtractError::UnexpectedPayloadType { + path: matched.path.clone(), + box_type: matched.info.box_type(), + offset: matched.info.offset(), + expected_type: type_name::(), + })?; + payloads.push(typed); + } + + Ok(payloads) +} + struct MatchedBox { info: BoxInfo, path: BoxPath, } +#[cfg(feature = "async")] +struct AsyncMatchCollector { + has_parent: bool, + paths: Vec, + matches: Arc>>, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor for AsyncMatchCollector +where + R: AsyncReadSeek, +{ + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a, + R: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncWalkHandle<'r, R>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + if handle.info().box_type() == FourCc::ANY { + return Ok(WalkControl::Continue); + } + + let relative_path = if self.has_parent { + BoxPath::from(handle.path().as_slice()[1..].to_vec()) + } else { + handle.path().clone() + }; + + let PathMatch { + forward_match, + exact_match, + } = match_paths(&self.paths, &relative_path); + if exact_match { + self.matches + .lock() + .map_err(|_| WalkError::Io(io::Error::other("async match collector poisoned")))? + .push(MatchedBox { + info: *handle.info(), + path: relative_path.clone(), + }); + } + + Ok(if forward_match { + WalkControl::Descend + } else { + WalkControl::Continue + }) + }) + } +} + #[derive(Clone, Copy)] enum ExtractedByteRange { FullBox, @@ -440,9 +1104,19 @@ where { matched.info.seek_to_payload(reader)?; let payload_size = matched.info.payload_size()?; + let payload_bytes = read_exact_bytes(reader, payload_size)?; + decode_payload_from_bytes(matched, registry, &payload_bytes) +} + +fn decode_payload_from_bytes( + matched: &MatchedBox, + registry: &BoxRegistry, + payload_bytes: &[u8], +) -> Result, ExtractError> { + let mut payload_reader = Cursor::new(payload_bytes); let (payload, _) = unmarshal_any_with_context( - reader, - payload_size, + &mut payload_reader, + payload_bytes.len() as u64, matched.info.box_type(), registry, matched.info.lookup_context(), @@ -478,6 +1152,30 @@ where read_exact_bytes(reader, len) } +#[cfg(feature = "async")] +async fn read_matched_bytes_async( + reader: &mut R, + info: BoxInfo, + range: ExtractedByteRange, +) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let len = match range { + ExtractedByteRange::FullBox => { + reader.seek(io::SeekFrom::Start(info.offset())).await?; + info.size() + } + ExtractedByteRange::Payload => { + reader + .seek(io::SeekFrom::Start(info.offset() + info.header_size())) + .await?; + info.payload_size()? + } + }; + read_exact_bytes_async(reader, len).await +} + fn read_exact_bytes(reader: &mut R, len: u64) -> Result, ExtractError> where R: Read, @@ -497,6 +1195,24 @@ where Ok(bytes) } +#[cfg(feature = "async")] +async fn read_exact_bytes_async(reader: &mut R, len: u64) -> Result, ExtractError> +where + R: AsyncReadSeek, +{ + let mut bytes = usize::try_from(len) + .map(Vec::with_capacity) + .unwrap_or_else(|_| Vec::new()); + + let mut limited = (&mut *reader).take(len); + let copied = limited.read_to_end(&mut bytes).await? as u64; + if copied != len { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); + } + + Ok(bytes) +} + fn validate_paths(paths: &[BoxPath]) -> Result<(), ExtractError> { if paths.iter().any(BoxPath::is_empty) { return Err(ExtractError::EmptyPath); diff --git a/src/header.rs b/src/header.rs index 2e9cc09..b130a65 100644 --- a/src/header.rs +++ b/src/header.rs @@ -4,6 +4,11 @@ use std::error::Error; use std::fmt; use std::io::{self, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; use crate::boxes::BoxLookupContext; use crate::fourcc::FourCc; @@ -184,6 +189,36 @@ impl BoxInfo { }) } + /// Writes the header through the additive Tokio-based async library surface and returns + /// normalized metadata that reflects the written form. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn write_async(&self, writer: &mut W) -> Result + where + W: AsyncWriteSeek, + { + let offset = writer.stream_position().await?; + let encoded = self.encode(); + writer.write_all(&encoded).await?; + + let prior_payload = + self.size + .checked_sub(self.header_size) + .ok_or(HeaderError::SizeUnderflow { + size: self.size, + header_size: self.header_size, + })?; + + Ok(Self { + offset, + size: prior_payload + encoded.len() as u64, + header_size: encoded.len() as u64, + box_type: self.box_type, + extend_to_eof: self.extend_to_eof, + lookup_context: self.lookup_context, + }) + } + /// Reads a header from the current stream position. pub fn read(reader: &mut R) -> Result where @@ -237,6 +272,60 @@ impl BoxInfo { Ok(info) } + /// Reads a header from the current stream position through the additive Tokio-based async + /// library surface. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn read_async(reader: &mut R) -> Result + where + R: AsyncReadSeek, + { + let offset = reader.stream_position().await?; + + let mut small_header = [0_u8; SMALL_HEADER_SIZE as usize]; + reader.read_exact(&mut small_header).await?; + + let size = u32::from_be_bytes([ + small_header[0], + small_header[1], + small_header[2], + small_header[3], + ]) as u64; + let box_type = FourCc::from_bytes([ + small_header[4], + small_header[5], + small_header[6], + small_header[7], + ]); + + let mut info = Self::new(box_type, size).with_offset(offset); + + if size == 0 { + let end = reader.seek(SeekFrom::End(0)).await?; + info.size = end - offset; + info.extend_to_eof = true; + info.seek_to_payload_async(reader).await?; + } else if size == 1 { + let mut large_size = [0_u8; 8]; + reader.read_exact(&mut large_size).await?; + info.header_size = LARGE_HEADER_SIZE; + info.size = u64::from_be_bytes(large_size); + } + + if info.size == 0 { + return Err(HeaderError::InvalidSize); + } + + if info.size < info.header_size { + return Err(HeaderError::SizeUnderflow { + size: info.size, + header_size: info.header_size, + }); + } + + Ok(info) + } + pub(crate) fn set_lookup_context(&mut self, lookup_context: BoxLookupContext) { self.lookup_context = lookup_context; } @@ -246,15 +335,50 @@ impl BoxInfo { seeker.seek(SeekFrom::Start(self.offset)) } + /// Seeks to the beginning of the box header through the additive Tokio-based async library + /// surface. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn seek_to_start_async(&self, seeker: &mut S) -> io::Result + where + S: AsyncReadSeek, + { + seeker.seek(SeekFrom::Start(self.offset)).await + } + /// Seeks to the start of the box payload. pub fn seek_to_payload(&self, seeker: &mut S) -> io::Result { seeker.seek(SeekFrom::Start(self.offset + self.header_size)) } + /// Seeks to the start of the box payload through the additive Tokio-based async library + /// surface. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn seek_to_payload_async(&self, seeker: &mut S) -> io::Result + where + S: AsyncReadSeek, + { + seeker + .seek(SeekFrom::Start(self.offset + self.header_size)) + .await + } + /// Seeks to the byte immediately after the end of the box. pub fn seek_to_end(&self, seeker: &mut S) -> io::Result { seeker.seek(SeekFrom::Start(self.offset + self.size)) } + + /// Seeks to the byte immediately after the end of the box through the additive Tokio-based + /// async library surface. + #[cfg(feature = "async")] + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn seek_to_end_async(&self, seeker: &mut S) -> io::Result + where + S: AsyncReadSeek, + { + seeker.seek(SeekFrom::Start(self.offset + self.size)).await + } } /// Errors raised while parsing or writing box headers. diff --git a/src/lib.rs b/src/lib.rs index b924fb1..ef34e4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,15 @@ //! MP4 and ISOBMFF toolkit with low-level building blocks and thin ergonomic helpers. +//! +//! The default surface is synchronous. Enable the optional `async` feature when you want the +//! additive Tokio-based library companions for seekable readers and writers. That async surface is +//! intended for supported seekable Tokio I/O such as `tokio::fs::File` and seekable in-memory +//! cursors, and it supports normal multithreaded `tokio::spawn` use for independent-file library +//! work. The CLI remains on the synchronous path. +/// Tokio-based async I/O traits for the additive library-side async surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub mod async_io; /// Bit-level reader and writer helpers used by the codec layer. pub mod bitio; /// Box definitions and registry helpers. diff --git a/src/probe.rs b/src/probe.rs index 0a79237..450dfdf 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -7,6 +7,8 @@ use std::io::{self, Cursor, Read, Seek, SeekFrom}; use crate::BoxInfo; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::AsyncReadSeek; use crate::bitio::BitReader; use crate::boxes::av1::AV1CodecConfiguration; use crate::boxes::etsi_ts_102_366::Dac3; @@ -24,8 +26,12 @@ use crate::boxes::opus::DOps; use crate::boxes::vp::VpCodecConfiguration; use crate::codec::{CodecBox, CodecError, ImmutableBox, unmarshal}; use crate::extract::{ExtractError, ExtractedBox, extract_boxes, extract_boxes_with_payload}; +#[cfg(feature = "async")] +use crate::extract::{extract_boxes_async, extract_boxes_with_payload_async}; use crate::header::HeaderError; use crate::walk::BoxPath; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); const MOOV: FourCc = FourCc::from_bytes(*b"moov"); @@ -1048,6 +1054,20 @@ where probe_with_options(reader, ProbeOptions::default()) } +/// Probes a file through the additive Tokio-based async surface and returns the +/// backwards-compatible coarse movie, track, and fragment summary. +/// +/// For richer sample-entry, handler, language, and protection metadata, use +/// [`probe_detailed_async`]. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_async(reader: &mut R) -> Result +where + R: AsyncReadSeek, +{ + probe_with_options_async(reader, ProbeOptions::default()).await +} + /// Probes a file with additive expansion controls and returns the backwards-compatible coarse /// movie, track, and fragment summary. pub fn probe_with_options(reader: &mut R, options: ProbeOptions) -> Result @@ -1059,6 +1079,22 @@ where )?)) } +/// Probes a file through the additive Tokio-based async surface with expansion controls and +/// returns the backwards-compatible coarse movie, track, and fragment summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_with_options_async( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + Ok(strip_probe_details( + probe_detailed_with_options_async(reader, options).await?, + )) +} + /// Probes a file and returns an additive detailed movie, track, and fragment summary. pub fn probe_detailed(reader: &mut R) -> Result where @@ -1067,6 +1103,17 @@ where probe_detailed_with_options(reader, ProbeOptions::default()) } +/// Probes a file through the additive Tokio-based async surface and returns an additive detailed +/// movie, track, and fragment summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_detailed_async(reader: &mut R) -> Result +where + R: AsyncReadSeek, +{ + probe_detailed_with_options_async(reader, ProbeOptions::default()).await +} + /// Probes a file with additive expansion controls and returns the detailed movie, track, and /// fragment summary. pub fn probe_detailed_with_options( @@ -1081,6 +1128,22 @@ where )?)) } +/// Probes a file through the additive Tokio-based async surface with expansion controls and +/// returns the detailed movie, track, and fragment summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_detailed_with_options_async( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + Ok(strip_codec_details( + probe_codec_detailed_with_options_async(reader, options).await?, + )) +} + /// Probes a file and returns an additive detailed summary with parsed codec-specific /// configuration when it is available. pub fn probe_codec_detailed(reader: &mut R) -> Result @@ -1090,6 +1153,19 @@ where probe_codec_detailed_with_options(reader, ProbeOptions::default()) } +/// Probes a file through the additive Tokio-based async surface and returns an additive detailed +/// summary with parsed codec-specific configuration when it is available. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_codec_detailed_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + probe_codec_detailed_with_options_async(reader, ProbeOptions::default()).await +} + /// Probes a file with additive expansion controls and returns the codec-detailed summary. pub fn probe_codec_detailed_with_options( reader: &mut R, @@ -1138,6 +1214,59 @@ where Ok(summary) } +/// Probes a file through the additive Tokio-based async surface with expansion controls and +/// returns the codec-detailed summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_codec_detailed_with_options_async( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let paths = root_probe_box_paths(options); + let infos = extract_boxes_async(reader, None, &paths).await?; + + let mut summary = CodecDetailedProbeInfo::default(); + let mut mdat_appeared = false; + + for info in infos { + match info.box_type() { + FTYP => { + let ftyp = + read_payload_as_async::<_, crate::boxes::iso14496_12::Ftyp>(reader, info) + .await?; + summary.major_brand = ftyp.major_brand; + summary.minor_version = ftyp.minor_version; + summary.compatible_brands = ftyp.compatible_brands; + } + MOOV => { + summary.fast_start = !mdat_appeared; + } + MVHD => { + let mvhd = read_payload_as_async::<_, Mvhd>(reader, info).await?; + summary.timescale = mvhd.timescale; + summary.duration = mvhd.duration(); + } + TRAK => { + summary + .tracks + .push(probe_trak_codec_detailed_async(reader, info, options).await?); + } + MOOF if options.include_segments => { + summary.segments.push(probe_moof_async(reader, info).await?); + } + MDAT => { + mdat_appeared = true; + } + _ => {} + } + } + + Ok(summary) +} + /// Probes a file and returns an additive summary with parsed codec and media characteristics. pub fn probe_media_characteristics( reader: &mut R, @@ -1148,6 +1277,19 @@ where probe_media_characteristics_with_options(reader, ProbeOptions::default()) } +/// Probes a file through the additive Tokio-based async surface and returns an additive summary +/// with parsed codec and media characteristics. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_media_characteristics_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + probe_media_characteristics_with_options_async(reader, ProbeOptions::default()).await +} + /// Probes a file with additive expansion controls and returns the media-characteristics summary. pub fn probe_media_characteristics_with_options( reader: &mut R, @@ -1196,6 +1338,59 @@ where Ok(summary) } +/// Probes a file through the additive Tokio-based async surface with expansion controls and +/// returns the media-characteristics summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_media_characteristics_with_options_async( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let paths = root_probe_box_paths(options); + let infos = extract_boxes_async(reader, None, &paths).await?; + + let mut summary = MediaCharacteristicsProbeInfo::default(); + let mut mdat_appeared = false; + + for info in infos { + match info.box_type() { + FTYP => { + let ftyp = + read_payload_as_async::<_, crate::boxes::iso14496_12::Ftyp>(reader, info) + .await?; + summary.major_brand = ftyp.major_brand; + summary.minor_version = ftyp.minor_version; + summary.compatible_brands = ftyp.compatible_brands; + } + MOOV => { + summary.fast_start = !mdat_appeared; + } + MVHD => { + let mvhd = read_payload_as_async::<_, Mvhd>(reader, info).await?; + summary.timescale = mvhd.timescale; + summary.duration = mvhd.duration(); + } + TRAK => { + summary + .tracks + .push(probe_trak_media_characteristics_async(reader, info, options).await?); + } + MOOF if options.include_segments => { + summary.segments.push(probe_moof_async(reader, info).await?); + } + MDAT => { + mdat_appeared = true; + } + _ => {} + } + } + + Ok(summary) +} + /// Probes a file and returns an additive summary with parsed codec, media characteristics, and /// extra typed visual sample-entry metadata. pub fn probe_extended_media_characteristics( @@ -1207,6 +1402,19 @@ where probe_extended_media_characteristics_with_options(reader, ProbeOptions::default()) } +/// Probes a file through the additive Tokio-based async surface and returns an additive summary +/// with parsed codec, media characteristics, and extra typed visual sample-entry metadata. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_extended_media_characteristics_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + probe_extended_media_characteristics_with_options_async(reader, ProbeOptions::default()).await +} + /// Probes a file with additive expansion controls and returns the extended /// media-characteristics summary. pub fn probe_extended_media_characteristics_with_options( @@ -1258,6 +1466,59 @@ where Ok(summary) } +/// Probes a file through the additive Tokio-based async surface with expansion controls and +/// returns the extended media-characteristics summary. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_extended_media_characteristics_with_options_async( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let paths = root_probe_box_paths(options); + let infos = extract_boxes_async(reader, None, &paths).await?; + + let mut summary = ExtendedMediaCharacteristicsProbeInfo::default(); + let mut mdat_appeared = false; + + for info in infos { + match info.box_type() { + FTYP => { + let ftyp = + read_payload_as_async::<_, crate::boxes::iso14496_12::Ftyp>(reader, info) + .await?; + summary.major_brand = ftyp.major_brand; + summary.minor_version = ftyp.minor_version; + summary.compatible_brands = ftyp.compatible_brands; + } + MOOV => { + summary.fast_start = !mdat_appeared; + } + MVHD => { + let mvhd = read_payload_as_async::<_, Mvhd>(reader, info).await?; + summary.timescale = mvhd.timescale; + summary.duration = mvhd.duration(); + } + TRAK => { + summary.tracks.push( + probe_trak_extended_media_characteristics_async(reader, info, options).await?, + ); + } + MOOF if options.include_segments => { + summary.segments.push(probe_moof_async(reader, info).await?); + } + MDAT => { + mdat_appeared = true; + } + _ => {} + } + } + + Ok(summary) +} + /// Probes an in-memory MP4 byte slice and returns the coarse movie, track, and fragment /// summary. /// @@ -1361,6 +1622,16 @@ where probe(reader) } +/// Legacy fragmented-file probe entry point through the additive Tokio-based async surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_fra_async(reader: &mut R) -> Result +where + R: AsyncReadSeek, +{ + probe_async(reader).await +} + /// Legacy fragmented-file detailed probe entry point that currently aliases [`probe_detailed`]. pub fn probe_fra_detailed(reader: &mut R) -> Result where @@ -1369,6 +1640,17 @@ where probe_detailed(reader) } +/// Legacy fragmented-file detailed probe entry point through the additive Tokio-based async +/// surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_fra_detailed_async(reader: &mut R) -> Result +where + R: AsyncReadSeek, +{ + probe_detailed_async(reader).await +} + /// Legacy fragmented-file codec-detailed probe entry point that currently aliases /// [`probe_codec_detailed`]. pub fn probe_fra_codec_detailed(reader: &mut R) -> Result @@ -1378,6 +1660,19 @@ where probe_codec_detailed(reader) } +/// Legacy fragmented-file codec-detailed probe entry point through the additive Tokio-based async +/// surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_fra_codec_detailed_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + probe_codec_detailed_async(reader).await +} + /// Legacy fragmented-file media-characteristics probe entry point that currently aliases /// [`probe_media_characteristics`]. pub fn probe_fra_media_characteristics( @@ -1389,6 +1684,19 @@ where probe_media_characteristics(reader) } +/// Legacy fragmented-file media-characteristics probe entry point through the additive Tokio-based +/// async surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn probe_fra_media_characteristics_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + probe_media_characteristics_async(reader).await +} + /// Legacy fragmented-file probe entry point for in-memory MP4 bytes. /// /// This currently aliases [`probe_bytes`] for callers that already use the `probe_fra` naming. @@ -1549,6 +1857,61 @@ where Ok(indices) } +/// Finds sample indices whose AVC payload contains an IDR NAL unit through the additive Tokio- +/// based async surface. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn find_idr_frames_async( + reader: &mut R, + track: &TrackInfo, +) -> Result, ProbeError> +where + R: AsyncReadSeek, +{ + let Some(avc) = track.avc.as_ref() else { + return Ok(Vec::new()); + }; + let length_size = u32::from(avc.length_size); + + let mut sample_index = 0usize; + let mut indices = Vec::new(); + for chunk in &track.chunks { + let end = sample_index.saturating_add(chunk.samples_per_chunk as usize); + let mut data_offset = chunk.data_offset; + while sample_index < end && sample_index < track.samples.len() { + let sample = &track.samples[sample_index]; + if sample.size != 0 { + let mut nal_offset = 0_u32; + while nal_offset.saturating_add(length_size).saturating_add(1) <= sample.size { + reader + .seek(SeekFrom::Start(data_offset + u64::from(nal_offset))) + .await?; + let mut data = vec![0_u8; length_size as usize + 1]; + reader.read_exact(&mut data).await?; + + let mut nal_length = 0_u32; + for byte in &data[..length_size as usize] { + nal_length = (nal_length << 8) | u32::from(*byte); + } + if data[length_size as usize] & 0x1f == 5 { + indices.push(sample_index); + break; + } + + nal_offset = nal_offset + .saturating_add(length_size) + .saturating_add(nal_length); + } + } + + data_offset = data_offset.saturating_add(u64::from(sample.size)); + sample_index += 1; + } + } + + Ok(indices) +} + /// Returns the average bitrate implied by `samples` in the supplied timescale. pub fn average_sample_bitrate(samples: &[SampleInfo], timescale: u32) -> u64 { let total_size = samples @@ -1818,6 +2181,22 @@ where }) } +#[cfg(feature = "async")] +async fn probe_trak_codec_detailed_async( + reader: &mut R, + parent: BoxInfo, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let track = probe_trak_rich_details_async(reader, parent, options).await?; + Ok(CodecDetailedTrackInfo { + summary: track.summary, + codec_details: track.codec_details, + }) +} + fn probe_trak_media_characteristics( reader: &mut R, parent: &BoxInfo, @@ -1834,6 +2213,23 @@ where }) } +#[cfg(feature = "async")] +async fn probe_trak_media_characteristics_async( + reader: &mut R, + parent: BoxInfo, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let track = probe_trak_rich_details_async(reader, parent, options).await?; + Ok(MediaCharacteristicsTrackInfo { + summary: track.summary, + codec_details: track.codec_details, + media_characteristics: track.media_characteristics, + }) +} + fn probe_trak_extended_media_characteristics( reader: &mut R, parent: &BoxInfo, @@ -1851,6 +2247,24 @@ where }) } +#[cfg(feature = "async")] +async fn probe_trak_extended_media_characteristics_async( + reader: &mut R, + parent: BoxInfo, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let track = probe_trak_rich_details_async(reader, parent, options).await?; + Ok(ExtendedMediaCharacteristicsTrackInfo { + summary: track.summary, + codec_details: track.codec_details, + media_characteristics: track.media_characteristics, + visual_metadata: track.visual_metadata, + }) +} + fn probe_trak_rich_details( reader: &mut R, parent: &BoxInfo, @@ -1861,7 +2275,27 @@ where { let paths = track_probe_box_paths(options); let boxes = extract_boxes_with_payload(reader, Some(parent), &paths)?; + parse_trak_rich_details(boxes, options) +} + +#[cfg(feature = "async")] +async fn probe_trak_rich_details_async( + reader: &mut R, + parent: BoxInfo, + options: ProbeOptions, +) -> Result +where + R: AsyncReadSeek, +{ + let paths = track_probe_box_paths(options); + let boxes = extract_boxes_with_payload_async(reader, Some(&parent), &paths).await?; + parse_trak_rich_details(boxes, options) +} +fn parse_trak_rich_details( + boxes: Vec, + options: ProbeOptions, +) -> Result { let mut track = DetailedTrackInfo::default(); let mut tkhd = None; let mut mdhd = None; @@ -2633,7 +3067,31 @@ where BoxPath::from([TRAF, TRUN]), ], )?; + parse_moof_segment(boxes, parent.offset()) +} + +#[cfg(feature = "async")] +async fn probe_moof_async(reader: &mut R, parent: BoxInfo) -> Result +where + R: AsyncReadSeek, +{ + let boxes = extract_boxes_with_payload_async( + reader, + Some(&parent), + &[ + BoxPath::from([TRAF, TFHD]), + BoxPath::from([TRAF, TFDT]), + BoxPath::from([TRAF, TRUN]), + ], + ) + .await?; + parse_moof_segment(boxes, parent.offset()) +} +fn parse_moof_segment( + boxes: Vec, + moof_offset: u64, +) -> Result { let mut tfhd = None; let mut tfdt = None; let mut trun = None; @@ -2649,7 +3107,7 @@ where let tfhd = tfhd.ok_or(ProbeError::MissingRequiredBox("tfhd"))?; let mut segment = SegmentInfo { track_id: tfhd.track_id, - moof_offset: parent.offset(), + moof_offset, default_sample_duration: tfhd.default_sample_duration, ..SegmentInfo::default() }; @@ -2720,6 +3178,32 @@ where Ok(decoded) } +#[cfg(feature = "async")] +async fn read_payload_as_async(reader: &mut R, info: BoxInfo) -> Result +where + R: AsyncReadSeek, + B: CodecBox + Default + Send, +{ + reader + .seek(SeekFrom::Start(info.offset() + info.header_size())) + .await?; + let mut payload_bytes = Vec::with_capacity(info.payload_size()?.try_into().unwrap_or(0)); + let mut payload_reader = (&mut *reader).take(info.payload_size()?); + let payload_read = payload_reader.read_to_end(&mut payload_bytes).await? as u64; + if payload_read != info.payload_size()? { + return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into()); + } + + let mut decoded = B::default(); + unmarshal( + &mut Cursor::new(payload_bytes.as_slice()), + info.payload_size()?, + &mut decoded, + None, + )?; + Ok(decoded) +} + fn downcast_clone(extracted: &ExtractedBox) -> Result where T: Clone + 'static, diff --git a/src/rewrite.rs b/src/rewrite.rs index 51e4a74..7daa46c 100644 --- a/src/rewrite.rs +++ b/src/rewrite.rs @@ -10,6 +10,8 @@ use std::fmt; use std::io::{self, Cursor, Read, Seek, SeekFrom, Write}; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; use crate::boxes::iso14496_12::{ Ftyp, VisualSampleEntry, split_box_children_with_optional_trailing_bytes, }; @@ -19,6 +21,8 @@ use crate::codec::{CodecBox, CodecError, marshal_dyn, unmarshal, unmarshal_any_w use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; use crate::walk::{BoxPath, PathMatch}; use crate::writer::{Writer, WriterError}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); const KEYS: FourCc = FourCc::from_bytes(*b"keys"); @@ -151,6 +155,129 @@ where Ok(rewritten_count) } +/// Rewrites every payload at `path` through the additive Tokio-based async library surface by +/// downcasting it to `T` and applying `edit`. +/// +/// The edit closure runs once per matched box in depth-first order. The returned count is the +/// number of payloads that were successfully rewritten. Unmatched boxes are copied through to the +/// output verbatim. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn rewrite_box_as_async( + reader: &mut R, + writer: W, + path: BoxPath, + edit: F, +) -> Result +where + R: AsyncReadSeek, + W: AsyncWriteSeek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let paths = [path]; + rewrite_boxes_as_async(reader, writer, &paths, edit).await +} + +/// Rewrites every payload that matches any path in `paths` through the additive Tokio-based async +/// library surface by downcasting it to `T` and applying `edit`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn rewrite_boxes_as_async( + reader: &mut R, + writer: W, + paths: &[BoxPath], + edit: F, +) -> Result +where + R: AsyncReadSeek, + W: AsyncWriteSeek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let registry = default_registry(); + rewrite_boxes_as_with_registry_async(reader, writer, paths, ®istry, edit).await +} + +/// Rewrites every payload at `path` in an in-memory MP4 byte slice through the additive +/// Tokio-based async library surface and returns the rewritten bytes. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn rewrite_box_as_bytes_async( + input: &[u8], + path: BoxPath, + edit: F, +) -> Result, RewriteError> +where + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let paths = [path]; + rewrite_boxes_as_bytes_async::(input, &paths, edit).await +} + +/// Rewrites every payload that matches any path in `paths` in an in-memory MP4 byte slice through +/// the additive Tokio-based async library surface and returns the rewritten bytes. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn rewrite_boxes_as_bytes_async( + input: &[u8], + paths: &[BoxPath], + edit: F, +) -> Result, RewriteError> +where + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let mut reader = Cursor::new(input); + let mut writer = Cursor::new(Vec::with_capacity(input.len())); + rewrite_boxes_as_async(&mut reader, &mut writer, paths, edit).await?; + Ok(writer.into_inner()) +} + +/// Rewrites every payload that matches any path in `paths` through the additive Tokio-based async +/// library surface using `registry`, downcasts each match to `T`, and applies `edit`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn rewrite_boxes_as_with_registry_async( + reader: &mut R, + writer: W, + paths: &[BoxPath], + registry: &BoxRegistry, + mut edit: F, +) -> Result +where + R: AsyncReadSeek, + W: AsyncWriteSeek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + validate_paths(paths)?; + reader.seek(SeekFrom::Start(0)).await?; + + let mut writer = Writer::new(writer); + if paths.is_empty() { + tokio::io::copy(reader, &mut writer).await?; + return Ok(0); + } + + let mut rewritten_count = 0; + let mut plan = RewritePlan { + paths, + edit: &mut edit, + rewritten_count: &mut rewritten_count, + }; + rewrite_sequence_async::( + reader, + &mut writer, + registry, + &mut plan, + RewriteFrame::root(), + ) + .await?; + Ok(rewritten_count) +} + #[derive(Clone)] struct RewriteFrame { remaining_size: u64, @@ -249,6 +376,71 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn rewrite_sequence_async( + reader: &mut R, + writer: &mut Writer, + registry: &BoxRegistry, + plan: &mut RewritePlan<'_, F>, + mut frame: RewriteFrame, +) -> Result<(), RewriteError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + loop { + if !frame.is_root && frame.remaining_size < SMALL_HEADER_SIZE { + break; + } + + let start = reader.stream_position().await?; + let mut info = match BoxInfo::read_async(reader).await { + Ok(info) => info, + Err(HeaderError::Io(error)) + if frame.is_root && clean_root_eof_async(reader, start, &error).await? => + { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + + if !frame.is_root && info.size() > frame.remaining_size { + return Err(RewriteError::TooLargeBoxSize { + box_type: info.box_type(), + size: info.size(), + available_size: frame.remaining_size, + }); + } + if !frame.is_root { + frame.remaining_size -= info.size(); + } + + info.set_lookup_context(frame.sibling_context); + inspect_context_carriers_async(reader, &mut info, &frame.path).await?; + process_box_async::(reader, writer, registry, plan, &frame, &info).await?; + + if info.lookup_context().is_quicktime_compatible() { + frame.sibling_context = frame.sibling_context.with_quicktime_compatible(true); + } + if info.box_type() == KEYS { + frame.sibling_context = frame + .sibling_context + .with_metadata_keys_entry_count(info.lookup_context().metadata_keys_entry_count()); + } + } + + if !frame.is_root + && frame.remaining_size != 0 + && !frame.sibling_context.is_quicktime_compatible() + { + return Err(RewriteError::UnexpectedEof); + } + + Ok(()) +} + fn process_box( reader: &mut R, writer: &mut Writer, @@ -344,6 +536,121 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn process_box_async( + reader: &mut R, + writer: &mut Writer, + registry: &BoxRegistry, + plan: &mut RewritePlan<'_, F>, + frame: &RewriteFrame, + info: &BoxInfo, +) -> Result<(), RewriteError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, + T: CodecBox + 'static, + F: FnMut(&mut T), +{ + let current_path = child_path(&frame.path, info.box_type()); + let path_match = match_paths(plan.paths, ¤t_path); + if !path_match.forward_match && !path_match.exact_match { + writer.copy_box_async(reader, info).await?; + return Ok(()); + } + + reader + .seek(SeekFrom::Start(info.offset() + info.header_size())) + .await?; + let payload_size = info.payload_size()?; + let mut payload_bytes = Vec::with_capacity(payload_size.try_into().unwrap_or(0)); + let mut payload_reader = (&mut *reader).take(payload_size); + let payload_read = payload_reader.read_to_end(&mut payload_bytes).await? as u64; + if payload_read != payload_size { + return Err(RewriteError::UnexpectedEof); + } + let (encoded_payload, payload_read, is_visual_sample_entry) = { + let (mut payload, payload_read) = unmarshal_any_with_context( + &mut Cursor::new(payload_bytes.as_slice()), + payload_size, + info.box_type(), + registry, + info.lookup_context(), + None, + ) + .map_err(|source| RewriteError::PayloadDecode { + path: current_path.clone(), + box_type: info.box_type(), + offset: info.offset(), + source, + })?; + + if path_match.exact_match { + let typed = payload.as_any_mut().downcast_mut::().ok_or_else(|| { + RewriteError::UnexpectedPayloadType { + path: current_path.clone(), + box_type: info.box_type(), + offset: info.offset(), + expected_type: type_name::(), + } + })?; + (plan.edit)(typed); + *plan.rewritten_count += 1; + } + + let is_visual_sample_entry = payload.as_any().is::(); + let mut encoded_payload = Vec::new(); + marshal_dyn(&mut encoded_payload, payload.as_ref(), None).map_err(|source| { + RewriteError::PayloadEncode { + path: current_path.clone(), + box_type: info.box_type(), + offset: info.offset(), + source, + } + })?; + (encoded_payload, payload_read, is_visual_sample_entry) + }; + + let placeholder = BoxInfo::new(info.box_type(), info.header_size()) + .with_header_size(info.header_size()) + .with_lookup_context(info.lookup_context()) + .with_extend_to_eof(info.extend_to_eof()); + writer.start_box_async(placeholder).await?; + writer.write_all(&encoded_payload).await?; + + let children_offset = info.offset() + info.header_size() + payload_read; + let (children_size, trailing_bytes) = if is_visual_sample_entry { + visual_sample_entry_children_layout_async( + reader, + children_offset, + payload_size.saturating_sub(payload_read), + ) + .await? + } else { + (payload_size.saturating_sub(payload_read), Vec::new()) + }; + reader.seek(SeekFrom::Start(children_offset)).await?; + Box::pin(rewrite_sequence_async::( + reader, + writer, + registry, + plan, + RewriteFrame::child( + children_size, + current_path, + info.lookup_context().enter(info.box_type()), + ), + )) + .await?; + if !trailing_bytes.is_empty() { + writer.write_all(&trailing_bytes).await?; + } + reader + .seek(SeekFrom::Start(info.offset() + info.size())) + .await?; + writer.end_box_async().await?; + Ok(()) +} + fn inspect_context_carriers( reader: &mut R, info: &mut BoxInfo, @@ -370,6 +677,33 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn inspect_context_carriers_async( + reader: &mut R, + info: &mut BoxInfo, + path: &BoxPath, +) -> Result<(), RewriteError> +where + R: AsyncReadSeek, +{ + if path.is_empty() && info.box_type() == FTYP { + let ftyp = decode_box_async::<_, Ftyp>(reader, info).await?; + if ftyp.has_compatible_brand(QT_BRAND) { + info.set_lookup_context(info.lookup_context().with_quicktime_compatible(true)); + } + } + + if info.box_type() == KEYS { + let keys = decode_box_async::<_, Keys>(reader, info).await?; + info.set_lookup_context( + info.lookup_context() + .with_metadata_keys_entry_count(keys.entry_count as usize), + ); + } + + Ok(()) +} + fn visual_sample_entry_children_layout( reader: &mut R, extension_offset: u64, @@ -387,6 +721,24 @@ where Ok((child_len as u64, bytes[child_len..].to_vec())) } +#[cfg(feature = "async")] +async fn visual_sample_entry_children_layout_async( + reader: &mut R, + extension_offset: u64, + extension_size: u64, +) -> Result<(u64, Vec), RewriteError> +where + R: AsyncReadSeek, +{ + let checkpoint = reader.stream_position().await?; + reader.seek(SeekFrom::Start(extension_offset)).await?; + let bytes = read_extension_bytes_async(reader, extension_size).await?; + reader.seek(SeekFrom::Start(checkpoint)).await?; + + let child_len = split_box_children_with_optional_trailing_bytes(&bytes); + Ok((child_len as u64, bytes[child_len..].to_vec())) +} + fn read_extension_bytes(reader: &mut R, extension_size: u64) -> Result, RewriteError> where R: Read, @@ -399,6 +751,22 @@ where Ok(bytes) } +#[cfg(feature = "async")] +async fn read_extension_bytes_async( + reader: &mut R, + extension_size: u64, +) -> Result, RewriteError> +where + R: AsyncReadSeek, +{ + let extension_len = usize::try_from(extension_size).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "payload extension is too large") + })?; + let mut bytes = vec![0; extension_len]; + reader.read_exact(&mut bytes).await?; + Ok(bytes) +} + fn decode_box(reader: &mut R, info: &BoxInfo) -> Result where R: Read + Seek, @@ -411,6 +779,19 @@ where Ok(decoded) } +#[cfg(feature = "async")] +async fn decode_box_async(reader: &mut R, info: &BoxInfo) -> Result +where + R: AsyncReadSeek, + B: Default + CodecBox + Send, +{ + info.seek_to_payload_async(reader).await?; + let mut decoded = B::default(); + crate::codec::unmarshal_async(reader, info.payload_size()?, &mut decoded, None).await?; + info.seek_to_payload_async(reader).await?; + Ok(decoded) +} + fn clean_root_eof(reader: &mut R, start: u64, error: &io::Error) -> Result where R: Seek, @@ -423,6 +804,23 @@ where Ok(start == end) } +#[cfg(feature = "async")] +async fn clean_root_eof_async( + reader: &mut R, + start: u64, + error: &io::Error, +) -> Result +where + R: AsyncReadSeek, +{ + if error.kind() != io::ErrorKind::UnexpectedEof { + return Ok(false); + } + + let end = reader.seek(SeekFrom::End(0)).await?; + Ok(start == end) +} + fn validate_paths(paths: &[BoxPath]) -> Result<(), RewriteError> { if paths.iter().any(BoxPath::is_empty) { return Err(RewriteError::EmptyPath); diff --git a/src/sidx.rs b/src/sidx.rs index 6e6e44d..1369650 100644 --- a/src/sidx.rs +++ b/src/sidx.rs @@ -9,6 +9,8 @@ use std::fmt; use std::io::{self, Cursor, Read, Seek, SeekFrom, Write}; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; use crate::boxes::iso14496_12::{ Mdhd, Sidx, SidxReference, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, TRUN_SAMPLE_DURATION_PRESENT, Tfdt, Tfhd, Tkhd, @@ -18,6 +20,8 @@ use crate::codec::{CodecBox, CodecError, ImmutableBox, MutableBox, marshal, unma use crate::extract::{ExtractError, extract_box_as, extract_boxes}; use crate::header::{BoxInfo, HeaderError, LARGE_HEADER_SIZE, SMALL_HEADER_SIZE}; use crate::walk::BoxPath; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; const MOOV: FourCc = FourCc::from_bytes(*b"moov"); const MOOF: FourCc = FourCc::from_bytes(*b"moof"); @@ -812,6 +816,20 @@ pub fn analyze_top_level_sidx_update_bytes( analyze_top_level_sidx_update(&mut reader) } +/// Analyzes a fragmented file through the additive Tokio-based async library surface and returns +/// the default inputs for a top-level `sidx` refresh. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn analyze_top_level_sidx_update_async( + reader: &mut R, +) -> Result +where + R: AsyncReadSeek, +{ + let input = read_all_bytes_async(reader).await?; + analyze_top_level_sidx_update_bytes(&input) +} + /// Builds a deterministic top-level `sidx` refresh plan from analyzed file data. /// /// Returns `Ok(None)` when the file does not currently contain a top-level `sidx` and @@ -950,6 +968,24 @@ pub fn plan_top_level_sidx_update_bytes( plan_top_level_sidx_update(&mut reader, options) } +/// Analyzes a fragmented file through the additive Tokio-based async library surface and builds +/// the deterministic top-level `sidx` refresh plan. +/// +/// Returns `Ok(None)` when `add_if_not_exists` is `false` and no file-level top-level `sidx` +/// exists yet. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn plan_top_level_sidx_update_async( + reader: &mut R, + options: TopLevelSidxPlanOptions, +) -> Result, SidxPlanError> +where + R: AsyncReadSeek, +{ + let analysis = analyze_top_level_sidx_update_async(reader).await?; + build_top_level_sidx_plan(&analysis, options) +} + /// Applies a deterministic top-level `sidx` plan to a fragmented file and writes the updated bytes /// to `writer`. /// @@ -1004,6 +1040,50 @@ pub fn apply_top_level_sidx_plan_bytes( Ok(writer) } +/// Applies a deterministic top-level `sidx` plan through the additive Tokio-based async library +/// surface and writes the updated bytes to `writer`. +/// +/// The helper only rewrites the planned top-level `sidx` span. All other bytes are copied through +/// verbatim. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn apply_top_level_sidx_plan_async( + reader: &mut R, + writer: &mut W, + plan: &TopLevelSidxPlan, +) -> Result +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + validate_rewrite_plan(plan)?; + + validate_root_box_async(reader, &plan.insertion_box).await?; + let (write_offset, removed_size) = match &plan.action { + TopLevelSidxPlanAction::Insert => (plan.insertion_box.offset(), 0), + TopLevelSidxPlanAction::Replace { existing } => { + validate_root_box_async(reader, &existing.info).await?; + (existing.info.offset(), existing.info.size()) + } + }; + + let rewritten = build_rewritten_sidx(plan, write_offset, removed_size)?; + let input_end = reader.seek(SeekFrom::End(0)).await?; + let removed_end = checked_add_rewrite(write_offset, removed_size, "planned removed span end")?; + let trailing_size = + input_end + .checked_sub(removed_end) + .ok_or(SidxRewriteError::NumericOverflow { + field_name: "trailing rewrite bytes", + })?; + + copy_range_exact_async(reader, writer, 0, write_offset).await?; + writer.write_all(&rewritten.bytes).await?; + copy_range_exact_async(reader, writer, removed_end, trailing_size).await?; + + Ok(rewritten.applied) +} + fn scan_root_boxes(reader: &mut R) -> Result, SidxAnalysisError> where R: Read + Seek, @@ -1510,6 +1590,18 @@ fn checked_add(lhs: u64, rhs: u64, field_name: &'static str) -> Result(reader: &mut R) -> Result, SidxAnalysisError> +where + R: AsyncReadSeek, +{ + reader.seek(SeekFrom::Start(0)).await?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + reader.seek(SeekFrom::Start(0)).await?; + Ok(bytes) +} + fn encoded_payload_size(sidx: &Sidx) -> Result { let mut payload = Vec::new(); marshal(&mut payload, sidx, None)?; @@ -1570,6 +1662,30 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn validate_root_box_async( + reader: &mut R, + expected: &BoxInfo, +) -> Result<(), SidxRewriteError> +where + R: AsyncReadSeek, +{ + reader.seek(SeekFrom::Start(expected.offset())).await?; + let actual = BoxInfo::read_async(reader).await?; + if actual.box_type() != expected.box_type() || actual.size() != expected.size() { + return Err(SidxRewriteError::PlannedBoxMismatch { + expected_type: expected.box_type(), + expected_offset: expected.offset(), + expected_size: expected.size(), + actual_type: actual.box_type(), + actual_offset: actual.offset(), + actual_size: actual.size(), + }); + } + + Ok(()) +} + fn build_rewritten_sidx( plan: &TopLevelSidxPlan, write_offset: u64, @@ -1734,6 +1850,34 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn copy_range_exact_async( + reader: &mut R, + writer: &mut W, + start: u64, + len: u64, +) -> Result<(), SidxRewriteError> +where + R: AsyncReadSeek, + W: AsyncWriteSeek, +{ + if len == 0 { + return Ok(()); + } + + reader.seek(SeekFrom::Start(start)).await?; + let mut limited = (&mut *reader).take(len); + let copied = tokio::io::copy(&mut limited, writer).await?; + if copied != len { + return Err(SidxRewriteError::IncompleteCopy { + expected_size: len, + actual_size: copied, + }); + } + + Ok(()) +} + fn checked_add_rewrite( lhs: u64, rhs: u64, diff --git a/src/walk.rs b/src/walk.rs index c7aa0c5..8d7912e 100644 --- a/src/walk.rs +++ b/src/walk.rs @@ -2,11 +2,17 @@ use std::error::Error; use std::fmt; +#[cfg(feature = "async")] +use std::future::Future; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::ops::Deref; +#[cfg(feature = "async")] +use std::pin::Pin; use std::str::FromStr; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWrite}; use crate::boxes::iso14496_12::{ Ftyp, VisualSampleEntry, split_box_children_with_optional_trailing_bytes, }; @@ -15,6 +21,8 @@ use crate::boxes::{BoxLookupContext, BoxRegistry, default_registry}; use crate::codec::{CodecError, DynCodecBox, unmarshal, unmarshal_any_with_context}; use crate::fourcc::ParseFourCcError; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeekExt}; const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); const KEYS: FourCc = FourCc::from_bytes(*b"keys"); @@ -256,6 +264,67 @@ pub struct WalkHandle<'a, R> { children_layout: Option, } +/// Boxed future type used by closure-based async walk visitors. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub type AsyncWalkFuture<'a> = + Pin> + Send + 'a>>; + +/// Tokio-based async visitor view of one box during a depth-first structure walk. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub struct AsyncWalkHandle<'a, R> { + reader: &'a mut R, + registry: &'a BoxRegistry, + info: BoxInfo, + path: BoxPath, + descendant_lookup_context: BoxLookupContext, + children_layout: Option, +} + +/// Async visitor interface for the Tokio-based structure walker. +/// +/// The first async traversal rollout keeps the existing visitor-driven depth-first walk model but +/// allows the visitor to await payload decode or raw byte reads on the current box. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub trait AsyncWalkVisitor +where + R: AsyncReadSeek, + Self: Send, +{ + /// Future returned for one visited box. + type Future<'a>: Future> + Send + 'a + where + Self: 'a, + R: 'a; + + /// Visits one box and decides whether the walker should descend into its children. + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncWalkHandle<'r, R>) -> Self::Future<'a> + where + 'r: 'a; +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor for F +where + R: AsyncReadSeek, + F: Send + for<'a, 'r> FnMut(&'a mut AsyncWalkHandle<'r, R>) -> AsyncWalkFuture<'a>, +{ + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a, + R: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncWalkHandle<'r, R>) -> Self::Future<'a> + where + 'r: 'a, + { + self(handle) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ChildrenLayout { offset: u64, @@ -334,6 +403,90 @@ where } } +#[cfg(feature = "async")] +impl<'a, R> AsyncWalkHandle<'a, R> +where + R: AsyncReadSeek, +{ + /// Returns the header metadata for the current box. + pub const fn info(&self) -> &BoxInfo { + &self.info + } + + /// Returns the depth-first path to the current box. + pub fn path(&self) -> &BoxPath { + &self.path + } + + /// Returns the lookup context that will apply to direct children of this box. + pub const fn descendant_lookup_context(&self) -> BoxLookupContext { + self.descendant_lookup_context + } + + /// Returns `true` when the current box type is registered in the active lookup context. + pub fn is_supported_type(&self) -> bool { + self.registry + .is_registered_with_context(self.info.box_type(), self.info.lookup_context()) + } + + /// Decodes the current payload into a descriptor-backed runtime box value. + pub async fn read_payload_async(&mut self) -> Result<(Box, u64), WalkError> { + self.info.seek_to_payload_async(self.reader).await?; + let payload_size = self.info.payload_size()?; + let payload = crate::codec::read_exact_vec_untrusted_async( + self.reader, + usize::try_from(payload_size) + .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?, + ) + .await?; + self.info.seek_to_payload_async(self.reader).await?; + + let mut payload_reader = std::io::Cursor::new(payload.as_slice()); + let (boxed, read) = crate::codec::unmarshal_any_with_context( + &mut payload_reader, + payload_size, + self.info.box_type(), + self.registry, + self.info.lookup_context(), + None, + )?; + self.children_layout = Some(children_layout_for_buffered_payload( + &self.info, + payload_size, + read, + boxed.as_any().is::(), + &payload, + )?); + Ok((boxed, read)) + } + + /// Copies the raw payload bytes into `writer` without decoding them. + pub async fn read_data_async(&mut self, writer: &mut W) -> Result + where + W: AsyncWrite + Unpin, + { + self.info.seek_to_payload_async(self.reader).await?; + let payload_size = self.info.payload_size()?; + let mut limited = (&mut *self.reader).take(payload_size); + tokio::io::copy(&mut limited, writer) + .await + .map_err(WalkError::Io) + } + + async fn ensure_children_layout_async(&mut self) -> Result { + if let Some(children_layout) = self.children_layout { + return Ok(children_layout); + } + + self.read_payload_async().await?; + if let Some(children_layout) = self.children_layout { + Ok(children_layout) + } else { + unreachable!("read_payload_async always computes children layout") + } + } +} + /// Walks the file from the start in depth-first order using the built-in registry. pub fn walk_structure(reader: &mut R, visitor: F) -> Result<(), WalkError> where @@ -401,6 +554,87 @@ where ) } +/// Walks the file from the start in depth-first order through the additive Tokio-based async +/// surface using the built-in registry. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn walk_structure_async(reader: &mut R, visitor: V) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + let registry = default_registry(); + walk_structure_with_registry_async(reader, ®istry, visitor).await +} + +/// Walks the file from the start in depth-first order through the additive Tokio-based async +/// surface using `registry`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn walk_structure_with_registry_async( + reader: &mut R, + registry: &BoxRegistry, + mut visitor: V, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + reader.seek(SeekFrom::Start(0)).await?; + walk_sequence_async( + reader, + registry, + &mut visitor, + 0, + true, + &BoxPath::default(), + BoxLookupContext::new(), + ) + .await +} + +/// Walks `parent` and any expanded descendants through the additive Tokio-based async surface +/// using the built-in registry. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn walk_structure_from_box_async( + reader: &mut R, + parent: &BoxInfo, + visitor: V, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + let registry = default_registry(); + walk_structure_from_box_with_registry_async(reader, parent, ®istry, visitor).await +} + +/// Walks `parent` and any expanded descendants through the additive Tokio-based async surface +/// using `registry`. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn walk_structure_from_box_with_registry_async( + reader: &mut R, + parent: &BoxInfo, + registry: &BoxRegistry, + mut visitor: V, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + let mut parent = *parent; + walk_box_async( + reader, + registry, + &mut visitor, + &mut parent, + &BoxPath::default(), + ) + .await +} + fn walk_sequence( reader: &mut R, registry: &BoxRegistry, @@ -503,6 +737,120 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn walk_sequence_async( + reader: &mut R, + registry: &BoxRegistry, + visitor: &mut V, + mut remaining_size: u64, + is_root: bool, + path: &BoxPath, + mut sibling_lookup_context: BoxLookupContext, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + loop { + if !is_root && remaining_size < SMALL_HEADER_SIZE { + break; + } + + let start = reader.stream_position().await?; + let mut info = match BoxInfo::read_async(reader).await { + Ok(info) => info, + Err(HeaderError::Io(error)) + if is_root && clean_root_eof_async(reader, start, &error).await? => + { + return Ok(()); + } + Err(error) => return Err(error.into()), + }; + + if !is_root && info.size() > remaining_size { + return Err(WalkError::TooLargeBoxSize { + box_type: info.box_type(), + size: info.size(), + available_size: remaining_size, + }); + } + if !is_root { + remaining_size -= info.size(); + } + + info.set_lookup_context(sibling_lookup_context); + walk_box_async(reader, registry, visitor, &mut info, path).await?; + + if info.lookup_context().is_quicktime_compatible() { + sibling_lookup_context = sibling_lookup_context.with_quicktime_compatible(true); + } + if info.box_type() == KEYS { + sibling_lookup_context = sibling_lookup_context + .with_metadata_keys_entry_count(info.lookup_context().metadata_keys_entry_count()); + } + } + + if !is_root && remaining_size != 0 && !sibling_lookup_context.is_quicktime_compatible() { + return Err(WalkError::UnexpectedEof); + } + + Ok(()) +} + +#[cfg(feature = "async")] +async fn walk_box_async( + reader: &mut R, + registry: &BoxRegistry, + visitor: &mut V, + info: &mut BoxInfo, + path: &BoxPath, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, + V: AsyncWalkVisitor + Send, +{ + inspect_context_carriers_async(reader, info, path).await?; + + let path = path.child_path(info.box_type()); + let descendant_lookup_context = info.lookup_context().enter(info.box_type()); + let mut handle = AsyncWalkHandle { + reader, + registry, + info: *info, + path, + descendant_lookup_context, + children_layout: None, + }; + + let control = { + let future = visitor.visit(&mut handle); + future.await? + }; + if matches!(control, WalkControl::Descend) { + let children_layout = handle.ensure_children_layout_async().await?; + let path = handle.path.clone(); + let descendant_lookup_context = handle.descendant_lookup_context; + handle + .reader + .seek(SeekFrom::Start(children_layout.offset)) + .await?; + Box::pin(walk_sequence_async( + handle.reader, + handle.registry, + visitor, + children_layout.size, + false, + &path, + descendant_lookup_context, + )) + .await?; + } + + let info = handle.info; + info.seek_to_end_async(handle.reader).await?; + Ok(()) +} + fn children_layout_for_payload( reader: &mut R, info: &BoxInfo, @@ -527,6 +875,29 @@ where Ok(ChildrenLayout { offset, size }) } +#[cfg(feature = "async")] +fn children_layout_for_buffered_payload( + info: &BoxInfo, + payload_size: u64, + payload_read: u64, + is_visual_sample_entry: bool, + payload: &[u8], +) -> Result { + let offset = info.offset() + info.header_size() + payload_read; + let size = if is_visual_sample_entry { + let payload_read = usize::try_from(payload_read) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + let remaining = payload + .get(payload_read..) + .ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + split_box_children_with_optional_trailing_bytes(remaining) as u64 + } else { + payload_size.saturating_sub(payload_read) + }; + + Ok(ChildrenLayout { offset, size }) +} + fn visual_sample_entry_child_payload_size( reader: &mut R, extension_offset: u64, @@ -580,6 +951,33 @@ where Ok(()) } +#[cfg(feature = "async")] +async fn inspect_context_carriers_async( + reader: &mut R, + info: &mut BoxInfo, + path: &BoxPath, +) -> Result<(), WalkError> +where + R: AsyncReadSeek, +{ + if path.is_empty() && info.box_type() == FTYP { + let ftyp = decode_box_async::<_, Ftyp>(reader, info).await?; + if ftyp.has_compatible_brand(QT_BRAND) { + info.set_lookup_context(info.lookup_context().with_quicktime_compatible(true)); + } + } + + if info.box_type() == KEYS { + let keys = decode_box_async::<_, Keys>(reader, info).await?; + info.set_lookup_context( + info.lookup_context() + .with_metadata_keys_entry_count(keys.entry_count as usize), + ); + } + + Ok(()) +} + fn decode_box(reader: &mut R, info: &BoxInfo) -> Result where R: Read + Seek, @@ -592,6 +990,19 @@ where Ok(decoded) } +#[cfg(feature = "async")] +async fn decode_box_async(reader: &mut R, info: &BoxInfo) -> Result +where + R: AsyncReadSeek, + B: Default + crate::codec::CodecBox + Send, +{ + info.seek_to_payload_async(reader).await?; + let mut decoded = B::default(); + crate::codec::unmarshal_async(reader, info.payload_size()?, &mut decoded, None).await?; + info.seek_to_payload_async(reader).await?; + Ok(decoded) +} + fn clean_root_eof(reader: &mut R, start: u64, error: &io::Error) -> Result where R: Seek, @@ -604,6 +1015,23 @@ where Ok(start == end) } +#[cfg(feature = "async")] +async fn clean_root_eof_async( + reader: &mut R, + start: u64, + error: &io::Error, +) -> Result +where + R: AsyncReadSeek, +{ + if error.kind() != io::ErrorKind::UnexpectedEof { + return Ok(false); + } + + let end = reader.seek(SeekFrom::End(0)).await?; + Ok(start == end) +} + /// Errors raised while walking a box tree. #[derive(Debug)] pub enum WalkError { diff --git a/src/writer.rs b/src/writer.rs index 8308500..e74a268 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -3,9 +3,17 @@ use std::error::Error; use std::fmt; use std::io::{self, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; use crate::FourCc; +#[cfg(feature = "async")] +use crate::async_io::{AsyncReadSeek, AsyncWriteSeek}; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; +#[cfg(feature = "async")] +use tokio::io::{AsyncReadExt, AsyncSeek, AsyncSeekExt, AsyncWrite}; /// Stateful MP4 writer that can backfill container sizes after payload bytes are written. /// @@ -118,6 +126,94 @@ where } } +#[cfg(feature = "async")] +impl Writer +where + W: AsyncWriteSeek, +{ + /// Starts a new box through the additive Tokio-based async library surface using `box_type` + /// and an empty small-header placeholder. + /// + /// The final size is written later by [`Writer::end_box_async`]. + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn start_box_type_async(&mut self, box_type: FourCc) -> Result { + self.start_box_async(BoxInfo::new(box_type, SMALL_HEADER_SIZE)) + .await + } + + /// Writes `info` as the next box header through the additive Tokio-based async library + /// surface and pushes it onto the open-box stack. + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn start_box_async(&mut self, info: BoxInfo) -> Result { + let written = info.write_async(&mut self.writer).await?; + self.box_stack.push(written); + Ok(written) + } + + /// Rewrites the most recently opened box header with its final size through the additive + /// Tokio-based async library surface. + /// + /// The returned [`BoxInfo`] reflects the finalized on-disk size after the rewrite completes. + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn end_box_async(&mut self) -> Result { + let Some(started) = self.box_stack.pop() else { + return Err(WriterError::NoOpenBox); + }; + + let end = self.writer.stream_position().await?; + if end < started.offset() { + return Err(WriterError::InvalidBoxSpan { + box_type: started.box_type(), + offset: started.offset(), + end, + }); + } + + let final_size = end - started.offset(); + let rewritten = BoxInfo::new(started.box_type(), final_size) + .with_offset(started.offset()) + .with_header_size(started.header_size()) + .with_lookup_context(started.lookup_context()); + + self.writer.seek(SeekFrom::Start(started.offset())).await?; + let rewritten = rewritten.write_async(&mut self.writer).await?; + if rewritten.header_size() != started.header_size() { + return Err(WriterError::HeaderSizeChanged { + box_type: started.box_type(), + original_header_size: started.header_size(), + rewritten_header_size: rewritten.header_size(), + }); + } + + self.writer.seek(SeekFrom::Start(end)).await?; + Ok(rewritten) + } + + /// Copies the exact byte range described by `info` into the current output position through + /// the additive Tokio-based async library surface. + #[cfg_attr(docsrs, doc(cfg(feature = "async")))] + pub async fn copy_box_async( + &mut self, + reader: &mut R, + info: &BoxInfo, + ) -> Result<(), WriterError> + where + R: AsyncReadSeek, + { + info.seek_to_start_async(reader).await?; + let mut limited = (&mut *reader).take(info.size()); + let copied = tokio::io::copy(&mut limited, self).await?; + if copied != info.size() { + return Err(WriterError::IncompleteCopy { + expected_size: info.size(), + actual_size: copied, + }); + } + + Ok(()) + } +} + impl Write for Writer where W: Write, @@ -131,6 +227,47 @@ where } } +#[cfg(feature = "async")] +impl AsyncWrite for Writer +where + W: AsyncWrite + Unpin, +{ + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.writer).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.writer).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.writer).poll_shutdown(cx) + } +} + +#[cfg(feature = "async")] +impl AsyncSeek for Writer +where + W: AsyncSeek + Unpin, +{ + fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> { + let this = self.get_mut(); + Pin::new(&mut this.writer).start_seek(position) + } + + fn poll_complete(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + Pin::new(&mut this.writer).poll_complete(cx) + } +} + impl Seek for Writer where W: Seek, diff --git a/tests/async_feature_gate.rs b/tests/async_feature_gate.rs new file mode 100644 index 0000000..1b24f8c --- /dev/null +++ b/tests/async_feature_gate.rs @@ -0,0 +1,147 @@ +#![cfg(feature = "async")] + +use std::fs; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use mp4forge::FourCc; +use mp4forge::async_io::{AsyncReadSeek, AsyncWriteSeek}; +use mp4forge::boxes::iso14496_12::Ftyp; +use mp4forge::codec::{marshal_async, unmarshal_async}; +use mp4forge::header::BoxInfo; +use mp4forge::probe::probe_async; +use mp4forge::walk::{ + AsyncWalkFuture, AsyncWalkHandle, AsyncWalkVisitor, WalkControl, walk_structure_async, +}; +use tokio::fs::File as TokioFile; + +fn assert_async_read_seek(_value: &mut T) {} + +fn assert_async_write_seek(_value: &mut T) {} + +#[test] +fn cursor_satisfies_async_seek_aliases() { + let mut reader = Cursor::new(vec![0_u8; 4]); + assert_async_read_seek(&mut reader); + + let mut writer = Cursor::new(Vec::::new()); + assert_async_write_seek(&mut writer); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn typed_async_codec_futures_can_run_on_tokio_worker_threads() { + let ftyp = Ftyp { + major_brand: FourCc::from_bytes(*b"isom"), + minor_version: 0x0200, + compatible_brands: vec![FourCc::from_bytes(*b"isom")], + }; + + let encoded = tokio::spawn(async move { + let mut writer = Cursor::new(Vec::new()); + marshal_async(&mut writer, &ftyp, None).await.unwrap(); + writer.into_inner() + }) + .await + .unwrap(); + + let decoded = tokio::spawn(async move { + let mut header_and_payload = + Cursor::new(encode_raw_box(FourCc::from_bytes(*b"ftyp"), &encoded)); + let info = BoxInfo::read_async(&mut header_and_payload).await.unwrap(); + let mut decoded = Ftyp::default(); + unmarshal_async( + &mut header_and_payload, + info.payload_size().unwrap(), + &mut decoded, + None, + ) + .await + .unwrap(); + decoded + }) + .await + .unwrap(); + + assert_eq!(decoded.major_brand, FourCc::from_bytes(*b"isom")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_walk_visitor_future_can_run_on_tokio_worker_threads() { + let fixture = fixture_bytes(); + let visited = Arc::new(AtomicUsize::new(0)); + let visited_for_task = Arc::clone(&visited); + + let handle = tokio::spawn(async move { + let mut reader = Cursor::new(fixture); + walk_structure_async( + &mut reader, + CountingVisitor { + visited: visited_for_task, + }, + ) + .await + }); + + handle.await.unwrap().unwrap(); + assert!(visited.load(Ordering::Relaxed) > 0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn probe_async_file_handles_can_run_on_tokio_worker_threads() { + let fixture = fixture_path(); + + let summary = tokio::spawn(async move { + let mut file = TokioFile::open(fixture).await.unwrap(); + probe_async(&mut file).await.unwrap() + }) + .await + .unwrap(); + + assert_eq!(summary.tracks.len(), 2); +} + +struct CountingVisitor { + visited: Arc, +} + +impl AsyncWalkVisitor for CountingVisitor +where + R: AsyncReadSeek, +{ + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a, + R: 'a; + + fn visit<'a, 'r>(&'a mut self, _handle: &'a mut AsyncWalkHandle<'r, R>) -> Self::Future<'a> + where + 'r: 'a, + { + let visited = Arc::clone(&self.visited); + Box::pin(async move { + visited.fetch_add(1, Ordering::Relaxed); + Ok(WalkControl::Continue) + }) + } +} + +fn fixture_bytes() -> Vec { + fs::read(fixture_path()).unwrap() +} + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("sample.mp4") +} + +fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Vec { + let info = BoxInfo::new(box_type, 8 + payload.len() as u64); + let mut bytes = info.encode(); + bytes.extend_from_slice(payload); + bytes +} diff --git a/tests/bitio.rs b/tests/bitio.rs index e14a4e0..ed36a9f 100644 --- a/tests/bitio.rs +++ b/tests/bitio.rs @@ -1,5 +1,7 @@ use std::io::{Cursor, Read, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use mp4forge::bitio::{AsyncBitReader, AsyncBitWriter}; use mp4forge::bitio::{BitReader, BitWriter, INVALID_ALIGNMENT_MESSAGE}; #[test] @@ -68,3 +70,50 @@ fn byte_writes_fail_when_writer_is_not_aligned() { let error = writer.write(&[0xa4, 0x6f]).unwrap_err(); assert_eq!(error.to_string(), INVALID_ALIGNMENT_MESSAGE); } + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_read_and_write_match_bit_packing_examples() { + let mut writer = AsyncBitWriter::new(Cursor::new(Vec::new())); + writer.write_bits(&[0xda], 7).await.unwrap(); + writer.write_bits(&[0x07, 0x63, 0xd5], 17).await.unwrap(); + writer.write_all(&[0xa4, 0x6f]).await.unwrap(); + writer.write_bits(&[0x07, 0x69, 0xe3], 17).await.unwrap(); + writer.write_bit(true).await.unwrap(); + writer.write_bit(false).await.unwrap(); + writer.write_bits(&[0xf7], 5).await.unwrap(); + + let encoded = writer.into_inner().unwrap().into_inner(); + assert_eq!(encoded, [0xb5, 0x63, 0xd5, 0xa4, 0x6f, 0xb4, 0xf1, 0xd7]); + + let mut reader = AsyncBitReader::new(Cursor::new(encoded)); + assert_eq!(reader.read_bits(7).await.unwrap(), vec![0x5a]); + assert_eq!(reader.read_bits(17).await.unwrap(), vec![0x01, 0x63, 0xd5]); + + let mut aligned = [0_u8; 2]; + reader.read_exact(&mut aligned).await.unwrap(); + assert_eq!(aligned, [0xa4, 0x6f]); + + assert_eq!(reader.read_bits(17).await.unwrap(), vec![0x01, 0x69, 0xe3]); + assert!(reader.read_bit().await.unwrap()); + assert!(!reader.read_bit().await.unwrap()); + assert_eq!(reader.read_bits(5).await.unwrap(), vec![0x17]); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_alignment_guards_match_sync_behavior() { + let mut reader = AsyncBitReader::new(Cursor::new(vec![0x6c, 0x82, 0x41, 0x35, 0x71])); + assert_eq!(reader.stream_position().await.unwrap(), 0); + assert_eq!(reader.read_bits(3).await.unwrap(), vec![0x03]); + let error = reader.stream_position().await.unwrap_err(); + assert_eq!(error.to_string(), INVALID_ALIGNMENT_MESSAGE); + let error = reader.seek(SeekFrom::Current(1)).await.unwrap_err(); + assert_eq!(error.to_string(), INVALID_ALIGNMENT_MESSAGE); + + let mut writer = AsyncBitWriter::new(Cursor::new(Vec::new())); + writer.write_all(&[0xa4, 0x6f]).await.unwrap(); + writer.write_bits(&[0xda], 7).await.unwrap(); + let error = writer.write_all(&[0xa4, 0x6f]).await.unwrap_err(); + assert_eq!(error.to_string(), INVALID_ALIGNMENT_MESSAGE); +} diff --git a/tests/box_catalog_iso14496_12.rs b/tests/box_catalog_iso14496_12.rs index 4b4063a..7a0b9f2 100644 --- a/tests/box_catalog_iso14496_12.rs +++ b/tests/box_catalog_iso14496_12.rs @@ -32,6 +32,8 @@ use mp4forge::boxes::{AnyTypeBox, default_registry}; use mp4forge::codec::{ CodecBox, CodecError, ImmutableBox, MutableBox, marshal, unmarshal, unmarshal_any, }; +#[cfg(feature = "async")] +use mp4forge::codec::{marshal_async, unmarshal_any_async, unmarshal_async}; use mp4forge::stringify::stringify; fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) @@ -2318,6 +2320,63 @@ fn elng_preserves_payloads_without_full_box_header_bytes() { assert_eq!(encoded, payload); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_meta_and_prft_roundtrips_preserve_typed_behavior() { + let meta_payload = [ + 0x00, 0x00, 0x01, 0x00, b'h', b'd', b'l', b'r', 0x00, 0x00, 0x00, 0x00, + ]; + let mut meta = Meta::default(); + let mut meta_reader = Cursor::new(meta_payload); + let meta_read = unmarshal_async(&mut meta_reader, meta_payload.len() as u64, &mut meta, None) + .await + .unwrap(); + assert_eq!(meta_read, 0); + assert_eq!(meta_reader.position(), 0); + assert!(meta.is_quicktime_headerless()); + assert_eq!(meta.version(), 0); + assert_eq!(meta.flags(), 0); + + let mut prft = Prft::default(); + prft.set_version(1); + prft.set_flags(PRFT_TIME_MOOF_WRITTEN); + prft.reference_track_id = 7; + prft.ntp_timestamp = 0x0123_4567_89ab_cdef; + prft.media_time_v1 = 0x0fed_cba9_8765_4321; + + let expected = vec![ + 0x01, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x07, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, + 0xef, 0x0f, 0xed, 0xcb, 0xa9, 0x87, 0x65, 0x43, 0x21, + ]; + + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &prft, None).await.unwrap(); + assert_eq!(written, expected.len() as u64); + assert_eq!(encoded.into_inner(), expected); + + let mut decoded = Prft::default(); + let mut reader = Cursor::new(expected.clone()); + let read = unmarshal_async(&mut reader, expected.len() as u64, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, expected.len() as u64); + assert_eq!(decoded, prft); + + let registry = default_registry(); + let mut any_reader = Cursor::new(expected); + let (any_box, any_read) = unmarshal_any_async( + &mut any_reader, + 24, + FourCc::from_bytes(*b"prft"), + ®istry, + None, + ) + .await + .unwrap(); + assert_eq!(any_read, 24); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &prft); +} + #[test] fn counted_payload_validation_rejects_truncated_sbgp_entries() { let payload = [ diff --git a/tests/codec_roundtrip.rs b/tests/codec_roundtrip.rs index 5e22338..a817ba3 100644 --- a/tests/codec_roundtrip.rs +++ b/tests/codec_roundtrip.rs @@ -5,6 +5,8 @@ use mp4forge::codec::{ ANY_VERSION, CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, ImmutableBox, MutableBox, StringFieldMode, marshal, unmarshal, }; +#[cfg(feature = "async")] +use mp4forge::codec::{marshal_async, unmarshal_async}; use mp4forge::{FourCc, codec_field}; #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -413,3 +415,60 @@ fn unsupported_version_rolls_back_stream_position_and_state() { assert_eq!(dst.counter, 0); assert!(dst.name.is_empty()); } + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_marshal_and_unmarshal_descriptor_driven_box() { + let src = sample_box(); + let expected = vec![ + 0x01, 0x00, 0x02, 0x03, 0x77, 0x12, 0x34, 0xff, 0xfe, 0x80, 0x80, 0x80, 0xa4, 0x34, b'r', + b'u', b's', b't', 0x00, b'f', b'o', b'r', b'g', b'e', 0x00, b'A', b'B', b'C', b'D', 0x00, + 0x01, 0x12, 0x34, + ]; + + let mut encoded = Cursor::new(Vec::new()); + let written = marshal_async(&mut encoded, &src, None).await.unwrap(); + assert_eq!(written, expected.len() as u64); + assert_eq!(encoded.into_inner(), expected); + + let mut decoded = SampleBox { + hooks: src.hooks.clone(), + ..SampleBox::default() + }; + let mut cursor = Cursor::new(expected); + let payload_len = cursor.get_ref().len() as u64; + let read = unmarshal_async(&mut cursor, payload_len, &mut decoded, None) + .await + .unwrap(); + assert_eq!(read, payload_len); + assert_eq!(decoded, src); + assert_eq!(cursor.stream_position().unwrap(), payload_len); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_unsupported_version_rolls_back_stream_position_and_state() { + let payload = vec![0x03, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc]; + let mut cursor = Cursor::new(payload.clone()); + let mut dst = SampleBox::default(); + dst.set_version(0); + dst.set_flags(0x000111); + + let error = unmarshal_async(&mut cursor, payload.len() as u64, &mut dst, None) + .await + .unwrap_err(); + + match error { + CodecError::UnsupportedVersion { box_type, version } => { + assert_eq!(box_type, FourCc::from_bytes(*b"test")); + assert_eq!(version, 3); + } + other => panic!("unexpected error: {other}"), + } + + assert_eq!(cursor.stream_position().unwrap(), 0); + assert_eq!(dst.version(), 0); + assert_eq!(dst.flags(), 0x000111); + assert_eq!(dst.counter, 0); + assert!(dst.name.is_empty()); +} diff --git a/tests/default_feature_gate.rs b/tests/default_feature_gate.rs new file mode 100644 index 0000000..a6e9934 --- /dev/null +++ b/tests/default_feature_gate.rs @@ -0,0 +1,20 @@ +#![cfg(not(feature = "async"))] + +use std::fs; +use std::io::Cursor; +use std::path::PathBuf; + +use mp4forge::probe::probe; + +#[test] +fn default_build_keeps_sync_probe_surface_available() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("sample.mp4"); + + let bytes = fs::read(fixture).unwrap(); + let summary = probe(&mut Cursor::new(bytes)).unwrap(); + + assert_eq!(summary.tracks.len(), 2); +} diff --git a/tests/extract.rs b/tests/extract.rs index ae6681c..e442a80 100644 --- a/tests/extract.rs +++ b/tests/extract.rs @@ -16,15 +16,26 @@ use mp4forge::extract::{ extract_box_payload_bytes, extract_box_with_payload, extract_boxes, extract_boxes_as_bytes, extract_boxes_bytes, extract_boxes_payload_bytes, }; +#[cfg(feature = "async")] +use mp4forge::extract::{ + extract_box_as_async, extract_box_async, extract_box_bytes_async, + extract_box_payload_bytes_async, extract_box_with_payload_async, extract_boxes_async, +}; use mp4forge::stringify::stringify; use mp4forge::walk::BoxPath; use mp4forge::{BoxInfo, FourCc}; mod support; +#[cfg(feature = "async")] +use support::build_visual_sample_entry_box_with_trailing_bytes; +#[cfg(feature = "async")] +use support::write_temp_file; use support::{ build_encrypted_fragmented_video_file, build_event_message_movie_file, fixture_path, }; +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; #[test] fn extract_boxes_match_exact_wildcard_and_relative_paths() { @@ -69,6 +80,54 @@ fn extract_boxes_match_exact_wildcard_and_relative_paths() { assert_eq!(box_types(&relative), vec![fourcc("udta")]); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_boxes_match_exact_wildcard_and_relative_paths() { + let trak = encode_supported_box(&Trak, &[]); + let meta = encode_supported_box(&Meta::default(), &[]); + let udta = encode_supported_box(&Udta, &meta); + let moov = encode_supported_box(&Moov, &[trak, udta].concat()); + + let wildcard = extract_box_async( + &mut Cursor::new(moov.clone()), + None, + BoxPath::from([fourcc("moov"), FourCc::ANY]), + ) + .await + .unwrap(); + assert_eq!(box_types(&wildcard), vec![fourcc("trak"), fourcc("udta")]); + + let exact = extract_boxes_async( + &mut Cursor::new(moov.clone()), + None, + &[ + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("moov"), fourcc("udta")]), + ], + ) + .await + .unwrap(); + assert_eq!(box_types(&exact), vec![fourcc("moov"), fourcc("udta")]); + + let parent = extract_box_async( + &mut Cursor::new(moov.clone()), + None, + BoxPath::from([fourcc("moov")]), + ) + .await + .unwrap() + .pop() + .unwrap(); + let relative = extract_box_async( + &mut Cursor::new(moov), + Some(&parent), + BoxPath::from([fourcc("udta")]), + ) + .await + .unwrap(); + assert_eq!(box_types(&relative), vec![fourcc("udta")]); +} + #[test] fn extract_box_with_payload_uses_walked_lookup_context() { let qt = fourcc("qt "); @@ -133,6 +192,236 @@ fn extract_box_with_payload_uses_walked_lookup_context() { assert_eq!(numbered.data.data, b"1.0.0"); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_with_payload_uses_walked_lookup_context() { + let qt = fourcc("qt "); + let ftyp = Ftyp { + major_brand: qt, + minor_version: 0x0200, + compatible_brands: vec![qt], + }; + let mut keys = Keys::default(); + keys.entry_count = 1; + keys.entries = vec![Key { + key_size: 9, + key_namespace: fourcc("mdta"), + key_value: vec![b'x'], + }]; + + let mut numbered = NumberedMetadataItem::default(); + numbered.set_box_type(FourCc::from_u32(1)); + numbered.item_name = fourcc("data"); + numbered.data = Data { + data_type: DATA_TYPE_STRING_UTF8, + data_lang: 0, + data: b"1.0.0".to_vec(), + }; + + let keys_box = encode_supported_box(&keys, &[]); + let numbered_box = encode_supported_box(&numbered, &[]); + let ilst_box = encode_supported_box(&Ilst, &numbered_box); + let meta_box = encode_supported_box(&Meta::default(), &[keys_box, ilst_box].concat()); + let moov_box = encode_supported_box(&Moov, &meta_box); + let file = [encode_supported_box(&ftyp, &[]), moov_box].concat(); + + let extracted = extract_box_with_payload_async( + &mut Cursor::new(file), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("meta"), + fourcc("ilst"), + FourCc::from_u32(1), + ]), + ) + .await + .unwrap(); + + assert_eq!(extracted.len(), 1); + let extracted = &extracted[0]; + assert_eq!(extracted.info.box_type(), FourCc::from_u32(1)); + assert!(extracted.info.lookup_context().under_ilst()); + assert_eq!( + extracted.info.lookup_context().metadata_keys_entry_count(), + 1 + ); + + let numbered = extracted + .payload + .as_ref() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(numbered.item_name, fourcc("data")); + assert_eq!(numbered.data.data_type, DATA_TYPE_STRING_UTF8); + assert_eq!(numbered.data.data, b"1.0.0"); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_extract_helpers_can_run_on_tokio_worker_threads() { + let header_file = build_event_message_movie_file(); + let header_handle = tokio::spawn(async move { + let mut reader = Cursor::new(header_file); + let matches = extract_box_async( + &mut reader, + None, + BoxPath::from([fourcc("moov"), fourcc("trak")]), + ) + .await + .unwrap(); + (matches.len(), matches[0].box_type()) + }); + assert_eq!(header_handle.await.unwrap(), (1, fourcc("trak"))); + + let typed_file = build_encrypted_fragmented_video_file(); + let typed_handle = tokio::spawn(async move { + let mut reader = Cursor::new(typed_file); + let payloads = extract_box_as_async::<_, Senc>( + &mut reader, + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("senc")]), + ) + .await + .unwrap(); + (payloads.len(), payloads[0].sample_count) + }); + assert_eq!(typed_handle.await.unwrap(), (1, 1)); + + let bytes_file = build_event_message_movie_file(); + let bytes_handle = tokio::spawn(async move { + let mut reader = Cursor::new(bytes_file); + let boxes = extract_box_bytes_async( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("evte"), + fourcc("silb"), + ]), + ) + .await + .unwrap(); + boxes[0].len() + }); + assert!(bytes_handle.await.unwrap() > 8); + + let payload_file = build_event_message_movie_file(); + let expected_payload_len = extract_box_payload_bytes( + &mut Cursor::new(payload_file.clone()), + None, + BoxPath::from([fourcc("emib")]), + ) + .unwrap()[0] + .len(); + let payload_handle = tokio::spawn(async move { + let mut reader = Cursor::new(payload_file); + let payloads = + extract_box_payload_bytes_async(&mut reader, None, BoxPath::from([fourcc("emib")])) + .await + .unwrap(); + payloads[0].len() + }); + assert_eq!(payload_handle.await.unwrap(), expected_payload_len); + + let boxed_file = build_event_message_movie_file(); + let boxed_handle = tokio::spawn(async move { + let mut reader = Cursor::new(boxed_file); + let extracted = + extract_box_with_payload_async(&mut reader, None, BoxPath::from([fourcc("emeb")])) + .await + .unwrap(); + (extracted.len(), extracted[0].info.box_type()) + }); + assert_eq!(boxed_handle.await.unwrap(), (1, fourcc("emeb"))); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn async_extract_independent_file_tasks_can_run_concurrently_on_tokio_worker_threads() { + let event_a = write_temp_file( + "async-extract-concurrency-event-a", + &build_event_message_movie_file(), + ); + let event_b = write_temp_file( + "async-extract-concurrency-event-b", + &build_event_message_movie_file(), + ); + let event_c = write_temp_file( + "async-extract-concurrency-event-c", + &build_event_message_movie_file(), + ); + let encrypted = write_temp_file( + "async-extract-concurrency-encrypted", + &build_encrypted_fragmented_video_file(), + ); + + let header_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(event_a).await.unwrap(); + let matches = extract_box_async( + &mut reader, + None, + BoxPath::from([fourcc("moov"), fourcc("trak")]), + ) + .await + .unwrap(); + (matches.len(), matches[0].box_type()) + }); + + let payload_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(event_b).await.unwrap(); + let payloads = + extract_box_payload_bytes_async(&mut reader, None, BoxPath::from([fourcc("emib")])) + .await + .unwrap(); + payloads[0].len() + }); + + let typed_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(encrypted).await.unwrap(); + let payloads = extract_box_as_async::<_, Senc>( + &mut reader, + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("senc")]), + ) + .await + .unwrap(); + (payloads.len(), payloads[0].sample_count) + }); + + let bytes_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(event_c).await.unwrap(); + let boxes = extract_box_bytes_async( + &mut reader, + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("evte"), + fourcc("silb"), + ]), + ) + .await + .unwrap(); + boxes[0].len() + }); + + assert_eq!(header_handle.await.unwrap(), (1, fourcc("trak"))); + assert!(payload_handle.await.unwrap() > 0); + assert_eq!(typed_handle.await.unwrap(), (1, 1)); + assert!(bytes_handle.await.unwrap() > 8); +} + #[test] fn extract_box_as_returns_typed_payloads() { let mut tkhd_a = Tkhd::default(); @@ -160,6 +449,35 @@ fn extract_box_as_returns_typed_payloads() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_as_returns_typed_payloads() { + let mut tkhd_a = Tkhd::default(); + tkhd_a.track_id = 1; + let mut tkhd_b = Tkhd::default(); + tkhd_b.track_id = 2; + let trak_a = encode_supported_box(&Trak, &encode_supported_box(&tkhd_a, &[])); + let trak_b = encode_supported_box(&Trak, &encode_supported_box(&tkhd_b, &[])); + let moov = encode_supported_box(&Moov, &[trak_a, trak_b].concat()); + + let extracted = extract_box_as_async::<_, Tkhd>( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("trak"), fourcc("tkhd")]), + ) + .await + .unwrap(); + + assert_eq!(extracted.len(), 2); + assert_eq!( + extracted + .iter() + .map(|tkhd| tkhd.track_id) + .collect::>(), + vec![1, 2] + ); +} + #[test] fn extract_box_bytes_preserve_exact_leaf_box_bytes_for_relative_paths() { let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); @@ -185,6 +503,34 @@ fn extract_box_bytes_preserve_exact_leaf_box_bytes_for_relative_paths() { assert_eq!(extracted, vec![leaf]); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_bytes_preserve_exact_leaf_box_bytes_for_relative_paths() { + let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); + let udta = encode_supported_box(&Udta, &leaf); + let moov = encode_supported_box(&Moov, &udta); + + let parent = extract_box_async( + &mut Cursor::new(moov.clone()), + None, + BoxPath::from([fourcc("moov")]), + ) + .await + .unwrap() + .pop() + .unwrap(); + + let extracted = extract_box_bytes_async( + &mut Cursor::new(moov), + Some(&parent), + BoxPath::from([fourcc("udta"), fourcc("zzzz")]), + ) + .await + .unwrap(); + + assert_eq!(extracted, vec![leaf]); +} + #[test] fn extract_box_payload_bytes_preserve_exact_container_payload_bytes() { let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); @@ -201,6 +547,51 @@ fn extract_box_payload_bytes_preserve_exact_container_payload_bytes() { assert_eq!(extracted, vec![leaf]); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_payload_bytes_preserve_exact_container_payload_bytes() { + let leaf = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); + let udta = encode_supported_box(&Udta, &leaf); + let moov = encode_supported_box(&Moov, &udta); + + let extracted = extract_box_payload_bytes_async( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("udta")]), + ) + .await + .unwrap(); + + assert_eq!(extracted, vec![leaf]); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_bytes_descends_visual_sample_entry_children_without_trailing_bytes() { + let file = build_visual_sample_entry_box_with_trailing_bytes(); + + let extracted = extract_box_bytes_async( + &mut Cursor::new(file), + None, + BoxPath::from([fourcc("avc1"), fourcc("pasp")]), + ) + .await + .unwrap(); + + assert_eq!(extracted.len(), 1); + assert_eq!( + extract_box_async( + &mut Cursor::new(extracted[0].clone()), + None, + BoxPath::from([fourcc("pasp")]) + ) + .await + .unwrap() + .len(), + 1 + ); +} + #[test] fn extract_box_as_decodes_known_tref_children_and_preserves_unknown_ones_as_raw_bytes() { let cdsc = encode_supported_box( diff --git a/tests/fixture_probe_coverage.rs b/tests/fixture_probe_coverage.rs index b4cabd1..7705ada 100644 --- a/tests/fixture_probe_coverage.rs +++ b/tests/fixture_probe_coverage.rs @@ -4,6 +4,10 @@ use std::fs; use mp4forge::cli::probe as cli_probe; use mp4forge::probe::{TrackCodec, TrackCodecFamily, probe, probe_media_characteristics}; +#[cfg(feature = "async")] +use mp4forge::probe::{probe_async, probe_media_characteristics_async}; +#[cfg(feature = "async")] +use tokio::fs as tokio_fs; use support::fixture_path; @@ -249,3 +253,31 @@ fn codec_family_name(value: TrackCodecFamily) -> &'static str { TrackCodecFamily::WebVtt => "webvtt", } } + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_fixture_probe_surfaces_match_sync_results_for_added_codec_families() { + for file_name in [ + "vp9_opus.mp4", + "av1_opus.mp4", + "aac_audio.mp4", + "opus_audio.mp4", + "pcm_audio.mp4", + ] { + let path = fixture_path(file_name); + + let expected_summary = probe(&mut fs::File::open(&path).unwrap()).unwrap(); + let actual_summary = probe_async(&mut tokio_fs::File::open(&path).await.unwrap()) + .await + .unwrap(); + assert_eq!(actual_summary, expected_summary, "fixture={file_name}"); + + let expected_media = + probe_media_characteristics(&mut fs::File::open(&path).unwrap()).unwrap(); + let actual_media = + probe_media_characteristics_async(&mut tokio_fs::File::open(&path).await.unwrap()) + .await + .unwrap(); + assert_eq!(actual_media, expected_media, "fixture={file_name}"); + } +} diff --git a/tests/header.rs b/tests/header.rs index 85555ca..37a1084 100644 --- a/tests/header.rs +++ b/tests/header.rs @@ -139,6 +139,108 @@ fn seek_helpers_follow_box_boundaries() { assert_eq!(info.seek_to_end(&mut cursor).unwrap(), 50); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_write_and_read_preserve_offsets_and_header_forms() { + let cases = [ + ( + "small", + Vec::new(), + BoxInfo::new(fourcc(), 0x0001_2345).with_header_size(SMALL_HEADER_SIZE), + BoxInfo::new(fourcc(), 0x0001_2345).with_header_size(SMALL_HEADER_SIZE), + vec![0x00, 0x01, 0x23, 0x45, b't', b'e', b's', b't'], + ), + ( + "large", + Vec::new(), + BoxInfo::new(fourcc(), 0x0000_1234_5678_9abc).with_header_size(SMALL_HEADER_SIZE), + BoxInfo::new(fourcc(), 0x0000_1234_5678_9ac4).with_header_size(LARGE_HEADER_SIZE), + vec![ + 0x00, 0x00, 0x00, 0x01, b't', b'e', b's', b't', 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, + 0x9a, 0xbc, + ], + ), + ( + "extend-to-eof", + Vec::new(), + BoxInfo::new(fourcc(), 0x0123) + .with_header_size(SMALL_HEADER_SIZE) + .with_extend_to_eof(true), + BoxInfo::new(fourcc(), 0x0123) + .with_header_size(SMALL_HEADER_SIZE) + .with_extend_to_eof(true), + vec![0x00, 0x00, 0x00, 0x00, b't', b'e', b's', b't'], + ), + ]; + + for (name, prefix, info, expected_info, expected_bytes) in cases { + let mut cursor = Cursor::new(prefix); + cursor.seek(SeekFrom::End(0)).unwrap(); + + let written = info.write_async(&mut cursor).await.unwrap(); + assert_eq!(written, expected_info, "{name}"); + assert_eq!(cursor.into_inner(), expected_bytes, "{name}"); + } + + let read_cases = [ + ( + "small", + vec![0x00, 0x01, 0x23, 0x45, b't', b'e', b's', b't'], + 0, + BoxInfo::new(fourcc(), 0x0001_2345).with_header_size(SMALL_HEADER_SIZE), + SMALL_HEADER_SIZE, + ), + ( + "large", + vec![ + 0x00, 0x00, 0x00, 0x01, b't', b'e', b's', b't', 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, + ], + 0, + BoxInfo::new(fourcc(), 0x0123_4567_89ab_cdef).with_header_size(LARGE_HEADER_SIZE), + LARGE_HEADER_SIZE, + ), + ( + "extend-to-eof", + vec![ + 0x00, 0x00, 0x00, 0x00, b't', b'e', b's', b't', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ], + 0, + BoxInfo::new(fourcc(), 20) + .with_header_size(SMALL_HEADER_SIZE) + .with_extend_to_eof(true), + SMALL_HEADER_SIZE, + ), + ]; + + for (name, bytes, seek, expected, expected_position) in read_cases { + let mut cursor = Cursor::new(bytes); + cursor.seek(SeekFrom::Start(seek)).unwrap(); + + let info = BoxInfo::read_async(&mut cursor).await.unwrap(); + assert_eq!(info, expected, "{name}"); + assert_eq!( + cursor.stream_position().unwrap(), + expected_position, + "{name}" + ); + } +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_seek_helpers_follow_box_boundaries() { + let info = BoxInfo::new(fourcc(), 40) + .with_offset(10) + .with_header_size(LARGE_HEADER_SIZE); + + let mut cursor = Cursor::new(vec![0_u8; 64]); + assert_eq!(info.seek_to_start_async(&mut cursor).await.unwrap(), 10); + assert_eq!(info.seek_to_payload_async(&mut cursor).await.unwrap(), 26); + assert_eq!(info.seek_to_end_async(&mut cursor).await.unwrap(), 50); +} + fn fourcc() -> FourCc { FourCc::try_from("test").unwrap() } diff --git a/tests/parity_harness.rs b/tests/parity_harness.rs index 8959e23..f74b3af 100644 --- a/tests/parity_harness.rs +++ b/tests/parity_harness.rs @@ -12,10 +12,14 @@ use mp4forge::probe::{ ProbeError, ProbeOptions, TrackCodec, average_sample_bitrate, average_segment_bitrate, find_idr_frames, max_sample_bitrate, max_segment_bitrate, probe, probe_with_options, }; +#[cfg(feature = "async")] +use mp4forge::probe::{find_idr_frames_async, probe_async, probe_with_options_async}; use mp4forge::sidx::{ TopLevelSidxPlanOptions, apply_top_level_sidx_plan_bytes, plan_top_level_sidx_update_bytes, }; use mp4forge::walk::BoxPath; +#[cfg(feature = "async")] +use tokio::fs as tokio_fs; use support::{fixture_path, read_golden, read_text, temp_output_dir, write_temp_file}; @@ -359,6 +363,77 @@ fn lightweight_probe_report_matches_library_summary_across_representative_fixtur } } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_probe_surfaces_match_sync_summaries_across_shared_fixtures() { + for file_name in ["sample.mp4", "sample_fragmented.mp4", "sample_qt.mp4"] { + let path = fixture_path(file_name); + + let expected = probe(&mut std::fs::File::open(&path).unwrap()).unwrap(); + let actual = probe_async(&mut tokio_fs::File::open(&path).await.unwrap()) + .await + .unwrap(); + assert_eq!(actual, expected, "fixture={file_name}"); + + let expected_lightweight = probe_with_options( + &mut std::fs::File::open(&path).unwrap(), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_lightweight = probe_with_options_async( + &mut tokio_fs::File::open(&path).await.unwrap(), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!( + actual_lightweight, expected_lightweight, + "fixture={file_name}" + ); + } + + let sample_path = fixture_path("sample.mp4"); + let summary = probe(&mut std::fs::File::open(&sample_path).unwrap()).unwrap(); + let video_track = &summary.tracks[0]; + + let expected_idr = + find_idr_frames(&mut std::fs::File::open(&sample_path).unwrap(), video_track).unwrap(); + let actual_idr = find_idr_frames_async( + &mut tokio_fs::File::open(&sample_path).await.unwrap(), + video_track, + ) + .await + .unwrap(); + assert_eq!(actual_idr, expected_idr); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_probe_file_helpers_can_run_on_tokio_worker_threads() { + let sample_path = fixture_path("sample.mp4"); + let fragmented_path = fixture_path("sample_fragmented.mp4"); + let expected_summary = probe(&mut std::fs::File::open(&sample_path).unwrap()).unwrap(); + let expected_fragmented = probe_with_options( + &mut std::fs::File::open(&fragmented_path).unwrap(), + ProbeOptions::lightweight(), + ) + .unwrap(); + + let summary_handle = tokio::spawn(async move { + let mut file = tokio_fs::File::open(&sample_path).await.unwrap(); + probe_async(&mut file).await.unwrap() + }); + let fragmented_handle = tokio::spawn(async move { + let mut file = tokio_fs::File::open(&fragmented_path).await.unwrap(); + probe_with_options_async(&mut file, ProbeOptions::lightweight()) + .await + .unwrap() + }); + + assert_eq!(summary_handle.await.unwrap(), expected_summary); + assert_eq!(fragmented_handle.await.unwrap(), expected_fragmented); +} + #[test] fn extract_command_matches_library_box_boundaries_on_shared_fixtures() { let cases = [ diff --git a/tests/probe.rs b/tests/probe.rs index a90a611..8160641 100644 --- a/tests/probe.rs +++ b/tests/probe.rs @@ -1,5 +1,7 @@ #![allow(clippy::field_reassign_with_default)] +#[cfg(feature = "async")] +use std::fs; use std::io::Cursor; use mp4forge::boxes::AnyTypeBox; @@ -47,10 +49,25 @@ use mp4forge::probe::{ probe_media_characteristics_bytes_with_options, probe_media_characteristics_with_options, probe_with_options, }; +#[cfg(feature = "async")] +use mp4forge::probe::{ + find_idr_frames_async, probe_async, probe_codec_detailed_async, + probe_codec_detailed_with_options_async, probe_detailed_async, + probe_detailed_with_options_async, probe_extended_media_characteristics_async, + probe_extended_media_characteristics_with_options, + probe_extended_media_characteristics_with_options_async, probe_fra_async, + probe_fra_codec_detailed_async, probe_fra_detailed_async, probe_fra_media_characteristics, + probe_fra_media_characteristics_async, probe_media_characteristics_async, + probe_media_characteristics_with_options_async, probe_with_options_async, +}; use mp4forge::{BoxInfo, FourCc}; +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; mod support; +#[cfg(feature = "async")] +use support::fixture_path; use support::{build_encrypted_fragmented_video_file, build_event_message_movie_file}; #[test] @@ -159,6 +176,227 @@ fn probe_bytes_matches_cursor_based_probe() { assert_eq!(actual, expected); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_probe_surfaces_match_sync_cursor_probe_surfaces() { + let movie_file = build_movie_file(); + let expected_probe = probe(&mut Cursor::new(movie_file.clone())).unwrap(); + let actual_probe = probe_async(&mut Cursor::new(movie_file.clone())) + .await + .unwrap(); + assert_eq!(actual_probe, expected_probe); + + let expected_lightweight = probe_with_options( + &mut Cursor::new(movie_file.clone()), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_lightweight = probe_with_options_async( + &mut Cursor::new(movie_file.clone()), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!(actual_lightweight, expected_lightweight); + + let expected_detailed = probe_detailed(&mut Cursor::new(movie_file.clone())).unwrap(); + let actual_detailed = probe_detailed_async(&mut Cursor::new(movie_file.clone())) + .await + .unwrap(); + assert_eq!(actual_detailed, expected_detailed); + + let expected_detailed_lightweight = probe_detailed_with_options( + &mut Cursor::new(movie_file.clone()), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_detailed_lightweight = probe_detailed_with_options_async( + &mut Cursor::new(movie_file.clone()), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!(actual_detailed_lightweight, expected_detailed_lightweight); + + let video_track = expected_probe.tracks.first().unwrap(); + let expected_idr = find_idr_frames(&mut Cursor::new(movie_file.clone()), video_track).unwrap(); + let actual_idr = find_idr_frames_async(&mut Cursor::new(movie_file), video_track) + .await + .unwrap(); + assert_eq!(actual_idr, expected_idr); + + let hevc_file = build_hevc_movie_file(); + let expected_codec = probe_codec_detailed(&mut Cursor::new(hevc_file.clone())).unwrap(); + let actual_codec = probe_codec_detailed_async(&mut Cursor::new(hevc_file.clone())) + .await + .unwrap(); + assert_eq!(actual_codec, expected_codec); + + let expected_codec_lightweight = probe_codec_detailed_with_options( + &mut Cursor::new(hevc_file.clone()), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_codec_lightweight = probe_codec_detailed_with_options_async( + &mut Cursor::new(hevc_file), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!(actual_codec_lightweight, expected_codec_lightweight); + + let media_file = build_media_characteristics_movie_file(); + let expected_media = probe_media_characteristics(&mut Cursor::new(media_file.clone())).unwrap(); + let actual_media = probe_media_characteristics_async(&mut Cursor::new(media_file.clone())) + .await + .unwrap(); + assert_eq!(actual_media, expected_media); + + let expected_media_lightweight = probe_media_characteristics_with_options( + &mut Cursor::new(media_file.clone()), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_media_lightweight = probe_media_characteristics_with_options_async( + &mut Cursor::new(media_file.clone()), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!(actual_media_lightweight, expected_media_lightweight); + + let expected_extended = + probe_extended_media_characteristics(&mut Cursor::new(media_file.clone())).unwrap(); + let actual_extended = + probe_extended_media_characteristics_async(&mut Cursor::new(media_file.clone())) + .await + .unwrap(); + assert_eq!(actual_extended, expected_extended); + + let expected_extended_lightweight = probe_extended_media_characteristics_with_options( + &mut Cursor::new(media_file.clone()), + ProbeOptions::lightweight(), + ) + .unwrap(); + let actual_extended_lightweight = probe_extended_media_characteristics_with_options_async( + &mut Cursor::new(media_file), + ProbeOptions::lightweight(), + ) + .await + .unwrap(); + assert_eq!(actual_extended_lightweight, expected_extended_lightweight); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_probe_helpers_can_run_on_tokio_worker_threads() { + let movie_file = build_movie_file(); + let summary_handle = tokio::spawn(async move { + let mut reader = Cursor::new(movie_file); + let summary = probe_async(&mut reader).await.unwrap(); + (summary.tracks.len(), summary.tracks[0].track_id) + }); + assert_eq!(summary_handle.await.unwrap(), (2, 1)); + + let media_file = build_media_characteristics_movie_file(); + let expected_media = + probe_extended_media_characteristics(&mut Cursor::new(media_file.clone())).unwrap(); + let media_handle = tokio::spawn(async move { + let mut reader = Cursor::new(media_file); + let summary = probe_extended_media_characteristics_async(&mut reader) + .await + .unwrap(); + ( + summary.tracks.len(), + summary.tracks[0].summary.summary.track_id, + ) + }); + assert_eq!( + media_handle.await.unwrap(), + ( + expected_media.tracks.len(), + expected_media.tracks[0].summary.summary.track_id, + ) + ); + + let hevc_file = build_hevc_movie_file(); + let expected_codec = probe_codec_detailed(&mut Cursor::new(hevc_file.clone())).unwrap(); + let codec_handle = tokio::spawn(async move { + let mut reader = Cursor::new(hevc_file); + let summary = probe_codec_detailed_async(&mut reader).await.unwrap(); + ( + summary.tracks.len(), + summary.tracks[0].summary.summary.track_id, + ) + }); + assert_eq!( + codec_handle.await.unwrap(), + ( + expected_codec.tracks.len(), + expected_codec.tracks[0].summary.summary.track_id, + ) + ); + + let idr_file = build_movie_file(); + let mut sync_summary = probe(&mut Cursor::new(idr_file.clone())).unwrap(); + let track = sync_summary.tracks.remove(0); + let idr_handle = tokio::spawn(async move { + let mut reader = Cursor::new(idr_file); + find_idr_frames_async(&mut reader, &track).await.unwrap() + }); + assert_eq!(idr_handle.await.unwrap(), vec![0]); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn async_probe_independent_file_tasks_can_run_concurrently_on_tokio_worker_threads() { + let sample_path = fixture_path("sample.mp4"); + let fragmented_path = fixture_path("sample_fragmented.mp4"); + let qt_path = fixture_path("sample_qt.mp4"); + + let expected_sample = probe(&mut Cursor::new(fs::read(&sample_path).unwrap())).unwrap(); + let expected_fragmented = + probe_detailed(&mut Cursor::new(fs::read(&fragmented_path).unwrap())).unwrap(); + let expected_qt = probe_fra(&mut Cursor::new(fs::read(&qt_path).unwrap())).unwrap(); + + let sample_handle = tokio::spawn(async move { + let mut file = TokioFile::open(sample_path).await.unwrap(); + let summary = probe_async(&mut file).await.unwrap(); + (summary.tracks.len(), summary.tracks[0].track_id) + }); + + let fragmented_handle = tokio::spawn(async move { + let mut file = TokioFile::open(fragmented_path).await.unwrap(); + let summary = probe_detailed_async(&mut file).await.unwrap(); + (summary.tracks.len(), summary.tracks[0].summary.track_id) + }); + + let qt_handle = tokio::spawn(async move { + let mut file = TokioFile::open(qt_path).await.unwrap(); + let summary = probe_fra_async(&mut file).await.unwrap(); + (summary.tracks.len(), summary.tracks[0].track_id) + }); + + assert_eq!( + sample_handle.await.unwrap(), + ( + expected_sample.tracks.len(), + expected_sample.tracks[0].track_id + ) + ); + assert_eq!( + fragmented_handle.await.unwrap(), + ( + expected_fragmented.tracks.len(), + expected_fragmented.tracks[0].summary.track_id, + ) + ); + assert_eq!( + qt_handle.await.unwrap(), + (expected_qt.tracks.len(), expected_qt.tracks[0].track_id) + ); +} + #[test] fn probe_with_options_skips_expensive_expansions_but_preserves_core_summary() { let file = build_movie_file(); @@ -451,6 +689,59 @@ fn probe_fra_bytes_matches_cursor_based_probe_fra() { assert_eq!(actual, expected); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_fragment_probe_surfaces_match_sync_cursor_probe_surfaces() { + let fragment_file = build_fragment_file_with_prft(); + let expected_probe = probe(&mut Cursor::new(fragment_file.clone())).unwrap(); + let actual_probe = probe_async(&mut Cursor::new(fragment_file.clone())) + .await + .unwrap(); + assert_eq!(actual_probe, expected_probe); + + let expected_fra = probe_fra(&mut Cursor::new(fragment_file.clone())).unwrap(); + let actual_fra = probe_fra_async(&mut Cursor::new(fragment_file.clone())) + .await + .unwrap(); + assert_eq!(actual_fra, expected_fra); + + let expected_fra_detailed = + probe_fra_detailed(&mut Cursor::new(fragment_file.clone())).unwrap(); + let actual_fra_detailed = probe_fra_detailed_async(&mut Cursor::new(fragment_file.clone())) + .await + .unwrap(); + assert_eq!(actual_fra_detailed, expected_fra_detailed); + + let expected_fra_codec = + probe_fra_codec_detailed(&mut Cursor::new(fragment_file.clone())).unwrap(); + let actual_fra_codec = probe_fra_codec_detailed_async(&mut Cursor::new(fragment_file.clone())) + .await + .unwrap(); + assert_eq!(actual_fra_codec, expected_fra_codec); + + let expected_fra_media = + probe_fra_media_characteristics(&mut Cursor::new(fragment_file.clone())).unwrap(); + let actual_fra_media = probe_fra_media_characteristics_async(&mut Cursor::new(fragment_file)) + .await + .unwrap(); + assert_eq!(actual_fra_media, expected_fra_media); + + let encrypted_fragment_file = build_encrypted_fragmented_video_file(); + let expected_encrypted = + probe_detailed(&mut Cursor::new(encrypted_fragment_file.clone())).unwrap(); + let actual_encrypted = probe_detailed_async(&mut Cursor::new(encrypted_fragment_file.clone())) + .await + .unwrap(); + assert_eq!(actual_encrypted, expected_encrypted); + + let event_file = build_event_message_movie_file(); + let expected_event = probe_media_characteristics(&mut Cursor::new(event_file.clone())).unwrap(); + let actual_event = probe_media_characteristics_async(&mut Cursor::new(event_file)) + .await + .unwrap(); + assert_eq!(actual_event, expected_event); +} + #[test] fn probe_detailed_recognizes_av01_track_family() { let file = build_av01_movie_file(); diff --git a/tests/rewrite.rs b/tests/rewrite.rs index 15775cf..6c116fd 100644 --- a/tests/rewrite.rs +++ b/tests/rewrite.rs @@ -10,8 +10,14 @@ use mp4forge::extract::extract_box_as; use mp4forge::rewrite::{ RewriteError, rewrite_box_as, rewrite_box_as_bytes, rewrite_boxes_as_bytes, }; +#[cfg(feature = "async")] +use mp4forge::rewrite::{rewrite_box_as_async, rewrite_box_as_bytes_async}; use mp4forge::walk::BoxPath; +#[cfg(feature = "async")] +use tokio::fs::File as TokioFile; +#[cfg(feature = "async")] +use support::write_temp_file; use support::{ build_encrypted_fragmented_video_file, build_event_message_movie_file, encode_raw_box, encode_supported_box, fixture_path, fourcc, @@ -45,6 +51,36 @@ fn rewrite_box_as_updates_matching_typed_payloads() { assert_eq!(tfdt[0].base_media_decode_time_v0, 12_345); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_rewrite_box_as_updates_matching_typed_payloads() { + let input = build_rewrite_input_file(); + let mut reader = Cursor::new(input); + let mut output = Cursor::new(Vec::new()); + + let rewritten = rewrite_box_as_async::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + |tfdt| { + tfdt.base_media_decode_time_v0 = 12_345; + }, + ) + .await + .unwrap(); + + let tfdt = extract_box_as::<_, Tfdt>( + &mut Cursor::new(output.into_inner()), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ) + .unwrap(); + + assert_eq!(rewritten, 1); + assert_eq!(tfdt.len(), 1); + assert_eq!(tfdt[0].base_media_decode_time_v0, 12_345); +} + #[test] fn rewrite_box_as_bytes_updates_matching_typed_payloads() { let input = build_rewrite_input_file(); @@ -96,6 +132,36 @@ fn rewrite_box_as_bytes_updates_fragmented_encrypted_sample_group_descriptions() assert_eq!(sgpd[0].seig_entries_l[0].description_length, 20); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_rewrite_box_as_bytes_updates_fragmented_encrypted_sample_group_descriptions() { + let input = build_encrypted_fragmented_video_file(); + let output = rewrite_box_as_bytes_async::( + &input, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + |sgpd| { + sgpd.seig_entries_l[0].seig_entry.crypt_byte_block = 5; + sgpd.seig_entries_l[0].seig_entry.skip_byte_block = 6; + }, + ) + .await + .unwrap(); + + let sgpd = extract_box_as::<_, Sgpd>( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + ) + .unwrap(); + + assert_eq!(sgpd.len(), 1); + assert_eq!(sgpd[0].grouping_type, fourcc("seig")); + assert_eq!(sgpd[0].seig_entries_l.len(), 1); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.crypt_byte_block, 5); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.skip_byte_block, 6); + assert_eq!(sgpd[0].seig_entries_l[0].description_length, 20); +} + #[test] fn rewrite_box_as_returns_zero_and_preserves_bytes_when_nothing_matches() { let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); @@ -114,6 +180,204 @@ fn rewrite_box_as_returns_zero_and_preserves_bytes_when_nothing_matches() { assert_eq!(output.into_inner(), input); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_rewrite_box_as_returns_zero_and_preserves_bytes_when_nothing_matches() { + let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); + let mut reader = Cursor::new(input.clone()); + let mut output = Cursor::new(Vec::new()); + + let rewritten = rewrite_box_as_async::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("zzzz")]), + |_| {}, + ) + .await + .unwrap(); + + assert_eq!(rewritten, 0); + assert_eq!(output.into_inner(), input); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_rewrite_helpers_can_run_on_tokio_worker_threads() { + let input = build_rewrite_input_file(); + let typed_handle = tokio::spawn(async move { + let mut reader = Cursor::new(input); + let mut output = Cursor::new(Vec::new()); + let rewritten = rewrite_box_as_async::<_, _, Tfdt, _>( + &mut reader, + &mut output, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + |tfdt| { + tfdt.base_media_decode_time_v0 = 44_444; + }, + ) + .await + .unwrap(); + (rewritten, output.into_inner()) + }); + let (rewritten, typed_bytes) = typed_handle.await.unwrap(); + assert_eq!(rewritten, 1); + assert_eq!( + extract_box_as::<_, Tfdt>( + &mut Cursor::new(typed_bytes), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ) + .unwrap()[0] + .base_media_decode_time_v0, + 44_444 + ); + + let encrypted = build_encrypted_fragmented_video_file(); + let bytes_handle = tokio::spawn(async move { + rewrite_box_as_bytes_async::( + &encrypted, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + |sgpd| { + sgpd.seig_entries_l[0].seig_entry.crypt_byte_block = 7; + sgpd.seig_entries_l[0].seig_entry.skip_byte_block = 3; + }, + ) + .await + .unwrap() + }); + let rewritten_bytes = bytes_handle.await.unwrap(); + let sgpd = extract_box_as::<_, Sgpd>( + &mut Cursor::new(rewritten_bytes), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + ) + .unwrap(); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.crypt_byte_block, 7); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.skip_byte_block, 3); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn async_rewrite_independent_file_tasks_can_run_concurrently_on_tokio_worker_threads() { + let tfdt_input = write_temp_file( + "async-rewrite-concurrency-tfdt-in", + &build_rewrite_input_file(), + ); + let tfdt_output = write_temp_file("async-rewrite-concurrency-tfdt-out", &[]); + let silb_input = write_temp_file( + "async-rewrite-concurrency-silb-in", + &build_event_message_movie_file(), + ); + let silb_output = write_temp_file("async-rewrite-concurrency-silb-out", &[]); + let sgpd_input = write_temp_file( + "async-rewrite-concurrency-sgpd-in", + &build_encrypted_fragmented_video_file(), + ); + let sgpd_output = write_temp_file("async-rewrite-concurrency-sgpd-out", &[]); + + let tfdt_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(&tfdt_input).await.unwrap(); + let mut writer = TokioFile::create(&tfdt_output).await.unwrap(); + let rewritten = rewrite_box_as_async::<_, _, Tfdt, _>( + &mut reader, + &mut writer, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + |tfdt| { + tfdt.base_media_decode_time_v0 = 55_555; + }, + ) + .await + .unwrap(); + (rewritten, tfdt_output) + }); + + let silb_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(&silb_input).await.unwrap(); + let mut writer = TokioFile::create(&silb_output).await.unwrap(); + let rewritten = rewrite_box_as_async::<_, _, Silb, _>( + &mut reader, + &mut writer, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("evte"), + fourcc("silb"), + ]), + |silb| { + silb.schemes[0].value = "event-1c".to_string(); + silb.other_schemes_flag = false; + }, + ) + .await + .unwrap(); + (rewritten, silb_output) + }); + + let sgpd_handle = tokio::spawn(async move { + let mut reader = TokioFile::open(&sgpd_input).await.unwrap(); + let mut writer = TokioFile::create(&sgpd_output).await.unwrap(); + let rewritten = rewrite_box_as_async::<_, _, Sgpd, _>( + &mut reader, + &mut writer, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + |sgpd| { + sgpd.seig_entries_l[0].seig_entry.crypt_byte_block = 9; + sgpd.seig_entries_l[0].seig_entry.skip_byte_block = 4; + }, + ) + .await + .unwrap(); + (rewritten, sgpd_output) + }); + + let (tfdt_rewritten, tfdt_output) = tfdt_handle.await.unwrap(); + let (silb_rewritten, silb_output) = silb_handle.await.unwrap(); + let (sgpd_rewritten, sgpd_output) = sgpd_handle.await.unwrap(); + + assert_eq!(tfdt_rewritten, 1); + assert_eq!(silb_rewritten, 1); + assert_eq!(sgpd_rewritten, 1); + + let tfdt = extract_box_as::<_, Tfdt>( + &mut Cursor::new(fs::read(tfdt_output).unwrap()), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ) + .unwrap(); + assert_eq!(tfdt[0].base_media_decode_time_v0, 55_555); + + let silb = extract_box_as::<_, Silb>( + &mut Cursor::new(fs::read(silb_output).unwrap()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("evte"), + fourcc("silb"), + ]), + ) + .unwrap(); + assert_eq!(silb[0].schemes[0].value, "event-1c"); + assert!(!silb[0].other_schemes_flag); + + let sgpd = extract_box_as::<_, Sgpd>( + &mut Cursor::new(fs::read(sgpd_output).unwrap()), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + ) + .unwrap(); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.crypt_byte_block, 9); + assert_eq!(sgpd[0].seig_entries_l[0].seig_entry.skip_byte_block, 4); +} + #[test] fn rewrite_box_as_bytes_updates_event_message_boxes() { let input = build_event_message_movie_file(); diff --git a/tests/sidx.rs b/tests/sidx.rs index 216d0cd..a3d5871 100644 --- a/tests/sidx.rs +++ b/tests/sidx.rs @@ -19,6 +19,8 @@ use mp4forge::sidx::{ apply_top_level_sidx_plan, apply_top_level_sidx_plan_bytes, build_top_level_sidx_plan, plan_top_level_sidx_update, plan_top_level_sidx_update_bytes, }; +#[cfg(feature = "async")] +use mp4forge::sidx::{apply_top_level_sidx_plan_async, plan_top_level_sidx_update_async}; use mp4forge::walk::BoxPath; use support::{encode_raw_box, encode_supported_box, fixture_path, fourcc}; @@ -252,6 +254,32 @@ fn plan_top_level_sidx_update_builds_insert_plan_with_default_values() { assert!(plan.encoded_box_size >= 44); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_plan_top_level_sidx_update_builds_insert_plan_with_default_values() { + let input = build_styp_fragmented_single_track_file(); + + let async_plan = plan_top_level_sidx_update_async( + &mut Cursor::new(&input), + TopLevelSidxPlanOptions { + add_if_not_exists: true, + non_zero_ept: false, + }, + ) + .await + .unwrap(); + let sync_plan = plan_top_level_sidx_update_bytes( + &input, + TopLevelSidxPlanOptions { + add_if_not_exists: true, + non_zero_ept: false, + }, + ) + .unwrap(); + + assert_eq!(async_plan, sync_plan); +} + #[test] fn plan_top_level_sidx_update_builds_replace_plan_with_non_zero_ept() { let input = build_top_level_sidx_fragmented_single_track_file(false); @@ -380,6 +408,44 @@ fn apply_top_level_sidx_plan_bytes_inserts_top_level_sidx_and_preserves_other_by ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_apply_top_level_sidx_plan_inserts_top_level_sidx_and_preserves_other_bytes() { + let input = build_styp_fragmented_single_track_file(); + let plan = plan_top_level_sidx_update_async( + &mut Cursor::new(&input), + TopLevelSidxPlanOptions { + add_if_not_exists: true, + non_zero_ept: false, + }, + ) + .await + .unwrap() + .unwrap(); + + let mut output = Cursor::new(Vec::new()); + let applied = apply_top_level_sidx_plan_async(&mut Cursor::new(&input), &mut output, &plan) + .await + .unwrap(); + let output = output.into_inner(); + let sidx = extract_box_as::<_, Sidx>( + &mut Cursor::new(&output), + None, + BoxPath::from([fourcc("sidx")]), + ) + .unwrap(); + + assert_eq!(sidx.len(), 1); + assert_eq!(sidx[0], applied.sidx); + let insertion_offset = plan.insertion_box.offset() as usize; + let encoded_size = applied.info.size() as usize; + assert_eq!(&output[..insertion_offset], &input[..insertion_offset]); + assert_eq!( + &output[insertion_offset + encoded_size..], + &input[insertion_offset..] + ); +} + #[test] fn apply_top_level_sidx_plan_replaces_existing_box_and_preserves_following_bytes() { let input = build_gapped_top_level_sidx_fragmented_single_track_file_v0(); @@ -414,6 +480,45 @@ fn apply_top_level_sidx_plan_replaces_existing_box_and_preserves_following_bytes assert_eq!(&output[new_end..], &input[old_end..]); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_apply_top_level_sidx_plan_replaces_existing_box_and_preserves_following_bytes() { + let input = build_gapped_top_level_sidx_fragmented_single_track_file_v0(); + let plan = plan_top_level_sidx_update_async( + &mut Cursor::new(&input), + TopLevelSidxPlanOptions { + add_if_not_exists: false, + non_zero_ept: true, + }, + ) + .await + .unwrap() + .unwrap(); + + let existing = match &plan.action { + TopLevelSidxPlanAction::Replace { existing } => existing.clone(), + TopLevelSidxPlanAction::Insert => panic!("expected replace plan"), + }; + + let mut output = Cursor::new(Vec::new()); + let applied = apply_top_level_sidx_plan_async(&mut Cursor::new(&input), &mut output, &plan) + .await + .unwrap(); + let output = output.into_inner(); + + assert_eq!(applied.info.offset(), existing.info.offset()); + assert_eq!(applied.sidx.version(), 1); + assert_eq!(applied.sidx.earliest_presentation_time(), 105); + assert_eq!(applied.sidx.first_offset(), segment_gap_box().len() as u64); + assert_eq!( + &output[..existing.info.offset() as usize], + &input[..existing.info.offset() as usize] + ); + let new_end = (applied.info.offset() + applied.info.size()) as usize; + let old_end = (existing.info.offset() + existing.info.size()) as usize; + assert_eq!(&output[new_end..], &input[old_end..]); +} + #[test] fn apply_top_level_sidx_plan_bytes_is_stable_after_replanning() { let input = build_styp_fragmented_single_track_file(); @@ -463,6 +568,82 @@ fn apply_top_level_sidx_plan_bytes_rejects_stale_input() { )); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_apply_top_level_sidx_plan_rejects_stale_input() { + let input = build_styp_fragmented_single_track_file(); + let stale_input = build_audio_first_fragmented_file(); + let plan = plan_top_level_sidx_update_async( + &mut Cursor::new(&input), + TopLevelSidxPlanOptions { + add_if_not_exists: true, + non_zero_ept: false, + }, + ) + .await + .unwrap() + .unwrap(); + + let error = apply_top_level_sidx_plan_async( + &mut Cursor::new(&stale_input), + &mut Cursor::new(Vec::new()), + &plan, + ) + .await + .unwrap_err(); + + assert!(matches!( + error, + SidxRewriteError::PlannedBoxMismatch { + expected_type, + .. + } if expected_type == fourcc("styp") + )); +} + +#[cfg(feature = "async")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_sidx_helpers_can_run_on_tokio_worker_threads() { + let input = build_styp_fragmented_single_track_file(); + let plan_handle = tokio::spawn(async move { + let mut reader = Cursor::new(input); + plan_top_level_sidx_update_async( + &mut reader, + TopLevelSidxPlanOptions { + add_if_not_exists: true, + non_zero_ept: false, + }, + ) + .await + .unwrap() + .unwrap() + }); + let plan = plan_handle.await.unwrap(); + assert_eq!(plan.sidx.reference_count, 2); + + let input = build_styp_fragmented_single_track_file(); + let apply_handle = tokio::spawn(async move { + let mut reader = Cursor::new(input); + let mut writer = Cursor::new(Vec::new()); + let applied = apply_top_level_sidx_plan_async(&mut reader, &mut writer, &plan) + .await + .unwrap(); + (applied, writer.into_inner()) + }); + let (applied, bytes) = apply_handle.await.unwrap(); + assert_eq!(applied.sidx.reference_count, 2); + assert_eq!( + extract_box_as::<_, Sidx>( + &mut Cursor::new(bytes), + None, + BoxPath::from([fourcc("sidx")]) + ) + .unwrap() + .len(), + 1 + ); +} + #[test] fn build_top_level_sidx_plan_rejects_multiple_file_level_top_level_sidx_boxes() { let input = build_multiple_top_level_sidx_fragmented_single_track_file(); diff --git a/tests/structure_walk.rs b/tests/structure_walk.rs index df7244f..fb461a3 100644 --- a/tests/structure_walk.rs +++ b/tests/structure_walk.rs @@ -3,9 +3,123 @@ use std::io::Cursor; use mp4forge::boxes::iso14496_12::{Meta, Moov, Trak, Udta}; use mp4forge::codec::{CodecBox, marshal}; use mp4forge::header::HeaderError; +#[cfg(feature = "async")] +use mp4forge::walk::{ + AsyncWalkFuture, AsyncWalkHandle, AsyncWalkVisitor, walk_structure_async, + walk_structure_from_box_async, +}; use mp4forge::walk::{BoxPath, WalkControl, WalkError, walk_structure, walk_structure_from_box}; use mp4forge::{BoxInfo, FourCc}; +#[cfg(feature = "async")] +type AsyncCursorWalkHandle<'a> = AsyncWalkHandle<'a, Cursor>>; + +#[cfg(feature = "async")] +struct AsyncTrackingVisitor<'a> { + visited: &'a mut Vec, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor>> for AsyncTrackingVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncCursorWalkHandle<'r>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + self.visited.push(handle.path().clone()); + + match handle.info().box_type() { + box_type if box_type == fourcc("moov") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 0); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("trak") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 0); + assert!(payload.as_ref().as_any().is::()); + Ok(WalkControl::Continue) + } + box_type if box_type == fourcc("meta") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 4); + let meta = payload.as_ref().as_any().downcast_ref::().unwrap(); + assert!(!meta.is_quicktime_headerless()); + Ok(WalkControl::Continue) + } + box_type if box_type == fourcc("udta") => Ok(WalkControl::Descend), + box_type if box_type == fourcc("zzzz") => { + assert!(!handle.is_supported_type()); + let mut raw = Vec::new(); + assert_eq!(handle.read_data_async(&mut raw).await?, 4); + assert_eq!(raw, vec![0xde, 0xad, 0xbe, 0xef]); + Ok(WalkControl::Continue) + } + other => panic!("unexpected box {other}"), + } + }) + } +} + +#[cfg(feature = "async")] +struct AsyncMoovInfoVisitor<'a> { + moov_info: &'a mut Option, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor>> for AsyncMoovInfoVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncCursorWalkHandle<'r>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + if handle.info().box_type() == fourcc("moov") { + *self.moov_info = Some(*handle.info()); + } + Ok(WalkControl::Continue) + }) + } +} + +#[cfg(feature = "async")] +struct AsyncDescendMoovVisitor<'a> { + visited: &'a mut Vec, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor>> for AsyncDescendMoovVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncCursorWalkHandle<'r>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + self.visited.push(handle.path().clone()); + + if handle.info().box_type() == fourcc("moov") { + return Ok(WalkControl::Descend); + } + + Ok(WalkControl::Continue) + }) + } +} + #[test] fn walk_structure_tracks_paths_and_supports_raw_payload_reads() { let unknown = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); @@ -115,6 +229,70 @@ fn walk_structure_reports_invalid_zero_sized_boxes() { assert!(matches!(error, WalkError::Header(HeaderError::InvalidSize))); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_walk_structure_tracks_paths_and_supports_raw_payload_reads() { + let unknown = encode_raw_box(fourcc("zzzz"), &[0xde, 0xad, 0xbe, 0xef]); + let trak = encode_supported_box(&Trak, &[]); + let udta = encode_supported_box(&Udta, &unknown); + let meta = encode_supported_box(&Meta::default(), &[]); + let moov = encode_supported_box(&Moov, &[trak.clone(), meta, udta.clone()].concat()); + let file = moov.clone(); + + let mut visited = Vec::new(); + let visitor = AsyncTrackingVisitor { + visited: &mut visited, + }; + walk_structure_async(&mut Cursor::new(file), visitor) + .await + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("moov"), fourcc("trak")]), + BoxPath::from([fourcc("moov"), fourcc("meta")]), + BoxPath::from([fourcc("moov"), fourcc("udta")]), + BoxPath::from([fourcc("moov"), fourcc("udta"), fourcc("zzzz")]), + ] + ); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_walk_structure_from_box_reuses_parent_metadata_and_paths() { + let trak = encode_supported_box(&Trak, &[]); + let udta = encode_supported_box(&Udta, &[]); + let moov_bytes = encode_supported_box(&Moov, &[trak, udta].concat()); + + let mut moov_info = None; + let visitor = AsyncMoovInfoVisitor { + moov_info: &mut moov_info, + }; + walk_structure_async(&mut Cursor::new(moov_bytes.clone()), visitor) + .await + .unwrap(); + + let parent = moov_info.unwrap(); + let mut visited = Vec::new(); + let visitor = AsyncDescendMoovVisitor { + visited: &mut visited, + }; + walk_structure_from_box_async(&mut Cursor::new(moov_bytes), &parent, visitor) + .await + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("moov"), fourcc("trak")]), + BoxPath::from([fourcc("moov"), fourcc("udta")]), + ] + ); +} + fn fourcc(value: &str) -> FourCc { FourCc::try_from(value).unwrap() } diff --git a/tests/structure_walk_quicktime.rs b/tests/structure_walk_quicktime.rs index a61357e..58aa7cf 100644 --- a/tests/structure_walk_quicktime.rs +++ b/tests/structure_walk_quicktime.rs @@ -6,9 +6,95 @@ use mp4forge::boxes::metadata::{ }; use mp4forge::boxes::{AnyTypeBox, BoxLookupContext}; use mp4forge::codec::{CodecBox, marshal}; +#[cfg(feature = "async")] +use mp4forge::walk::{AsyncWalkFuture, AsyncWalkHandle, AsyncWalkVisitor, walk_structure_async}; use mp4forge::walk::{BoxPath, WalkControl, walk_structure}; use mp4forge::{BoxInfo, FourCc}; +#[cfg(feature = "async")] +type AsyncCursorWalkHandle<'a> = AsyncWalkHandle<'a, Cursor>>; + +#[cfg(feature = "async")] +struct AsyncQuickTimeVisitor<'a> { + visited: &'a mut Vec, +} + +#[cfg(feature = "async")] +impl AsyncWalkVisitor>> for AsyncQuickTimeVisitor<'_> { + type Future<'a> + = AsyncWalkFuture<'a> + where + Self: 'a; + + fn visit<'a, 'r>(&'a mut self, handle: &'a mut AsyncCursorWalkHandle<'r>) -> Self::Future<'a> + where + 'r: 'a, + { + Box::pin(async move { + self.visited.push(handle.path().clone()); + + match handle.info().box_type() { + box_type if box_type == fourcc("ftyp") => { + assert!(handle.info().lookup_context().is_quicktime_compatible()); + Ok(WalkControl::Continue) + } + box_type if box_type == fourcc("moov") => { + assert!(handle.info().lookup_context().is_quicktime_compatible()); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("meta") => { + assert!(handle.info().lookup_context().is_quicktime_compatible()); + assert_eq!( + handle.descendant_lookup_context(), + BoxLookupContext::new().with_quicktime_compatible(true) + ); + Ok(WalkControl::Descend) + } + box_type if box_type == fourcc("keys") => { + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 17); + let keys = payload.as_ref().as_any().downcast_ref::().unwrap(); + assert_eq!(keys.entry_count, 1); + assert_eq!( + handle.info().lookup_context().metadata_keys_entry_count(), + 1 + ); + Ok(WalkControl::Continue) + } + box_type if box_type == fourcc("ilst") => { + assert_eq!( + handle.info().lookup_context().metadata_keys_entry_count(), + 1 + ); + assert!(handle.descendant_lookup_context().under_ilst()); + Ok(WalkControl::Descend) + } + box_type if box_type == FourCc::from_u32(1) => { + assert!(handle.is_supported_type()); + assert_eq!( + handle.info().lookup_context().metadata_keys_entry_count(), + 1 + ); + assert!(handle.info().lookup_context().under_ilst()); + + let (payload, read) = handle.read_payload_async().await?; + assert_eq!(read, 21); + let numbered = payload + .as_ref() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(numbered.item_name, fourcc("data")); + assert_eq!(numbered.data.data_type, DATA_TYPE_STRING_UTF8); + assert_eq!(numbered.data.data, b"1.0.0"); + Ok(WalkControl::Continue) + } + other => panic!("unexpected box {other}"), + } + }) + } +} + #[test] fn walk_structure_carries_quicktime_brand_and_keys_context() { let qt = fourcc("qt "); @@ -124,6 +210,65 @@ fn walk_structure_carries_quicktime_brand_and_keys_context() { ); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_walk_structure_carries_quicktime_brand_and_keys_context() { + let qt = fourcc("qt "); + let ftyp = Ftyp { + major_brand: qt, + minor_version: 0x0200, + compatible_brands: vec![qt], + }; + let mut keys = Keys::default(); + keys.entry_count = 1; + keys.entries = vec![Key { + key_size: 9, + key_namespace: fourcc("mdta"), + key_value: vec![b'x'], + }]; + + let mut numbered = NumberedMetadataItem::default(); + numbered.set_box_type(FourCc::from_u32(1)); + numbered.item_name = fourcc("data"); + numbered.data = Data { + data_type: DATA_TYPE_STRING_UTF8, + data_lang: 0, + data: b"1.0.0".to_vec(), + }; + + let keys_box = encode_supported_box(&keys, &[]); + let numbered_box = encode_supported_box(&numbered, &[]); + let ilst_box = encode_supported_box(&Ilst, &numbered_box); + let meta_box = encode_supported_box(&Meta::default(), &[keys_box, ilst_box].concat()); + let moov_box = encode_supported_box(&Moov, &meta_box); + let file = [encode_supported_box(&ftyp, &[]), moov_box].concat(); + + let mut visited = Vec::new(); + let visitor = AsyncQuickTimeVisitor { + visited: &mut visited, + }; + walk_structure_async(&mut Cursor::new(file), visitor) + .await + .unwrap(); + + assert_eq!( + visited, + vec![ + BoxPath::from([fourcc("ftyp")]), + BoxPath::from([fourcc("moov")]), + BoxPath::from([fourcc("moov"), fourcc("meta")]), + BoxPath::from([fourcc("moov"), fourcc("meta"), fourcc("keys")]), + BoxPath::from([fourcc("moov"), fourcc("meta"), fourcc("ilst")]), + BoxPath::from([ + fourcc("moov"), + fourcc("meta"), + fourcc("ilst"), + FourCc::from_u32(1) + ]), + ] + ); +} + fn fourcc(value: &str) -> FourCc { FourCc::try_from(value).unwrap() } diff --git a/tests/writer.rs b/tests/writer.rs index 87ff9d1..43dcfdd 100644 --- a/tests/writer.rs +++ b/tests/writer.rs @@ -1,9 +1,15 @@ use std::io::{self, Cursor, Seek, SeekFrom, Write}; +#[cfg(feature = "async")] +use std::pin::Pin; +#[cfg(feature = "async")] +use std::task::{Context, Poll}; use mp4forge::boxes::iso14496_12::{Ftyp, Tkhd}; use mp4forge::codec::marshal; use mp4forge::writer::{Writer, WriterError}; use mp4forge::{BoxInfo, FourCc}; +#[cfg(feature = "async")] +use tokio::io::{AsyncSeek, AsyncSeekExt, AsyncWrite}; #[test] fn writer_backfills_sizes_and_copies_boxes() { @@ -61,7 +67,7 @@ fn writer_backfills_sizes_and_copies_boxes() { assert_eq!(info.offset(), 24); assert_eq!(info.size(), 123); - writer.seek(SeekFrom::Start(8)).unwrap(); + Seek::seek(&mut writer, SeekFrom::Start(8)).unwrap(); ftyp.compatible_brands[1] = fourcc("EFGH"); marshal(&mut writer, &ftyp, None).unwrap(); @@ -88,6 +94,101 @@ fn end_box_rejects_empty_stack() { assert!(matches!(error, WriterError::NoOpenBox)); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_writer_backfills_sizes_and_copies_boxes() { + let mut writer = Writer::new(Cursor::new(Vec::new())); + + let info = writer.start_box_type_async(fourcc("ftyp")).await.unwrap(); + assert_eq!(info.offset(), 0); + assert_eq!(info.size(), 8); + + let mut ftyp = Ftyp { + major_brand: fourcc("abem"), + minor_version: 0x1234_5678, + compatible_brands: vec![fourcc("abcd"), fourcc("efgh")], + }; + mp4forge::codec::marshal_async(&mut writer, &ftyp, None) + .await + .unwrap(); + + let info = writer.end_box_async().await.unwrap(); + assert_eq!(info.offset(), 0); + assert_eq!(info.size(), 24); + + let info = writer.start_box_type_async(fourcc("moov")).await.unwrap(); + assert_eq!(info.offset(), 24); + assert_eq!(info.size(), 8); + + writer + .copy_box_async( + &mut Cursor::new(vec![ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, b'u', b'd', b't', b'a', + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + ]), + &BoxInfo::new(fourcc("udta"), 15).with_offset(6), + ) + .await + .unwrap(); + + let info = writer.start_box_type_async(fourcc("trak")).await.unwrap(); + assert_eq!(info.offset(), 47); + assert_eq!(info.size(), 8); + + let info = writer.start_box_type_async(fourcc("tkhd")).await.unwrap(); + assert_eq!(info.offset(), 55); + assert_eq!(info.size(), 8); + + let tkhd = sample_tkhd(); + mp4forge::codec::marshal_async(&mut writer, &tkhd, None) + .await + .unwrap(); + + let info = writer.end_box_async().await.unwrap(); + assert_eq!(info.offset(), 55); + assert_eq!(info.size(), 92); + + let info = writer.end_box_async().await.unwrap(); + assert_eq!(info.offset(), 47); + assert_eq!(info.size(), 100); + + let info = writer.end_box_async().await.unwrap(); + assert_eq!(info.offset(), 24); + assert_eq!(info.size(), 123); + + AsyncSeekExt::seek(&mut writer, SeekFrom::Start(8)) + .await + .unwrap(); + ftyp.compatible_brands[1] = fourcc("EFGH"); + mp4forge::codec::marshal_async(&mut writer, &ftyp, None) + .await + .unwrap(); + + let mut expected = vec![ + 0x00, 0x00, 0x00, 0x18, b'f', b't', b'y', b'p', b'a', b'b', b'e', b'm', 0x12, 0x34, 0x56, + 0x78, b'a', b'b', b'c', b'd', b'E', b'F', b'G', b'H', 0x00, 0x00, 0x00, 0x7b, b'm', b'o', + b'o', b'v', 0x00, 0x00, 0x00, 0x0a, b'u', b'd', b't', b'a', 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x00, 0x00, 0x00, 0x64, b't', b'r', b'a', b'k', 0x00, 0x00, 0x00, 0x5c, b't', + b'k', b'h', b'd', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x06, 0x00, 0x07, 0x00, 0x00, + ]; + expected.extend_from_slice(&[0x00; 36]); + expected.extend_from_slice(&[0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x09]); + + assert_eq!(writer.into_inner().into_inner(), expected); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_end_box_rejects_empty_stack() { + let error = Writer::new(Cursor::new(Vec::::new())) + .end_box_async() + .await + .unwrap_err(); + assert!(matches!(error, WriterError::NoOpenBox)); +} + #[test] fn copy_box_rejects_short_source() { let mut writer = Writer::new(Cursor::new(Vec::new())); @@ -107,13 +208,32 @@ fn copy_box_rejects_short_source() { )); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_copy_box_rejects_short_source() { + let mut writer = Writer::new(Cursor::new(Vec::new())); + let error = writer + .copy_box_async( + &mut Cursor::new(vec![0x00, 0x00, 0x00, 0x08, b'f', b'r', b'e', b'e']), + &BoxInfo::new(fourcc("free"), 12), + ) + .await + .unwrap_err(); + + assert!(matches!( + error, + WriterError::IncompleteCopy { + expected_size: 12, + actual_size: 8 + } + )); +} + #[test] fn end_box_rejects_header_size_changes() { let mut writer = Writer::new(SparseBuffer::default()); writer.start_box_type(fourcc("wide")).unwrap(); - writer - .seek(SeekFrom::Start(u64::from(u32::MAX) + 1)) - .unwrap(); + Seek::seek(&mut writer, SeekFrom::Start(u64::from(u32::MAX) + 1)).unwrap(); let error = writer.end_box().unwrap_err(); assert!(matches!( @@ -126,6 +246,26 @@ fn end_box_rejects_header_size_changes() { )); } +#[cfg(feature = "async")] +#[tokio::test] +async fn async_end_box_rejects_header_size_changes() { + let mut writer = Writer::new(SparseBuffer::default()); + writer.start_box_type_async(fourcc("wide")).await.unwrap(); + AsyncSeekExt::seek(&mut writer, SeekFrom::Start(u64::from(u32::MAX) + 1)) + .await + .unwrap(); + + let error = writer.end_box_async().await.unwrap_err(); + assert!(matches!( + error, + WriterError::HeaderSizeChanged { + box_type, + original_header_size: 8, + rewritten_header_size: 16 + } if box_type == fourcc("wide") + )); +} + fn fourcc(value: &str) -> FourCc { FourCc::try_from(value).unwrap() } @@ -184,3 +324,34 @@ impl Seek for SparseBuffer { Ok(next) } } + +#[cfg(feature = "async")] +impl AsyncWrite for SparseBuffer { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Write::write(self.get_mut(), buf)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Write::flush(self.get_mut())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[cfg(feature = "async")] +impl AsyncSeek for SparseBuffer { + fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> io::Result<()> { + Seek::seek(self.get_mut(), position)?; + Ok(()) + } + + fn poll_complete(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(self.get_mut().position)) + } +}