containr
Library New
Media containers for vidr and voxr: reads and writes MP4, MOV, Matroska and WebM, with integer timestamps and raw edit lists.
- Downloads
- 206
- Dependents
- 2
- Stars
- 0
- Forks
- 0
- Contributors
- 1
- Commits
- 18
- Latest release
- 0.1.1 ·
About
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.
| Format | Demux | Mux | Feature |
|---|---|---|---|
| MP4, QuickTime MOV | Yes. Fragmented MP4 (moof) is planned, and refused today | Yes | mp4 |
| Matroska, WebM | Yes | Yes. WebM refuses codecs it cannot hold | mkv |
Read the full README
Not supported yet
- Fragmented MP4 (
moof) input. Demux refuses it withError::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::planrefuses them, andStreamCopy::plan_roundedis the opt-in.
Stream copy
| Source track | Into MP4/MOV | Into MKV/WebM |
|---|---|---|
| From MP4/MOV | Any track, by raw sample entry | avc1, avc3, hvc1, hev1, av01, vp09, mp4a, Opus, tx3g |
| From MKV/WebM | H.264, HEVC, AV1, AAC, Opus, S_TEXT/UTF8 | Any 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.
| Feature | What it adds |
|---|---|
mp4 | containr::mp4::Demuxer and Muxer for MP4 and QuickTime MOV |
mkv | containr::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
moovcan sit before or aftermdat.- 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,esdsordOps. - Video tracks carry width, height, the
paspratio, and the rawtkhdmatrix. Audio tracks carry channels and sample rate. VideoInfo::colourholds the H.273 tags of acolrbox.Track::colourgives the value for either container.nclx: tags and the full-range bit.nclc(QuickTime): tags with no range.prof,rICC: no H.273 values.colourstaysNoneunless anothercolrbox givesnclx.- A code point above 255 is an error.
- Packets carry
dts,pts(signedcttsincluded), 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:
udtachildren andmeta/ilstitems, raw. Chapters come fromchpland from QuickTime chapter tracks. - Fragmented MP4 is refused with
Error::Fragmented. mp4::first_track_timingreads themdhdtimescale and raw edit list of the first track of a kind, frommoovalone, 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 implementsWriteandSeek, andReadforfinish, since faststart readsmdatback.TrackSpeccarries the sample entry as raw stream-copy bytes, or builds it fromavcC,hvcC,av1C,esds, ordOps.TrackSpecalso carries timescale, language, matrix,pasp,colour, and an edit list.colourwrites annclxcolrbox into a built visual entry. A raw entry keeps its owncolrbox.TrackSpec::copy_frombuilds a spec from an MP4 track.TrackSpec::copy_fromalso takes a Matroska H.264, HEVC, AV1, AAC, Opus orS_TEXT/UTF8track.StreamCopy::planadds packet timing and, for Matroska, the decode times and edit list MP4 needs.Matroska timing into MP4:
Matroska fact MP4 result Negative timestamps The edit list drops them Padding of the last block The edit list drops it AAC CodecDelayBecomes the edit's media_time. The segment lasts to the end of the last block less that delayA delay the Matroska tick cannot hold The track timescale rises to a multiple that holds it, so no timestamp rounds A delay no tick count matches within 1 ns Refused. The error names Matroska Opus CodecDelayAdds no edit. dOpspre_skipalready carries it, and a second skip drops it twiceLayout:
ftyp,mdat,moov.MuxOptions::faststartmovesmoovto 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_msand interleave across tracks by decode time.MuxOptions::max_bufferbounds the bytes held back.Sample tables:
stts,ctts,stsc,stsz,stss, andstco.co64replacesstcopast 4 GiB. Version 1cttswithcslgholds 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)
dtsout of order- a composition offset
cttscannot 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
cttsoffsets as signed.Known limits:
- Stream copy drops MP4
sgpdandsbgpsample-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.
- Stream copy drops MP4
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
TrackNumberas id, kind,TimestampScaletimebase, and language.Track::matroskaholdsTrackUID,CodecID, rawCodecPrivate,LanguageandLanguageBCP47,DefaultDuration,CodecDelay,SeekPreRoll, flags, andContentEncodings. - Video tracks carry pixel and display size,
DisplayUnit, crop, andProjectionwithProjectionPoseRoll. Audio tracks carry sampling frequency, channels, and bit depth. MatroskaVideo::colourholdsMatrixCoefficients,TransferCharacteristics,Primaries, andRange. The rawColourelement also stays inMatroskaVideo::extra.Range1 gives limited, 2 gives full, and 0 or 3 giveNone.- A code point above 255 is an error that names the element path.
- Packets carry
pts(ClusterTimestampplus the block timestamp), duration, keyframe and discardable flags,DiscardPadding, byte offset, and size.dtsisNone: 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
TimestampScalenanoseconds, as integers.CodecDelayandDiscardPaddingcome raw. The caller applies them. - A QuickTime Macintosh language code stays
Language::Other.Language::iso639givesNonefor it.Language::iso639_mappedmaps 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,SeekHeadentries, chapters from every edition, andTagscome raw. A tag that targets a track'sTrackUIDsits in that track's metadata.- Attachments expose name, media type, UID, and the byte range of their data.
Demuxer::read_attachmentreads it. - A truncated file keeps every block before the cut.
Demuxer::truncatedreports the cut. mkv::first_track_timingreads theCodecDelayand summed positiveDiscardPaddingof 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 throughNoSeekwithMkvOptions::seekableset to false.TrackSpec::copy_fromandStreamCopy::plancopy a Matroska track with everyMatroskaTrackfact, or map an MP4avc1,avc3,hvc1,hev1,av01,vp09,mp4a,Opusortx3gtrack.Colour tags survive every copy:
Copy Result MP4 to MKV A colrnclxornclcbox maps toColourMKV to MP4 Colourmaps to annclxcolrbox. An unset tag writes 2 (unspecified). An unset range writes limitedHand-built spec Sets MatroskaVideo::colourRaw Colourelement that matchescolourThe muxer writes it unchanged Raw Colourelement that differscolourreplaces the four tags. Other children, such asMasteringMetadata, stayLanguages:
Output Form Example Matroska Bibliographic, because version 2 readers expect it gerMP4 Terminology, as mdhddefines itdeuAn MP4 track's language maps through
Language::iso639_mapped. A code with no table entry writesund.An edit list maps to timing: an empty edit and a video
media_timeshiftpts, an audiomedia_timebecomesCodecDelay, and an audio end trim becomesDiscardPadding.The default refuses.
StreamCopy::planandMuxer::write_packettake a value only when it is a whole number of nanoseconds and a whole multiple ofTimestampScale.StreamCopy::timestamp_scalepicks a scale that holds a set of tracks.A repeated
TrackUID, attachment UID or non-zeroChapterUIDis 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,DiscardPaddingandDefaultDurationcount in nanoseconds, so each rounds to the nearest nanosecond, ties to even. A block's duration can differ fromDefaultDurationby a tick, and then the block states its own.- Decode order and
syncflags stay as the source has them. - Two packets of one track on one tick are refused, and the error names a finer scale.
StreamCopy::roundingreports the scale and the largest absolute error, in nanoseconds, rounded up.StreamCopy::timestamp_scalegives 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.
Compatibility
- 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.
| Project | Downloads | Dependents | Stars | Latest release | Runs on (GPU) | Native code | License |
|---|---|---|---|---|---|---|---|
|
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 |
|
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 daysdormancy: last activity 2026-11-06, 3 days ago, E = 365: activenew: 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
- Description: written for the catalogue
- crates.io database dump, fetched
- RustSec advisory database, fetched
- GitHub repository, fetched
- crates.io package, fetched
- GitHub contributors, fetched
Adoption0 active dependents, 0 new dependents in 365 days1 not measured
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| Active dependents | 0 | crates.io database dump | Yes | |
| New dependents (365 days) | 0 | crates.io database dump | Yes | |
| Peak active dependents | 0 | crates.io database dump | Yes | |
| Active dependents as a share of the peak | 1 | crates.io database dump | Yes | |
| Binary downloads per release | Not measured | Yes |
MaintenanceLatest human commit: 3 days ago3 not measured
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| Latest human commit | 2026-11-06 | GitHub repository | Yes | |
| Latest release | 2026-11-06 | crates.io database dump | Yes | |
| First release | 2026-11-05 | crates.io database dump | Yes | |
| Latest version | 0.1.1 | crates.io database dump | Yes | |
| Days the oldest outside issue waits for a reply | 0 | GitHub repository | Yes | |
| Maintainer replies over 365 days | 0 | GitHub repository | Yes | |
| Repository archived | false | GitHub repository | Yes | |
| Repository gone | false | GitHub repository | Yes | |
| Release commit or manifest in the repo | true | crates.io package | Yes | |
| Published docs | true | crates.io database dump | Yes | |
| Latest release fails cargo check | Not measured | Yes | ||
| Build check date | Not measured | Yes | ||
| Build check toolchain | Not measured | Yes |
SecurityUnpatched vulnerabilities: none
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| Unpatched vulnerabilities past the grace window | none | RustSec advisory database | Yes | |
| RustSec unmaintained advisory | none | RustSec advisory database | Yes | |
| Deprecation notice | none | crates.io database dump | Yes | |
| Deprecated requirement | none | crates.io database dump | Yes |
Distribution2 releases, 206 downloads over 30 days5 not measured
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| Releases | 2 | crates.io database dump | Yes | |
| Downloads over 30 days | 206 | crates.io database dump | Yes | |
| Binary downloads over the last 3 releases | Not measured | Yes | ||
| Homebrew installs on request over 365 days | Not measured | Yes | ||
| Repology repository families | Not measured | Yes | ||
| Docker pull growth over 90 days | Not measured | Yes | ||
| Docker pulls at the growth baseline | Not measured | Yes | ||
| Every version a prerelease | false | crates.io database dump | Yes | |
| Every version 0.0.x | false | crates.io database dump | Yes | |
| Every version yanked | false | crates.io database dump | Yes | |
| Crate deleted from crates.io | false | crates.io database dump | Yes |
Repository0 stars, 0 forks, 1 contributors, 18 commits. Stars alone never qualify a project.
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| GitHub stars | 0 | GitHub repository | Yes — one part of the repository route | |
| GitHub forks | 0 | GitHub repository | Yes — one part of the repository route | |
| Contributors with a GitHub account | 1 | GitHub contributors | Yes — one part of the repository route | |
| Commits on the default branch | 18 | GitHub repository | No | |
| Repository's main crate left out of the entry | none | crates.io database dump | Yes |
Facts that never count
| Signal | Value | Source | Fetched | Counts |
|---|---|---|---|---|
| License | Apache-2.0 | crates.io database dump | No | |
| Runs on, from listed crates and their direct dependencies | GPU support unknown | crates.io database dump | No | |
| What shows each backend | no dependency or feature matches a backend row | crates.io database dump | No | |
| Native code, from listed crates and their dependencies | No native code found | crates.io database dump | No | |
| Python bindings, from direct dependencies of listed crates | No Python binding crate found | crates.io database dump | No | |
| Crates that depend on a listed crate directly | 2 | crates.io database dump | No |
Corrections and review history
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.