containr

Library New

Media containers for vidr and voxr: reads and writes MP4, MOV, Matroska and WebM, with integer timestamps and raw edit lists.

Checked · Sources

Downloads
206
Dependents
2
Stars
0
Forks
0
Contributors
1
Commits
18
Latest release
0.1.1 ·

About

From the project README ·

Media containers in Rust. Packets in, packets out, nothing decoded.

Read and write MP4, MOV, Matroska, and WebM, with stream copy between them. Timestamps stay integers and edit lists stay raw.

Status · Features · Usage · Format details · Contributing

containr is an MP4, MOV, Matroska, and WebM demuxer and muxer for Rust. It does the container work of ffmpeg -c copy as a library, in Rust, with no C library and no Python.

containr holds the container layer of the ml-rust media stack. It parses boxes and EBML elements into tracks, packets, metadata, chapters, and attachments, and it writes them back. vidr (video) and voxr (audio) depend on it. Codecs live in those crates, so containr never decodes or encodes media.

file → demux → packets with integer timestamps → your codec, or mux → file

Status

In development. Version 0.1.0 is on crates.io.

FormatDemuxMuxFeature
MP4, QuickTime MOVYes. Fragmented MP4 (moof) is planned, and refused todayYesmp4
Matroska, WebMYesYes. WebM refuses codecs it cannot holdmkv
Read the full README
Not supported yet
  • Fragmented MP4 (moof) input. Demux refuses it with Error::Fragmented.
  • Decoding or encoding any codec. containr moves packets only.
  • Matroska attachments into MP4. MP4 has no form for them.
  • Exact AAC at 44.1 or 48 kHz and 29.97 fps video in Matroska. StreamCopy::plan refuses them, and StreamCopy::plan_rounded is the opt-in.
Stream copy
Source trackInto MP4/MOVInto MKV/WebM
From MP4/MOVAny track, by raw sample entryavc1, avc3, hvc1, hev1, av01, vp09, mp4a, Opus, tx3g
From MKV/WebMH.264, HEVC, AV1, AAC, Opus, S_TEXT/UTF8Any track

mkv::StreamCopy::plan refuses a timestamp Matroska cannot hold exactly. See what TimestampScale holds.

FFmpeg 8 and older store no duration on Matroska audio blocks, and every mux needs one on the last block. So when a last audio or video packet stores no duration, StreamCopy::plan gives it the gap between the track's two latest distinct timestamps, less its DiscardPadding. A track with one timestamp and a subtitle track get no such duration, and the muxer refuses them with Error::MissingDuration.

Features

Each container is a Cargo feature. The default build holds the shared types only.

cargo add containr --features mp4,mkv

The mp4 feature depends on the mp4-atom crate. The mkv feature has no dependency, because the EBML reader and writer are containr's own. The minimum Rust version is 1.95.

FeatureWhat it adds
mp4containr::mp4::Demuxer and Muxer for MP4 and QuickTime MOV
mkvcontainr::mkv::Demuxer and Muxer for Matroska and WebM

Usage

The examples need the mp4 and mkv features.

Read an MP4 or MOV file:

use containr::mp4::Demuxer;

fn main() -> Result<(), containr::Error> {
    let mut demuxer = Demuxer::open_path("input.mp4")?;
    for track in demuxer.tracks() {
        println!("track {}: {:?} {:?}", track.id, track.kind, track.codec());
    }
    for packet in demuxer.packets() {
        let packet = packet?;
        let bytes = demuxer.read_packet(&packet)?;
        println!("track {} pts {} size {}", packet.track_id, packet.pts, bytes.len());
    }
    Ok(())
}

containr::mkv::Demuxer has the same shape for Matroska and WebM:

use containr::mkv::Demuxer;

fn main() -> Result<(), containr::Error> {
    let mut demuxer = Demuxer::open_path("input.webm")?;
    for track in demuxer.tracks() {
        println!("track {}: {:?} {:?}", track.id, track.kind, track.codec_id());
    }
    for packet in demuxer.packets() {
        let packet = packet?;
        let bytes = demuxer.read_packet(&packet)?;
        println!("track {} pts {} size {}", packet.track_id, packet.pts, bytes.len());
    }
    Ok(())
}

