Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mp4forge"
version = "0.5.0"
version = "0.6.0"
edition = "2024"
rust-version = "1.88"
authors = ["bakgio"]
Expand All @@ -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"
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions examples/probe_async.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error + Send + Sync>;

#[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::<Vec<_>>();
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(())
}
32 changes: 32 additions & 0 deletions src/async_io.rs
Original file line number Diff line number Diff line change
@@ -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<T> 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<T> AsyncWriteSeek for T where T: AsyncWrite + AsyncSeek + Unpin + Send {}
185 changes: 185 additions & 0 deletions src/bitio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -91,6 +97,97 @@ impl<R: Read + Seek> Seek for BitReader<R> {
}
}

/// 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<R> {
inner: R,
octet: u8,
remaining_bits: u8,
}

#[cfg(feature = "async")]
impl<R> AsyncBitReader<R> {
/// 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<R: AsyncRead + Unpin> AsyncBitReader<R> {
/// Reads `width` bits and packs them into a big-endian byte vector.
pub async fn read_bits(&mut self, width: usize) -> io::Result<Vec<u8>> {
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<bool> {
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<R: AsyncRead + AsyncSeek + Unpin> AsyncBitReader<R> {
/// Returns the current byte position.
pub async fn stream_position(&mut self) -> io::Result<u64> {
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<u64> {
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<W> {
Expand Down Expand Up @@ -174,6 +271,94 @@ impl<W: Write> Write for BitWriter<W> {
}
}

/// 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<W> {
inner: W,
octet: u8,
written_bits: u8,
}

#[cfg(feature = "async")]
impl<W> AsyncBitWriter<W> {
/// 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<W: AsyncWrite + Unpin> AsyncBitWriter<W> {
/// 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<W> {
if !self.is_aligned() {
return Err(invalid_alignment());
}

Ok(self.inner)
}
}

fn invalid_alignment() -> io::Error {
io::Error::new(ErrorKind::InvalidInput, INVALID_ALIGNMENT_MESSAGE)
}
Loading
Loading