Copy an MP4 file into a new one, with moov at the front. The muxer rebuilds the chapter track from set_chapters, so the copy skips it:

use std::collections::HashMap;
use std::fs::File;

use containr::mp4::{Demuxer, MuxOptions, Muxer, StreamCopy};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut input = Demuxer::open_path("input.mp4")?;
    let target = File::options()
        .read(true)
        .write(true)
        .create(true)
        .truncate(true)
        .open("output.mp4")?;
    let options = MuxOptions {
        faststart: true,
        ..MuxOptions::default()
    };
    let movie_timescale = options.movie_timescale;
    let mut output = Muxer::new(target, options)?;

    let mut plans = HashMap::new();
    for track in input.tracks().iter().filter(|track| !track.chapter_track) {
        let packets = input.track_packets(track.id)?.collect::<Result<Vec<_>, _>>()?;
        let plan = StreamCopy::plan(track, &packets, movie_timescale)?;
        let id = output.add_track(plan.spec.clone())?;
        plans.insert(track.id, (plan, id));
    }
    output.set_metadata(input.metadata().to_vec())?;
    output.set_chapters(StreamCopy::chapters(input.chapters()))?;
    for packet in input.packets() {
        let packet = packet?;
        let Some((plan, id)) = plans.get(&packet.track_id) else {
            continue;
        };
        let data = input.read_packet(&packet)?;
        output.write_packet(*id, &plan.packet(&packet, &data)?)?;
    }
    output.finish()?;
    Ok(())
}

Copy a Matroska or WebM file into a new one, with one plan per track:

use std::fs::File;

use containr::mkv::{Demuxer, MkvOptions, Muxer, StreamCopy};

fn main() -> Result<(), containr::Error> {
    let mut input = Demuxer::open_path("input.mkv")?;
    let mut plans = Vec::new();
    for track in input.tracks() {
        let packets = input
            .track_packets(track.id)?
            .collect::<Result<Vec<_>, _>>()?;
        plans.push((track.id, StreamCopy::plan(track, &packets)?));
    }
    let options = MkvOptions {
        timestamp_scale: input.info().timestamp_scale,
        title: input.info().title.clone(),
        ..MkvOptions::default()
    };
    let output = File::create("output.mkv")
        .map_err(|err| containr::Error::Io {
            what: "output.mkv".to_owned(),
            offset: 0,
            source: err,
        })?;
    let mut muxer = Muxer::new(output, options)?;
    let mut ids = Vec::new();
    for (source, plan) in &plans {
        ids.push((*source, muxer.add_track(plan.spec.clone())?));
    }
    muxer.set_metadata(input.metadata().to_vec())?;
    muxer.set_chapters(input.chapters().to_vec())?;
    for packet in input.packets() {
        let packet = packet?;
        let bytes = input.read_packet(&packet)?;
        let slot = ids.iter().position(|(source, _)| *source == packet.track_id);
        if let Some(slot) = slot
            && let Some(block) = plans[slot].1.packet(&packet, &bytes)?
        {
            muxer.write_packet(ids[slot].1, &block)?;
        }
    }
    muxer.finish()?;
    Ok(())
}

Copy an MP4 file with 29.97 fps video or 48 kHz AAC into Matroska, rounded to 1 ms ticks:

use std::fs::File;

use containr::mkv::{MkvOptions, Muxer, StreamCopy};
use containr::mp4::Demuxer;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut input = Demuxer::open_path("input.mp4")?;
    let mut plans = Vec::new();
    for track in input.tracks().iter().filter(|track| !track.chapter_track) {
        let packets = input.track_packets(track.id)?.collect::<Result<Vec<_>, _>>()?;
        let plan = StreamCopy::plan_rounded(track, &packets, 1_000_000)?;
        if let Some(rounding) = plan.rounding() {
            println!("track {}: at most {} ns off", track.id, rounding.max_error_ns);
        }
        plans.push((track.id, plan));
    }
    let scales: Vec<StreamCopy> = plans.iter().map(|(_, plan)| plan.clone()).collect();
    let options = MkvOptions {
        timestamp_scale: StreamCopy::timestamp_scale(&scales),
        ..MkvOptions::default()
    };
    let mut muxer = Muxer::new(File::create("output.mkv")?, options)?;
    let mut ids = Vec::new();
    for (source, plan) in &plans {
        ids.push((*source, muxer.add_track(plan.spec.clone())?));
    }
    for packet in input.packets() {
        let packet = packet?;
        let slot = ids.iter().position(|(source, _)| *source == packet.track_id);
        let Some(slot) = slot else {
            continue;
        };
        let bytes = input.read_packet(&packet)?;
        if let Some(block) = plans[slot].1.packet(&packet, &bytes)? {
            muxer.write_packet(ids[slot].1, &block)?;
        }
    }
    muxer.finish()?;
    Ok(())
}

Format details

MP4/MOV demux
  • moov can sit before or after mdat.
  • Each track carries its id, kind and handler type, timescale, duration, and language.
  • Each sample entry keeps its raw bytes for stream copy, plus avcC, hvcC, av1C, vpcC, esds or dOps.
  • Video tracks carry width, height, the pasp ratio, and the raw tkhd matrix. Audio tracks carry channels and sample rate.
  • VideoInfo::colour holds the H.273 tags of a colr box. Track::colour gives the value for either container.
    • nclx: tags and the full-range bit.
    • nclc (QuickTime): tags with no range.
    • prof, rICC: no H.273 values. colour stays None unless another colr box gives nclx.
    • A code point above 255 is an error.
  • Packets carry dts, pts (signed ctts included), duration, sync flag, byte offset, and size.
  • Sample tables: stts, ctts, stsc, stsz, stz2, stco, co64, stss. They expand one sample at a time, so memory tracks the table entries, not the sample count.
  • Edit lists come raw, with the movie timescale. The caller applies them.
  • Metadata: udta children and meta/ilst items, raw. Chapters come from chpl and from QuickTime chapter tracks.
  • Fragmented MP4 is refused with Error::Fragmented.
  • mp4::first_track_timing reads the mdhd timescale and raw edit list of the first track of a kind, from moov alone, with no sample tables. It accepts a fragmented file.
  • Malformed input returns an error that names the box path. Every size from the file is checked before allocation.
MP4/MOV mux
  • Muxer::new, add_track, set_metadata, set_chapters, write_packet, finish. The target implements Write and Seek, and Read for finish, since faststart reads mdat back.

  • TrackSpec carries the sample entry as raw stream-copy bytes, or builds it from avcC, hvcC, av1C, esds, or dOps.

  • TrackSpec also carries timescale, language, matrix, pasp, colour, and an edit list. colour writes an nclx colr box into a built visual entry. A raw entry keeps its own colr box.

  • TrackSpec::copy_from builds a spec from an MP4 track.

  • TrackSpec::copy_from also takes a Matroska H.264, HEVC, AV1, AAC, Opus or S_TEXT/UTF8 track. StreamCopy::plan adds packet timing and, for Matroska, the decode times and edit list MP4 needs.

  • Matroska timing into MP4:

    Matroska factMP4 result
    Negative timestampsThe edit list drops them
    Padding of the last blockThe edit list drops it
    AAC CodecDelayBecomes the edit's media_time. The segment lasts to the end of the last block less that delay
    A delay the Matroska tick cannot holdThe track timescale rises to a multiple that holds it, so no timestamp rounds
    A delay no tick count matches within 1 nsRefused. The error names Matroska
    Opus CodecDelayAdds no edit. dOps pre_skip already carries it, and a second skip drops it twice
  • Layout: ftyp, mdat, moov. MuxOptions::faststart moves moov to the front in a second pass on the same target. That pass adds the shift to every chunk offset.

  • Chunks last at most MuxOptions::chunk_duration_ms and interleave across tracks by decode time. MuxOptions::max_buffer bounds the bytes held back.

  • Sample tables: stts, ctts, stsc, stsz, stss, and stco.

  • co64 replaces stco past 4 GiB. Version 1 ctts with cslg holds a negative offset. The last sample's duration comes from its packet, so the track's total duration is exact.

  • Refused, each by name:

    • a codec MP4 cannot hold (the error names Matroska)
    • dts out of order
    • a composition offset ctts cannot store
    • a second sample description
    • a last packet with no duration and no default
  • Composition offsets range from -2^31 to 2^31 - 1, since readers treat ctts offsets as signed.

  • Known limits:

    • Stream copy drops MP4 sgpd and sbgp sample-group boxes.
    • Matroska attachments have no MP4 form, so the muxer never sees them.
    • A caller that copies Matroska to MP4 must check Demuxer::attachments(). When it lists any, refuse the copy and name MKV as the container that holds them.
MKV/WebM demux
  • The EBML reader is containr's own. It checks every size before it allocates, and decodes unknown sizes of any length.
  • Each track carries its TrackNumber as id, kind, TimestampScale timebase, and language. Track::matroska holds TrackUID, CodecID, raw CodecPrivate, Language and LanguageBCP47, DefaultDuration, CodecDelay, SeekPreRoll, flags, and ContentEncodings.
  • Video tracks carry pixel and display size, DisplayUnit, crop, and Projection with ProjectionPoseRoll. Audio tracks carry sampling frequency, channels, and bit depth.
  • MatroskaVideo::colour holds MatrixCoefficients, TransferCharacteristics, Primaries, and Range. The raw Colour element also stays in MatroskaVideo::extra.
    • Range 1 gives limited, 2 gives full, and 0 or 3 give None.
    • A code point above 255 is an error that names the element path.
  • Packets carry pts (Cluster Timestamp plus the block timestamp), duration, keyframe and discardable flags, DiscardPadding, byte offset, and size. dts is None: Matroska stores no decode timestamp.
  • Xiph, EBML and fixed lacing expand into one packet per frame. Each laced packet records its place in the lace.
  • Timestamps count in TimestampScale nanoseconds, as integers. CodecDelay and DiscardPadding come raw. The caller applies them.
  • A QuickTime Macintosh language code stays Language::Other.
    • Language::iso639 gives None for it.
    • Language::iso639_mapped maps it to ISO 639-2/T.
    • Codes with no table entry give None: 0x7FFF, 95 to 127, and above 138.
  • A Cluster of unknown size, as a live WebM writes it, ends at the next Segment child.
  • Cues, SeekHead entries, chapters from every edition, and Tags come raw. A tag that targets a track's TrackUID sits in that track's metadata.
  • Attachments expose name, media type, UID, and the byte range of their data. Demuxer::read_attachment reads it.
  • A truncated file keeps every block before the cut. Demuxer::truncated reports the cut.
  • mkv::first_track_timing reads the CodecDelay and summed positive DiscardPadding of the first track of a kind, with no packet index.
  • Malformed input returns an error that names the element path, such as Segment/Cluster[3]/SimpleBlock[7].
MKV/WebM mux
  • Muxer::new, add_track, set_metadata, set_chapters, attach, write_packet, finish. A target with no seek works through NoSeek with MkvOptions::seekable set to false.

  • TrackSpec::copy_from and StreamCopy::plan copy a Matroska track with every MatroskaTrack fact, or map an MP4 avc1, avc3, hvc1, hev1, av01, vp09, mp4a, Opus or tx3g track.

  • Colour tags survive every copy:

    CopyResult
    MP4 to MKVA colr nclx or nclc box maps to Colour
    MKV to MP4Colour maps to an nclx colr box. An unset tag writes 2 (unspecified). An unset range writes limited
    Hand-built specSets MatroskaVideo::colour
    Raw Colour element that matches colourThe muxer writes it unchanged
    Raw Colour element that differscolour replaces the four tags. Other children, such as MasteringMetadata, stay
  • Languages:

    OutputFormExample
    MatroskaBibliographic, because version 2 readers expect itger
    MP4Terminology, as mdhd defines itdeu

    An MP4 track's language maps through Language::iso639_mapped. A code with no table entry writes und.

  • An edit list maps to timing: an empty edit and a video media_time shift pts, an audio media_time becomes CodecDelay, and an audio end trim becomes DiscardPadding.

  • The default refuses. StreamCopy::plan and Muxer::write_packet take a value only when it is a whole number of nanoseconds and a whole multiple of TimestampScale. StreamCopy::timestamp_scale picks a scale that holds a set of tracks.

  • A repeated TrackUID, attachment UID or non-zero ChapterUID is refused.

Limits: what TimestampScale holds

Matroska stores integer ticks of TimestampScale. It cannot hold 1024/48000 s or 1001/30000 s exactly, at any scale. So StreamCopy::plan refuses AAC at 44.1 or 48 kHz and 29.97 fps video in a 30000 timescale. The error names MP4, which holds them, and StreamCopy::plan_rounded.

StreamCopy::plan_rounded(track, packets, scale) is the opt-in for MP4 tracks. scale is the TimestampScale in nanoseconds:

  • Each timestamp rounds on its own, from its exact source value, to the nearest tick, ties to even, in integer arithmetic. Its error stays at most half a tick, and no error accumulates across packets.
  • A block's duration is the distance to the next packet's rounded timestamp, in presentation order. The last packet in that order rounds from its exact end. So durations sum to the track's rounded end.
  • CodecDelay, DiscardPadding and DefaultDuration count in nanoseconds, so each rounds to the nearest nanosecond, ties to even. A block's duration can differ from DefaultDuration by a tick, and then the block states its own.
  • Decode order and sync flags stay as the source has them.
  • Two packets of one track on one tick are refused, and the error names a finer scale.
  • StreamCopy::rounding reports the scale and the largest absolute error, in nanoseconds, rounded up.
  • StreamCopy::timestamp_scale gives the largest value that divides every plan's scale, so rounded and exact plans share one output.

A chapter time stays exact or refused. plan_rounded does not round chapters.

Minimum Rust version

1.95.

Contributing

Contributions are welcome. See CONTRIBUTING.md for conventions and checks.

Questions and design discussion happen on Discord.

License

Licensed under Apache-2.0. See LICENSE.

Runs on
GPU support unknown
What shows it
  • no dependency or feature matches a backend row
Native code
None found
  • No native code found
Python bindings
No Python binding crate found
License
Apache-2.0

Compare with similar projects

Order: strongest tier first, then most downloads, then name, with unmeasured values last. A library counts downloads over 30 days. An app counts binary downloads per release. An app with none counts its crate downloads over 30 days.

containr beside the projects that share a topic with it
ProjectDownloadsDependentsStarsLatest releaseRuns on (GPU)Native codeLicense
containr This page New

Media containers for vidr and voxr: reads and writes MP4, MOV, Matroska and WebM, with integer timestamps and raw edit lists.

206 downloads over 30 days 2 0 0.1.1 GPU support unknown No native code found Apache-2.0
rav1r New

AV1 encoder derived from rav1e, used by vidr. Its output stays bit-exact with upstream.

56 downloads over 30 days 1 0 0.1.1 GPU support unknown Binds native code: aom-sys (feature decode_test), libdav1d-sys (feature decode_test_dav1d). Build uses a native toolchain: cc (feature asm) BSD-2-Clause
vidr App New

GPU video library on numr and boostr: NVDEC, NVENC and Vulkan Video for H.264, HEVC, AV1 and VP9, CPU decoders, MP4 and MKV through containr, frames as GPU tensors, and vidr-remaster for restoration and upscaling, run on real clips.

30 downloads over 30 days Not applicable 0 0.1.1 CUDA, WebGPU, Vulkan Binds native code: dav1d-sys (feature dav1d) Apache-2.0
Why NewIts first release on 5 Nov 2026 is under 180 days old.

The check tests the Featured gates once the first release passes this age limit.

What else the check found

  • The last activity is on 6 Nov 2026, 3 days ago. The rules expect an update within 365 days. Dormancy rule

Check notes

  • the latest release was packaged from a dirty working tree

Exact rules: New. The check on applied thresholds version 9.

Every rule outcome, as the check records it
  • expected-interval: version 0.1.1 base 300, capped at 2 releases = 100, raised to the 365-day floor: E = 365 days
  • dormancy: last activity 2026-11-06, 3 days ago, E = 365: active
  • new: new: first release 2026-11-05 is under 180 days old

Measurements and sources

Each signal row shows the raw value, its source and the fetch date. The Sources list gathers every distinct source on this page. Movement needs more history.

Sources6 sources
Adoption0 active dependents, 0 new dependents in 365 days1 not measured
Adoption measurements
SignalValueSourceFetchedCounts
Active dependents0crates.io database dumpYes
New dependents (365 days)0crates.io database dumpYes
Peak active dependents0crates.io database dumpYes
Active dependents as a share of the peak1crates.io database dumpYes
Binary downloads per releaseNot measuredYes
MaintenanceLatest human commit: 3 days ago3 not measured
Maintenance measurements
SignalValueSourceFetchedCounts
Latest human commit2026-11-06GitHub repositoryYes
Latest release2026-11-06crates.io database dumpYes
First release2026-11-05crates.io database dumpYes
Latest version0.1.1crates.io database dumpYes
Days the oldest outside issue waits for a reply0GitHub repositoryYes
Maintainer replies over 365 days0GitHub repositoryYes
Repository archivedfalseGitHub repositoryYes
Repository gonefalseGitHub repositoryYes
Release commit or manifest in the repotruecrates.io packageYes
Published docstruecrates.io database dumpYes
Latest release fails cargo checkNot measuredYes
Build check dateNot measuredYes
Build check toolchainNot measuredYes
SecurityUnpatched vulnerabilities: none
Security measurements
SignalValueSourceFetchedCounts
Unpatched vulnerabilities past the grace windownoneRustSec advisory databaseYes
RustSec unmaintained advisorynoneRustSec advisory databaseYes
Deprecation noticenonecrates.io database dumpYes
Deprecated requirementnonecrates.io database dumpYes
Distribution2 releases, 206 downloads over 30 days5 not measured
Distribution measurements
SignalValueSourceFetchedCounts
Releases2crates.io database dumpYes
Downloads over 30 days206crates.io database dumpYes
Binary downloads over the last 3 releasesNot measuredYes
Homebrew installs on request over 365 daysNot measuredYes
Repology repository familiesNot measuredYes
Docker pull growth over 90 daysNot measuredYes
Docker pulls at the growth baselineNot measuredYes
Every version a prereleasefalsecrates.io database dumpYes
Every version 0.0.xfalsecrates.io database dumpYes
Every version yankedfalsecrates.io database dumpYes
Crate deleted from crates.iofalsecrates.io database dumpYes
Repository0 stars, 0 forks, 1 contributors, 18 commits. Stars alone never qualify a project.
Repository measurements
SignalValueSourceFetchedCounts
GitHub stars0GitHub repositoryYes — one part of the repository route
GitHub forks0GitHub repositoryYes — one part of the repository route
Contributors with a GitHub account1GitHub contributorsYes — one part of the repository route
Commits on the default branch18GitHub repositoryNo
Repository's main crate left out of the entrynonecrates.io database dumpYes
Facts that never count
Facts that never count measurements
SignalValueSourceFetchedCounts
LicenseApache-2.0crates.io database dumpNo
Runs on, from listed crates and their direct dependenciesGPU support unknowncrates.io database dumpNo
What shows each backendno dependency or feature matches a backend rowcrates.io database dumpNo
Native code, from listed crates and their dependenciesNo native code foundcrates.io database dumpNo
Python bindings, from direct dependencies of listed cratesNo Python binding crate foundcrates.io database dumpNo
Crates that depend on a listed crate directly2crates.io database dumpNo

Corrections and review history

Report a correction

No correction applies to this entry.

Judgments

A judgment enters only through an approved issue and a merged pull request.

No judgment applies to this entry.