M1.2: filmcraft-isobmff MP4/MOV demux + mux
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
936b28e4c4
commit
527f2aa1e6
|
|
@ -74,6 +74,16 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.1.0"
|
||||
|
|
@ -361,6 +371,26 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ filmcraft-time = { path = "crates/time" }
|
|||
filmcraft-geom = { path = "crates/geom" }
|
||||
filmcraft-color = { path = "crates/color" }
|
||||
filmcraft-bitstream = { path = "crates/bitstream" }
|
||||
filmcraft-isobmff = { path = "crates/isobmff" }
|
||||
filmcraft-frame = { path = "crates/frame" }
|
||||
filmcraft-media = { path = "crates/media" }
|
||||
filmcraft-project = { path = "crates/project" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "filmcraft-isobmff"
|
||||
description = "Clean-room ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
filmcraft-bitstream = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
# filmcraft-isobmff
|
||||
|
||||
A clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) demuxer and muxer,
|
||||
written from the public specifications. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`,
|
||||
has no `unsafe`, and builds for `wasm32-unknown-unknown`.
|
||||
|
||||
## Demuxing
|
||||
|
||||
```rust
|
||||
use filmcraft_isobmff::{open, TrackKind};
|
||||
|
||||
let file = std::fs::File::open("clip.mov")?;
|
||||
let mp4 = open(&file)?; // any ByteSource: &[u8], Vec<u8>, Arc<[u8]>, File, ...
|
||||
let v = mp4.track_of_kind(TrackKind::Video).unwrap();
|
||||
let track = &mp4.tracks[v];
|
||||
let i = track.sample_at_presentation_time(1000).unwrap(); // track timescale, edit-adjusted
|
||||
let key = track.sync_sample_before(i); // decode from here
|
||||
let bytes = mp4.read_sample(&file, v, key)?; // raw sample (AVCC/HVCC length-prefixed NALs, ...)
|
||||
```
|
||||
|
||||
- `ByteSource` (`len`, `read_at(offset, buf)`) is the input abstraction. Implementations exist for `[u8]`,
|
||||
`Vec<u8>`, `&T`, `Arc<T>`, `Box<T>`, and `std::fs::File` on non-wasm targets.
|
||||
- `open` reads only top-level box headers, `ftyp`, `moov` and every `moof`. It never reads `mdat`.
|
||||
The one exception is the first sample of a `tmcd` track, which it reads (4 bytes) to get the timecode
|
||||
start frame.
|
||||
- `Mp4File`: brands, `is_quicktime`, movie `timescale`/`duration`, `tracks`, `metadata`, `fragmented`,
|
||||
`fragment_count`.
|
||||
- `Track`: `id`, `kind` (Video/Audio/Timecode/Subtitle/Other), `handler`, `timescale`, `duration`,
|
||||
`language`, `tkhd` fields (size, matrix, volume, enabled), `entries: Vec<SampleEntry>`, `samples`,
|
||||
`edits`, `edit_offset`, `references` (`tref`), `sdtp`, `sample_groups` (`sbgp`+`sgpd`, raw), `pcm_chunked`.
|
||||
Helpers: `codec()`, `video()`, `audio()`, `presentation_pts(i)`, `sample_at_pts(pts)`,
|
||||
`sample_at_presentation_time(t)`, `sync_sample_before(i)`, `sync_samples()`.
|
||||
- `Sample`: `offset`, `size`, `dts`, `pts` (dts + ctts), `duration`, `is_sync` (all samples are sync when
|
||||
`stss` is absent), `description_index`. All times are raw media time in the track timescale.
|
||||
- **Edit lists:** `edits` holds the `elst` entries unchanged. `edit_offset` is
|
||||
`leading empty edits (rescaled to media timescale) − first non-empty edit's media_time`, so
|
||||
`presentation time = pts + edit_offset`. This matches ffprobe's timestamps for the common cases
|
||||
(B-frame delay, AAC priming, empty-edit offsets). Multi-segment edits, dwells and rate changes are exposed
|
||||
raw for the caller to interpret.
|
||||
- **Uncompressed PCM** (`lpcm`, `sowt`, `twos`, `in24`, `in32`, `fl32`, `fl64`, `raw `, `ipcm`, `fpcm`) is
|
||||
merged to one `Sample` per chunk (`pcm_chunked = true`), with `duration` in frames. This avoids one
|
||||
table entry per audio frame.
|
||||
|
||||
### Parsed boxes
|
||||
`ftyp`, `moov/mvhd`, `trak/tkhd`, `edts/elst` (v0/v1), `tref`, `mdia/mdhd` (v0/v1, ISO and Mac language
|
||||
codes), `hdlr` (ISO and Pascal names), `minf/stbl`: `stsd`, `stts`, `ctts` (v0/v1, read as signed), `stss`,
|
||||
`stsc`, `stsz`/`stz2` (4/8/16-bit), `stco`/`co64`, `sdtp`, `sbgp`/`sgpd`. Also `mvex/trex`, `moof/traf/tfhd/tfdt/trun`
|
||||
(every `tfhd` base-offset mode and multiple `moof`s), and `udta` metadata (QuickTime `©xxx` atoms,
|
||||
`meta/ilst`, and `meta/keys` (mdta)). `uuid`, `free`, `skip`, `wide`, `sidx` and `mfra` are skipped.
|
||||
Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are supported.
|
||||
|
||||
### Sample entries → `CodecConfig`
|
||||
| Format | Config |
|
||||
|---|---|
|
||||
| `avc1`/`avc3` + `avcC` | `Avc(AvcConfig)`: profile, level, length size, SPS/PPS, extension bytes |
|
||||
| `hvc1`/`hev1` + `hvcC` | `Hevc(HevcConfig)`: all header fields plus NAL arrays (`vps()/sps()/pps()`) |
|
||||
| `av01` + `av1C` | `Av1(Av1Config)` |
|
||||
| `vp09` + `vpcC` | `Vp9(VpcConfig)` |
|
||||
| `apch apcn apcs apco ap4h ap4x` | `ProRes { fourcc }` |
|
||||
| `jpeg mjpa mjpb` | `Jpeg { fourcc }` |
|
||||
| `AVdn AVdh` | `Dnx { fourcc }` |
|
||||
| `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B |
|
||||
| PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate |
|
||||
| `alac` | `Alac { cookie }` |
|
||||
| `Opus` + `dOps` | `Opus(OpusConfig)` |
|
||||
| `fLaC` + `dfLa` | `Flac(FlacConfig)` (STREAMINFO decoded) |
|
||||
| `ac-3`/`ec-3` | `Ac3 { dac3 }` / `Eac3 { dec3 }` |
|
||||
| `tmcd` | `Timecode(TimecodeConfig)`: flags (drop frame), timescale, frame duration, fps, start frame, reel name; `format_frame()` |
|
||||
| anything else | `Unknown { fourcc, raw }` |
|
||||
|
||||
Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into
|
||||
`VideoParams`. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`.
|
||||
|
||||
### Robustness
|
||||
The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are
|
||||
checked against the bytes in their box before anything is allocated. A constant-size `stsz` count is
|
||||
clamped to what fits in the file. `trun` counts are checked, and each track is capped at 2²⁶ samples.
|
||||
`moov`/`moof` are capped at 1 GiB. Compressed movie headers (`cmov`) are rejected with
|
||||
`Error::Unsupported`.
|
||||
|
||||
## Muxing
|
||||
|
||||
```rust
|
||||
use filmcraft_isobmff::*;
|
||||
let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4))?;
|
||||
let v = w.add_track(TrackConfig::new(SampleEntry::avc(avcc, 1920, 1080), 90_000))?;
|
||||
let mut acfg = TrackConfig::new(SampleEntry::aac(asc, 2, 48_000), 48_000);
|
||||
acfg.media_start = Some(1024); // AAC encoder delay → edit list
|
||||
let a = w.add_track(acfg)?;
|
||||
w.write_sample(v, WriteSample { data: &au, duration: 3000, composition_offset: 6000, is_sync: true })?;
|
||||
// ... interleave samples in decode order ...
|
||||
let bytes = w.finish_faststart()?.into_inner(); // or finish() for moov-at-end
|
||||
```
|
||||
|
||||
- `Mp4Writer<W: Write + Seek>` streams samples into a single `mdat`. `finish()` appends `moov`.
|
||||
`finish_faststart()` (needs `W: Read + Write + Seek`) moves the media data forward in place and writes
|
||||
`moov` before it. If `mdat` goes past 4 GiB the header becomes a large-size header, and chunk offsets
|
||||
switch to `co64` when needed.
|
||||
- Brands: `Brand::Mp4` writes `isom` with `isom iso2 avc1 mp41 mp42`. `Brand::Mov` writes `qt `, uses
|
||||
QuickTime `hdlr`/Pascal names, and sets `wide` as the placeholder box.
|
||||
- Tables written: `stts`, `ctts` (v1 when an offset is negative), `stss` (omitted when every sample is
|
||||
sync), `stsc`, `stsz` (constant or table), `stco`/`co64`. Consecutive samples of one track share a chunk.
|
||||
- Sample entries come from `SampleEntry` (`avc`, `hevc`, `prores`, `jpeg`, `aac`, `pcm`, or any demuxed
|
||||
entry). Remuxing copies codec config, `colr`, `pasp`, `clap`, `fiel`, `gama` and `btrt`.
|
||||
PCM is written as `sowt`/`twos` (16-bit) or `raw ` (8-bit) or sound description v2 `lpcm` in MOV, and as
|
||||
`ipcm`/`fpcm` + `pcmC` in MP4. A PCM `write_sample` call can hold any whole number of frames, and the
|
||||
track timescale must equal the sample rate.
|
||||
- Edit lists: set `TrackConfig::edits` explicitly, or use `media_start` to get a single edit.
|
||||
- Timecode: `add_timecode_track(video, TimecodeConfig, start_frame)` adds a `tmcd` track (with `gmhd`)
|
||||
and a `tref/tmcd` link from the video track.
|
||||
- `WriterOptions::metadata` writes `©xxx` text atoms to `udta`.
|
||||
- `FragmentedWriter<W: Write>` writes fMP4. The init segment is `ftyp` + `moov` with `mvex/trex`. Each
|
||||
`flush_fragment()` then writes `moof` (`mfhd`, `traf` with default-base-is-moof `tfhd`, `tfdt` v1,
|
||||
`trun` with per-sample duration, size, flags and composition offset) followed by `mdat`.
|
||||
|
||||
## Tests
|
||||
- Unit tests cover the box reader and hand-built `stbl` edge cases (stz2, co64, ctts v1, empty edits,
|
||||
64-bit and size-0 boxes, uuid/wide/skip), codec config round-trips, and allocation guards.
|
||||
- Property tests (`tests/roundtrip.rs`) write random multi-track files, both progressive (MP4/MOV,
|
||||
faststart, PCM, edits) and fragmented, then read them back and compare sample tables and bytes.
|
||||
- ffprobe oracle tests (`tests/oracle_demux.rs`) run on ffmpeg-generated fixtures in
|
||||
`target/fixtures/isobmff/`: H.264 with B-frames (MP4 and MOV), HEVC, ProRes 422/4444, MJPEG, DNxHR, PCM
|
||||
s16le/s16be/s24le/f32le, AAC, ALAC, FLAC, Opus, AC-3, VP9, AV1, fragmented (with and without
|
||||
default-base-is-moof), faststart, an edit-list cut, `tmcd` at 29.97 DF and 23.976, and colr/pasp. They
|
||||
compare packet offsets, sizes, pts/dts, durations and key flags, plus stream parameters. Tests skip
|
||||
with a message when ffmpeg or an encoder is missing.
|
||||
- Muxer oracle tests (`tests/oracle_mux.rs`) remux fixtures with our writers. They check that
|
||||
`ffmpeg -v error -f null` reports nothing, that ffprobe packets and stream parameters match the source,
|
||||
and that timecode, the AAC priming edit, ipcm and fMP4 output are correct.
|
||||
- Mutation fuzzing (`tests/fuzz.rs`) uses a deterministic seed to mutate and truncate synthetic and
|
||||
ffmpeg files, and checks that nothing panics.
|
||||
|
||||
## Limitations / not yet supported
|
||||
- `presentation time = pts + edit_offset` only applies the leading empty edits and the first media
|
||||
edit. Multi-segment edit lists, dwell edits and non-unity rates are exposed raw. ffmpeg handles an edit
|
||||
that starts mid-frame differently: it snaps to the frame.
|
||||
- Other data references (external `alis`/`url ` files), encrypted entries (`encv`/`enca`, `sinf`,
|
||||
`senc`), `cmov`, and QuickTime reference movies are not supported.
|
||||
- `sidx`/`mfra` are ignored, because the demuxer scans every top-level box. `stsd` entries with a
|
||||
QuickTime colour table are not decoded.
|
||||
- Subtitle and text tracks (`tx3g`, `c608`, `wvtt`) and `mp4v` show up as `Unknown`.
|
||||
- Muxer: one `mdat`, no chunk-size or duration limits (the caller controls interleaving), no `sdtp`,
|
||||
`sgpd`/`sbgp` or `cslg` output, ISO-style `meta/ilst` metadata is not written, and the fragmented
|
||||
writer has no PCM, `sidx` or `mfra`.
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration
|
||||
//! over in-memory payloads, and a box builder for the muxers.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::fmt;
|
||||
|
||||
/// A four-character code (box type, sample entry format, brand, handler type…).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
||||
pub struct FourCc(pub [u8; 4]);
|
||||
|
||||
impl FourCc {
|
||||
pub const fn new(b: &[u8; 4]) -> Self {
|
||||
FourCc(*b)
|
||||
}
|
||||
pub fn as_u32(self) -> u32 {
|
||||
u32::from_be_bytes(self.0)
|
||||
}
|
||||
/// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`.
|
||||
pub fn to_string_lossy(self) -> String {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0xA9 => '©',
|
||||
0x20..=0x7E => b as char,
|
||||
_ => '?',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FourCc {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_string_lossy())
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for FourCc {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "FourCc({:?})", self.to_string_lossy())
|
||||
}
|
||||
}
|
||||
impl From<&[u8; 4]> for FourCc {
|
||||
fn from(b: &[u8; 4]) -> Self {
|
||||
FourCc(*b)
|
||||
}
|
||||
}
|
||||
impl PartialEq<[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &[u8; 4]) -> bool {
|
||||
&self.0 == other
|
||||
}
|
||||
}
|
||||
impl PartialEq<&[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &&[u8; 4]) -> bool {
|
||||
&self.0 == *other
|
||||
}
|
||||
}
|
||||
impl PartialEq<&&[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &&&[u8; 4]) -> bool {
|
||||
&self.0 == **other
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds-checked big-endian cursor over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Cur<'a> {
|
||||
pub data: &'a [u8],
|
||||
pub pos: usize,
|
||||
what: &'static str,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
pub fn new(data: &'a [u8], what: &'static str) -> Self {
|
||||
Cur { data, pos: 0, what }
|
||||
}
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.data.len().saturating_sub(self.pos)
|
||||
}
|
||||
pub fn rest(&self) -> &'a [u8] {
|
||||
self.data.get(self.pos..).unwrap_or(&[])
|
||||
}
|
||||
pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?;
|
||||
let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
self.bytes(n).map(|_| ())
|
||||
}
|
||||
pub fn array<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let s = self.bytes(N)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Ok(a)
|
||||
}
|
||||
pub fn u8(&mut self) -> Result<u8> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
pub fn u16(&mut self) -> Result<u16> {
|
||||
Ok(u16::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i16(&mut self) -> Result<i16> {
|
||||
Ok(i16::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn u24(&mut self) -> Result<u32> {
|
||||
let a = self.array::<3>()?;
|
||||
Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32)
|
||||
}
|
||||
pub fn u32(&mut self) -> Result<u32> {
|
||||
Ok(u32::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i32(&mut self) -> Result<i32> {
|
||||
Ok(i32::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn u64(&mut self) -> Result<u64> {
|
||||
Ok(u64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i64(&mut self) -> Result<i64> {
|
||||
Ok(i64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn f64(&mut self) -> Result<f64> {
|
||||
Ok(f64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn fourcc(&mut self) -> Result<FourCc> {
|
||||
Ok(FourCc(self.array()?))
|
||||
}
|
||||
/// Full-box header: (version, flags).
|
||||
pub fn full_header(&mut self) -> Result<(u8, u32)> {
|
||||
let v = self.u32()?;
|
||||
Ok(((v >> 24) as u8, v & 0x00FF_FFFF))
|
||||
}
|
||||
/// Read a count and verify that `count * entry_size` bytes remain (allocation guard).
|
||||
pub fn count(&mut self, entry_size: usize) -> Result<usize> {
|
||||
let n = self.u32()? as usize;
|
||||
self.check_count(n, entry_size)?;
|
||||
Ok(n)
|
||||
}
|
||||
pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> {
|
||||
match n.checked_mul(entry_size) {
|
||||
Some(b) if b <= self.remaining() => Ok(()),
|
||||
_ => Err(Error::Truncated(self.what)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header of one box as found in a byte stream.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct BoxHeader {
|
||||
pub kind: FourCc,
|
||||
/// Header length including largesize and uuid extended type.
|
||||
pub header_len: u64,
|
||||
/// Total box size including header; `None` means "extends to end of the enclosing container".
|
||||
pub size: Option<u64>,
|
||||
pub uuid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available).
|
||||
pub(crate) fn parse_box_header(buf: &[u8]) -> Result<BoxHeader> {
|
||||
let mut c = Cur::new(buf, "box header");
|
||||
let size32 = c.u32()?;
|
||||
let kind = c.fourcc()?;
|
||||
let mut header_len = 8u64;
|
||||
let size = match size32 {
|
||||
0 => None,
|
||||
1 => {
|
||||
header_len += 8;
|
||||
let s = c.u64()?;
|
||||
Some(s)
|
||||
}
|
||||
s => Some(s as u64),
|
||||
};
|
||||
let uuid = if kind == b"uuid" {
|
||||
header_len += 16;
|
||||
Some(c.array::<16>()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = size
|
||||
&& s < header_len
|
||||
{
|
||||
return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header")));
|
||||
}
|
||||
Ok(BoxHeader { kind, header_len, size, uuid })
|
||||
}
|
||||
|
||||
/// One child box inside an in-memory payload.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct RawBox<'a> {
|
||||
pub kind: FourCc,
|
||||
pub payload: &'a [u8],
|
||||
#[allow(dead_code)]
|
||||
pub uuid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops.
|
||||
pub(crate) struct Boxes<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> {
|
||||
Boxes { data, pos: 0, done: false }
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Boxes<'a> {
|
||||
type Item = Result<RawBox<'a>>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.done {
|
||||
return None;
|
||||
}
|
||||
let rest = &self.data[self.pos..];
|
||||
if rest.is_empty() {
|
||||
self.done = true;
|
||||
return None;
|
||||
}
|
||||
// QuickTime allows a 32-bit zero terminator at the end of some containers.
|
||||
if rest.len() < 8 {
|
||||
self.done = true;
|
||||
if rest.iter().all(|&b| b == 0) {
|
||||
return None;
|
||||
}
|
||||
return Some(Err(Error::Truncated("box header")));
|
||||
}
|
||||
let h = match parse_box_header(rest) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
self.done = true;
|
||||
return Some(Err(e));
|
||||
}
|
||||
};
|
||||
let size = h.size.unwrap_or(rest.len() as u64);
|
||||
if size > rest.len() as u64 {
|
||||
self.done = true;
|
||||
return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len()))));
|
||||
}
|
||||
let size = size as usize;
|
||||
let payload = &rest[h.header_len as usize..size];
|
||||
self.pos += size;
|
||||
Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first child of the given type (malformed trailing children are ignored).
|
||||
pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload)
|
||||
}
|
||||
|
||||
/// Growable buffer for building boxes.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub(crate) struct BoxBuf {
|
||||
pub buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BoxBuf {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// Begin a box; returns a marker to pass to [`end`](Self::end).
|
||||
pub fn start(&mut self, kind: &[u8; 4]) -> usize {
|
||||
let at = self.buf.len();
|
||||
self.buf.extend_from_slice(&[0, 0, 0, 0]);
|
||||
self.buf.extend_from_slice(kind);
|
||||
at
|
||||
}
|
||||
pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize {
|
||||
let at = self.start(kind);
|
||||
self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF));
|
||||
at
|
||||
}
|
||||
pub fn end(&mut self, at: usize) {
|
||||
let size = (self.buf.len() - at) as u32;
|
||||
self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes());
|
||||
}
|
||||
/// Append a complete box with the given payload.
|
||||
pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) {
|
||||
let at = self.start(kind);
|
||||
self.bytes(payload);
|
||||
self.end(at);
|
||||
}
|
||||
pub fn u8(&mut self, v: u8) {
|
||||
self.buf.push(v);
|
||||
}
|
||||
pub fn u16(&mut self, v: u16) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i16(&mut self, v: i16) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn u32(&mut self, v: u32) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i32(&mut self, v: i32) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn u64(&mut self, v: u64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i64(&mut self, v: i64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn f64(&mut self, v: f64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn bytes(&mut self, b: &[u8]) {
|
||||
self.buf.extend_from_slice(b);
|
||||
}
|
||||
pub fn zeros(&mut self, n: usize) {
|
||||
self.buf.resize(self.buf.len() + n, 0);
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.buf.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_forms() {
|
||||
let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap();
|
||||
assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16)));
|
||||
let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap();
|
||||
assert_eq!(h.size, None);
|
||||
let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't'];
|
||||
v.extend_from_slice(&100u64.to_be_bytes());
|
||||
let h = parse_box_header(&v).unwrap();
|
||||
assert_eq!((h.header_len, h.size), (16, Some(100)));
|
||||
let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd'];
|
||||
v.extend_from_slice(&[7u8; 16]);
|
||||
let h = parse_box_header(&v).unwrap();
|
||||
assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16])));
|
||||
assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err());
|
||||
assert!(parse_box_header(&[0, 0, 0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterate_children() {
|
||||
let mut b = BoxBuf::new();
|
||||
b.leaf(b"aaaa", &[1, 2]);
|
||||
let m = b.start(b"bbbb");
|
||||
b.leaf(b"cccc", &[]);
|
||||
b.end(m);
|
||||
b.u32(0); // QT terminator
|
||||
let kids: Vec<_> = boxes(&b.buf).collect::<Result<Vec<_>>>().unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].payload, &[1, 2]);
|
||||
assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8));
|
||||
// Oversized child → error, no panic.
|
||||
let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0];
|
||||
assert!(boxes(&bad).next().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_bounds() {
|
||||
let mut c = Cur::new(&[0, 0, 0, 5, 1], "t");
|
||||
assert!(c.count(1).is_err());
|
||||
let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t");
|
||||
assert!(c.count(usize::MAX).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
|||
use std::io;
|
||||
|
||||
/// Errors produced by the demuxer and muxer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
/// A box or structure ended before all required fields were read.
|
||||
#[error("truncated data while reading {0}")]
|
||||
Truncated(&'static str),
|
||||
/// Structurally invalid data.
|
||||
#[error("invalid data: {0}")]
|
||||
Invalid(String),
|
||||
/// Valid but unsupported feature (e.g. compressed `cmov` movie header).
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
/// A count or size exceeds sanity limits relative to the file size.
|
||||
#[error("size limit exceeded: {0}")]
|
||||
TooLarge(&'static str),
|
||||
/// No `moov` box was found.
|
||||
#[error("no moov box found")]
|
||||
NoMoov,
|
||||
/// A track index was out of range.
|
||||
#[error("track index {0} out of range")]
|
||||
NoSuchTrack(usize),
|
||||
/// A sample index was out of range.
|
||||
#[error("sample index {0} out of range")]
|
||||
NoSuchSample(usize),
|
||||
/// Muxer usage error (e.g. invalid track config, bad sample).
|
||||
#[error("muxer: {0}")]
|
||||
Mux(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(Error::Invalid(msg.into()))
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support.
|
||||
//!
|
||||
//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a
|
||||
//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with
|
||||
//! [`Mp4File::read_sample`].
|
||||
//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart);
|
||||
//! [`FragmentedWriter`] writes fragmented MP4.
|
||||
//!
|
||||
//! See the crate README for supported boxes and limitations.
|
||||
|
||||
mod bytes;
|
||||
mod codec;
|
||||
mod demux;
|
||||
mod error;
|
||||
mod mux;
|
||||
mod source;
|
||||
|
||||
pub use bytes::FourCc;
|
||||
pub use codec::{
|
||||
AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, OpusConfig,
|
||||
PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
|
||||
};
|
||||
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};
|
||||
pub use error::{Error, Result};
|
||||
pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions};
|
||||
pub use source::ByteSource;
|
||||
|
|
@ -0,0 +1,946 @@
|
|||
//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]).
|
||||
|
||||
use crate::bytes::BoxBuf;
|
||||
use crate::codec::{CodecConfig, SampleEntry, TimecodeConfig};
|
||||
use crate::demux::Edit;
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
/// Output flavour: determines brands and some sample-entry encodings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Brand {
|
||||
/// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`.
|
||||
Mp4,
|
||||
/// QuickTime MOV: `qt ` major brand.
|
||||
Mov,
|
||||
}
|
||||
|
||||
/// Movie-level writer options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WriterOptions {
|
||||
pub brand: Brand,
|
||||
/// Movie timescale (`mvhd`); default 1000.
|
||||
pub movie_timescale: u32,
|
||||
/// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`).
|
||||
pub metadata: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl WriterOptions {
|
||||
pub fn new(brand: Brand) -> Self {
|
||||
WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-track configuration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackConfig {
|
||||
pub entry: SampleEntry,
|
||||
/// Media timescale. For PCM it must equal the sample rate (one tick per audio frame).
|
||||
pub timescale: u32,
|
||||
/// ISO 639-2/T language (`und` by default).
|
||||
pub language: String,
|
||||
/// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`.
|
||||
pub edits: Vec<Edit>,
|
||||
/// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming,
|
||||
/// or the first pts of a B-frame stream) spanning the rest of the media.
|
||||
pub media_start: Option<i64>,
|
||||
/// `hdlr` name; a sensible default is used when `None`.
|
||||
pub handler_name: Option<String>,
|
||||
}
|
||||
|
||||
impl TrackConfig {
|
||||
pub fn new(entry: SampleEntry, timescale: u32) -> Self {
|
||||
TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample handed to the muxers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WriteSample<'a> {
|
||||
pub data: &'a [u8],
|
||||
/// Duration in the media timescale. Ignored for PCM (each frame lasts one tick).
|
||||
pub duration: u32,
|
||||
/// pts − dts in the media timescale.
|
||||
pub composition_offset: i32,
|
||||
pub is_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Handler {
|
||||
Video,
|
||||
Audio,
|
||||
Timecode,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn handler_of(e: &SampleEntry) -> Handler {
|
||||
if matches!(e.codec, CodecConfig::Timecode(_)) {
|
||||
Handler::Timecode
|
||||
} else if e.video.is_some() {
|
||||
Handler::Video
|
||||
} else if e.audio.is_some() {
|
||||
Handler::Audio
|
||||
} else {
|
||||
Handler::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulated sample tables for one track.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Tables {
|
||||
stts: Vec<(u32, u32)>,
|
||||
ctts: Vec<(u32, i32)>,
|
||||
sizes: Vec<u32>,
|
||||
/// Constant sample size (PCM) — then `sizes` is unused.
|
||||
const_size: Option<u32>,
|
||||
sample_count: u64,
|
||||
/// 1-based sync sample numbers.
|
||||
sync: Vec<u32>,
|
||||
all_sync: bool,
|
||||
/// (offset relative to file start before shifting, sample count)
|
||||
chunks: Vec<(u64, u32)>,
|
||||
media_duration: u64,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn new() -> Self {
|
||||
Tables { all_sync: true, ..Default::default() }
|
||||
}
|
||||
fn push_run<T: PartialEq + Copy>(runs: &mut Vec<(u32, T)>, count: u32, v: T) {
|
||||
if let Some(last) = runs.last_mut()
|
||||
&& last.1 == v
|
||||
&& last.0.checked_add(count).is_some()
|
||||
{
|
||||
last.0 += count;
|
||||
return;
|
||||
}
|
||||
runs.push((count, v));
|
||||
}
|
||||
}
|
||||
|
||||
struct WTrack {
|
||||
cfg: TrackConfig,
|
||||
id: u32,
|
||||
handler: Handler,
|
||||
pcm_frame_bytes: Option<u32>,
|
||||
t: Tables,
|
||||
/// Track id of a linked timecode track (`tref/tmcd`).
|
||||
tmcd_ref: Option<u32>,
|
||||
/// For timecode tracks: (video track index, start frame).
|
||||
tmcd_source: Option<(usize, u32)>,
|
||||
}
|
||||
|
||||
fn pack_language(lang: &str) -> u16 {
|
||||
let b = lang.as_bytes();
|
||||
if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) {
|
||||
(((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16)
|
||||
} else {
|
||||
0x55C4 // "und"
|
||||
}
|
||||
}
|
||||
|
||||
fn rescale(v: u64, from: u32, to: u32) -> u64 {
|
||||
if from == 0 {
|
||||
return 0;
|
||||
}
|
||||
(v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000];
|
||||
|
||||
fn write_ftyp(b: &mut BoxBuf, brand: Brand) {
|
||||
let m = b.start(b"ftyp");
|
||||
match brand {
|
||||
Brand::Mp4 => {
|
||||
b.bytes(b"isom");
|
||||
b.u32(0x200);
|
||||
for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] {
|
||||
b.bytes(c);
|
||||
}
|
||||
}
|
||||
Brand::Mov => {
|
||||
b.bytes(b"qt ");
|
||||
b.u32(0x200);
|
||||
b.bytes(b"qt ");
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
|
||||
fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 {
|
||||
tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec<Edit> {
|
||||
if !t.cfg.edits.is_empty() {
|
||||
return t.cfg.edits.clone();
|
||||
}
|
||||
if let Some(start) = t.cfg.media_start {
|
||||
let rest = t.t.media_duration.saturating_sub(start.max(0) as u64);
|
||||
return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 {
|
||||
let e = effective_edits(t, movie_ts);
|
||||
if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() }
|
||||
}
|
||||
|
||||
fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) {
|
||||
let v1 = duration > u32::MAX as u64;
|
||||
let m = b.start_full(b"mvhd", v1 as u8, 0);
|
||||
if v1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(timescale);
|
||||
b.u64(duration);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(timescale);
|
||||
b.u32(duration as u32);
|
||||
}
|
||||
b.u32(0x10000);
|
||||
b.u16(0x100);
|
||||
b.zeros(10);
|
||||
for v in MATRIX {
|
||||
b.i32(v);
|
||||
}
|
||||
b.zeros(24);
|
||||
b.u32(next_id);
|
||||
b.end(m);
|
||||
}
|
||||
|
||||
fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> {
|
||||
let trak = b.start(b"trak");
|
||||
let dur = presentation_duration(t, movie_ts);
|
||||
let v1 = dur > u32::MAX as u64;
|
||||
let m = b.start_full(b"tkhd", v1 as u8, 3);
|
||||
if v1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(t.id);
|
||||
b.u32(0);
|
||||
b.u64(dur);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(t.id);
|
||||
b.u32(0);
|
||||
b.u32(dur as u32);
|
||||
}
|
||||
b.zeros(8);
|
||||
b.i16(0);
|
||||
b.u16(0);
|
||||
b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 });
|
||||
b.u16(0);
|
||||
for v in MATRIX {
|
||||
b.i32(v);
|
||||
}
|
||||
let (w, h) = match &t.cfg.entry.video {
|
||||
Some(v) => {
|
||||
let w = match v.pixel_aspect {
|
||||
Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32,
|
||||
_ => v.width as u32,
|
||||
};
|
||||
(w, v.height as u32)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
b.u32(w << 16);
|
||||
b.u32(h << 16);
|
||||
b.end(m);
|
||||
let edits = effective_edits(t, movie_ts);
|
||||
if !edits.is_empty() {
|
||||
let e = b.start(b"edts");
|
||||
let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64);
|
||||
let l = b.start_full(b"elst", v1 as u8, 0);
|
||||
b.u32(edits.len() as u32);
|
||||
for ed in &edits {
|
||||
if v1 {
|
||||
b.u64(ed.segment_duration);
|
||||
b.i64(ed.media_time);
|
||||
} else {
|
||||
b.u32(ed.segment_duration as u32);
|
||||
b.i32(ed.media_time as i32);
|
||||
}
|
||||
b.i32(ed.media_rate);
|
||||
}
|
||||
b.end(l);
|
||||
b.end(e);
|
||||
}
|
||||
if let Some(id) = t.tmcd_ref {
|
||||
let r = b.start(b"tref");
|
||||
let m = b.start(b"tmcd");
|
||||
b.u32(id);
|
||||
b.end(m);
|
||||
b.end(r);
|
||||
}
|
||||
let mdia = b.start(b"mdia");
|
||||
let mv1 = t.t.media_duration > u32::MAX as u64;
|
||||
let m = b.start_full(b"mdhd", mv1 as u8, 0);
|
||||
if mv1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(t.cfg.timescale);
|
||||
b.u64(t.t.media_duration);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(t.cfg.timescale);
|
||||
b.u32(t.t.media_duration as u32);
|
||||
}
|
||||
b.u16(pack_language(&t.cfg.language));
|
||||
b.u16(0);
|
||||
b.end(m);
|
||||
let (htype, default_name) = match t.handler {
|
||||
Handler::Video => (b"vide", "VideoHandler"),
|
||||
Handler::Audio => (b"soun", "SoundHandler"),
|
||||
Handler::Timecode => (b"tmcd", "TimeCodeHandler"),
|
||||
Handler::Other => (b"meta", "MetaHandler"),
|
||||
};
|
||||
let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into());
|
||||
let m = b.start_full(b"hdlr", 0, 0);
|
||||
b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] });
|
||||
b.bytes(htype);
|
||||
b.zeros(12);
|
||||
if qt {
|
||||
let n = name.len().min(255);
|
||||
b.u8(n as u8);
|
||||
b.bytes(&name.as_bytes()[..n]);
|
||||
} else {
|
||||
b.bytes(name.as_bytes());
|
||||
b.u8(0);
|
||||
}
|
||||
b.end(m);
|
||||
let minf = b.start(b"minf");
|
||||
match t.handler {
|
||||
Handler::Video => {
|
||||
let m = b.start_full(b"vmhd", 0, 1);
|
||||
b.zeros(8);
|
||||
b.end(m);
|
||||
}
|
||||
Handler::Audio => {
|
||||
let m = b.start_full(b"smhd", 0, 0);
|
||||
b.zeros(4);
|
||||
b.end(m);
|
||||
}
|
||||
Handler::Timecode => {
|
||||
let g = b.start(b"gmhd");
|
||||
let m = b.start_full(b"gmin", 0, 0);
|
||||
b.u16(0x40);
|
||||
b.u16(0x8000);
|
||||
b.u16(0x8000);
|
||||
b.u16(0x8000);
|
||||
b.i16(0);
|
||||
b.u16(0);
|
||||
b.end(m);
|
||||
let tm = b.start(b"tmcd");
|
||||
let m = b.start_full(b"tcmi", 0, 0);
|
||||
b.u16(0);
|
||||
b.u16(0);
|
||||
b.u16(12);
|
||||
b.u16(0);
|
||||
b.bytes(&[0xFF; 6]);
|
||||
b.zeros(6);
|
||||
b.u8(0);
|
||||
b.end(m);
|
||||
b.end(tm);
|
||||
b.end(g);
|
||||
}
|
||||
Handler::Other => {
|
||||
let m = b.start_full(b"nmhd", 0, 0);
|
||||
b.end(m);
|
||||
}
|
||||
}
|
||||
if qt {
|
||||
// QuickTime data handler reference.
|
||||
let m = b.start_full(b"hdlr", 0, 0);
|
||||
b.bytes(b"dhlr");
|
||||
b.bytes(b"url ");
|
||||
b.zeros(12);
|
||||
b.u8(0);
|
||||
b.end(m);
|
||||
}
|
||||
let d = b.start(b"dinf");
|
||||
let r = b.start_full(b"dref", 0, 0);
|
||||
b.u32(1);
|
||||
let u = b.start_full(b"url ", 0, 1);
|
||||
b.end(u);
|
||||
b.end(r);
|
||||
b.end(d);
|
||||
let stbl = b.start(b"stbl");
|
||||
let m = b.start_full(b"stsd", 0, 0);
|
||||
b.u32(1);
|
||||
t.cfg.entry.write(b, qt)?;
|
||||
b.end(m);
|
||||
let tb = &t.t;
|
||||
let m = b.start_full(b"stts", 0, 0);
|
||||
b.u32(tb.stts.len() as u32);
|
||||
for &(c, d) in &tb.stts {
|
||||
b.u32(c);
|
||||
b.u32(d);
|
||||
}
|
||||
b.end(m);
|
||||
if tb.ctts.iter().any(|&(_, o)| o != 0) {
|
||||
let neg = tb.ctts.iter().any(|&(_, o)| o < 0);
|
||||
let m = b.start_full(b"ctts", neg as u8, 0);
|
||||
b.u32(tb.ctts.len() as u32);
|
||||
for &(c, o) in &tb.ctts {
|
||||
b.u32(c);
|
||||
b.i32(o);
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
if !tb.all_sync {
|
||||
let m = b.start_full(b"stss", 0, 0);
|
||||
b.u32(tb.sync.len() as u32);
|
||||
for &s in &tb.sync {
|
||||
b.u32(s);
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
let m = b.start_full(b"stsc", 0, 0);
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for (i, &(_, n)) in tb.chunks.iter().enumerate() {
|
||||
if runs.last().is_none_or(|r| r.1 != n) {
|
||||
runs.push((i as u32 + 1, n));
|
||||
}
|
||||
}
|
||||
b.u32(runs.len() as u32);
|
||||
for (first, n) in runs {
|
||||
b.u32(first);
|
||||
b.u32(n);
|
||||
b.u32(1);
|
||||
}
|
||||
b.end(m);
|
||||
let m = b.start_full(b"stsz", 0, 0);
|
||||
match tb.const_size {
|
||||
Some(s) => {
|
||||
b.u32(s);
|
||||
b.u32(tb.sample_count.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
None => {
|
||||
let first = tb.sizes.first().copied().unwrap_or(0);
|
||||
if !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) {
|
||||
b.u32(first);
|
||||
b.u32(tb.sizes.len() as u32);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(tb.sizes.len() as u32);
|
||||
for &s in &tb.sizes {
|
||||
b.u32(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64);
|
||||
let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0);
|
||||
b.u32(tb.chunks.len() as u32);
|
||||
for &(o, _) in &tb.chunks {
|
||||
if co64 {
|
||||
b.u64(o + shift);
|
||||
} else {
|
||||
b.u32((o + shift) as u32);
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
b.end(stbl);
|
||||
b.end(minf);
|
||||
b.end(mdia);
|
||||
b.end(trak);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) {
|
||||
let items: Vec<([u8; 4], &str)> = md
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let mut chars = k.chars();
|
||||
if chars.next()? != '©' {
|
||||
return None;
|
||||
}
|
||||
let rest: Vec<u8> = chars.map(|c| c as u32 as u8).collect();
|
||||
(rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str()))
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let u = b.start(b"udta");
|
||||
for (k, v) in items {
|
||||
let m = b.start(&k);
|
||||
b.u16(v.len() as u16);
|
||||
b.u16(0x55C4);
|
||||
b.bytes(v.as_bytes());
|
||||
b.end(m);
|
||||
}
|
||||
b.end(u);
|
||||
}
|
||||
|
||||
fn validate_track(cfg: &TrackConfig) -> Result<Option<u32>> {
|
||||
if cfg.timescale == 0 {
|
||||
return Err(Error::Mux("timescale must be non-zero".into()));
|
||||
}
|
||||
if let CodecConfig::Pcm(p) = &cfg.entry.codec {
|
||||
if p.bits == 0 || p.channels == 0 {
|
||||
return Err(Error::Mux("PCM needs bits and channels".into()));
|
||||
}
|
||||
if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 {
|
||||
return Err(Error::Mux("PCM track timescale must equal the sample rate".into()));
|
||||
}
|
||||
return Ok(Some(p.bytes_per_frame()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish)
|
||||
/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)).
|
||||
pub struct Mp4Writer<W: Write + Seek> {
|
||||
w: W,
|
||||
opts: WriterOptions,
|
||||
tracks: Vec<WTrack>,
|
||||
/// Offset of the placeholder box (8 bytes) preceding the mdat header.
|
||||
pad_pos: u64,
|
||||
mdat_pos: u64,
|
||||
pos: u64,
|
||||
last_track: Option<usize>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Mp4Writer<W> {
|
||||
/// Start a file: writes `ftyp`, a placeholder box and the `mdat` header.
|
||||
pub fn new(mut w: W, opts: WriterOptions) -> Result<Self> {
|
||||
let start = w.stream_position()?;
|
||||
let mut b = BoxBuf::new();
|
||||
write_ftyp(&mut b, opts.brand);
|
||||
let pad_pos = start + b.len() as u64;
|
||||
b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]);
|
||||
let mdat_pos = start + b.len() as u64;
|
||||
b.leaf(b"mdat", &[]);
|
||||
w.write_all(&b.buf)?;
|
||||
let pos = start + b.len() as u64;
|
||||
Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None })
|
||||
}
|
||||
|
||||
/// Add a track; returns its index (track ids are index + 1).
|
||||
pub fn add_track(&mut self, cfg: TrackConfig) -> Result<usize> {
|
||||
let pcm = validate_track(&cfg)?;
|
||||
let handler = handler_of(&cfg.entry);
|
||||
let mut t = Tables::new();
|
||||
t.const_size = pcm;
|
||||
self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None });
|
||||
Ok(self.tracks.len() - 1)
|
||||
}
|
||||
|
||||
/// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start
|
||||
/// frame number) is written at finish, spanning the video track's duration.
|
||||
pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result<usize> {
|
||||
if video_track >= self.tracks.len() {
|
||||
return Err(Error::NoSuchTrack(video_track));
|
||||
}
|
||||
if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 {
|
||||
return Err(Error::Mux("timecode needs timescale, frame duration and fps".into()));
|
||||
}
|
||||
let entry = SampleEntry {
|
||||
format: crate::bytes::FourCc(*b"tmcd"),
|
||||
data_reference_index: 1,
|
||||
codec: CodecConfig::Timecode(tc.clone()),
|
||||
video: None,
|
||||
audio: None,
|
||||
bitrate: None,
|
||||
};
|
||||
let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?;
|
||||
self.tracks[idx].tmcd_source = Some((video_track, start_frame));
|
||||
let id = self.tracks[idx].id;
|
||||
self.tracks[video_track].tmcd_ref = Some(id);
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole
|
||||
/// number of audio frames.
|
||||
pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> {
|
||||
let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?;
|
||||
if tr.tmcd_source.is_some() {
|
||||
return Err(Error::Mux("timecode track samples are written automatically".into()));
|
||||
}
|
||||
let (count, dur) = match tr.pcm_frame_bytes {
|
||||
Some(fb) => {
|
||||
if !s.data.len().is_multiple_of(fb as usize) {
|
||||
return Err(Error::Mux("PCM data is not a whole number of frames".into()));
|
||||
}
|
||||
((s.data.len() / fb as usize) as u32, 1u32)
|
||||
}
|
||||
None => {
|
||||
if s.data.len() > u32::MAX as usize {
|
||||
return Err(Error::Mux("sample larger than 4 GiB".into()));
|
||||
}
|
||||
(1, s.duration)
|
||||
}
|
||||
};
|
||||
if count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.w.write_all(s.data)?;
|
||||
let offset = self.pos;
|
||||
self.pos += s.data.len() as u64;
|
||||
let contiguous = self.last_track == Some(track);
|
||||
self.last_track = Some(track);
|
||||
let tr = &mut self.tracks[track];
|
||||
let t = &mut tr.t;
|
||||
Tables::push_run(&mut t.stts, count, dur);
|
||||
Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset });
|
||||
if tr.pcm_frame_bytes.is_none() {
|
||||
t.sizes.push(s.data.len() as u32);
|
||||
if s.is_sync {
|
||||
t.sync.push(t.sample_count as u32 + 1);
|
||||
} else {
|
||||
t.all_sync = false;
|
||||
}
|
||||
}
|
||||
t.sample_count += count as u64;
|
||||
t.media_duration += count as u64 * dur as u64;
|
||||
match t.chunks.last_mut() {
|
||||
Some(c) if contiguous => c.1 += count,
|
||||
_ => t.chunks.push((offset, count)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_mdat(&mut self) -> Result<()> {
|
||||
// Timecode samples.
|
||||
for i in 0..self.tracks.len() {
|
||||
let Some((vi, start)) = self.tracks[i].tmcd_source else { continue };
|
||||
let v = &self.tracks[vi];
|
||||
let vd = v.t.media_duration;
|
||||
let vts = v.cfg.timescale;
|
||||
let tts = self.tracks[i].cfg.timescale;
|
||||
let tc_dur = match &self.tracks[i].cfg.entry.codec {
|
||||
CodecConfig::Timecode(tc) => tc.frame_duration,
|
||||
_ => 1,
|
||||
};
|
||||
let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32;
|
||||
self.w.write_all(&start.to_be_bytes())?;
|
||||
let off = self.pos;
|
||||
self.pos += 4;
|
||||
self.last_track = None;
|
||||
let t = &mut self.tracks[i].t;
|
||||
t.stts.push((1, d));
|
||||
t.sizes.push(4);
|
||||
t.sync.push(1);
|
||||
t.sample_count = 1;
|
||||
t.media_duration = d as u64;
|
||||
t.chunks.push((off, 1));
|
||||
}
|
||||
let size = self.pos - self.mdat_pos;
|
||||
if size <= u32::MAX as u64 {
|
||||
self.w.seek(SeekFrom::Start(self.mdat_pos))?;
|
||||
self.w.write_all(&(size as u32).to_be_bytes())?;
|
||||
} else {
|
||||
// Merge the placeholder into a 16-byte large-size mdat header.
|
||||
let mut h = [0u8; 16];
|
||||
h[..4].copy_from_slice(&1u32.to_be_bytes());
|
||||
h[4..8].copy_from_slice(b"mdat");
|
||||
h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes());
|
||||
self.w.seek(SeekFrom::Start(self.pad_pos))?;
|
||||
self.w.write_all(&h)?;
|
||||
}
|
||||
self.w.seek(SeekFrom::Start(self.pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_moov(&self, shift: u64, force_co64: bool) -> Result<Vec<u8>> {
|
||||
let qt = self.opts.brand == Brand::Mov;
|
||||
let ts = self.opts.movie_timescale.max(1);
|
||||
let mut b = BoxBuf::new();
|
||||
let m = b.start(b"moov");
|
||||
write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1);
|
||||
for t in &self.tracks {
|
||||
write_trak(&mut b, t, ts, qt, shift, force_co64)?;
|
||||
}
|
||||
write_udta(&mut b, &self.opts.metadata);
|
||||
b.end(m);
|
||||
Ok(b.buf)
|
||||
}
|
||||
|
||||
/// Finish the file with `moov` after `mdat`. Returns the underlying writer.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.finish_mdat()?;
|
||||
let moov = self.build_moov(0, false)?;
|
||||
self.w.write_all(&moov)?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Read + Write + Seek> Mp4Writer<W> {
|
||||
/// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place.
|
||||
pub fn finish_faststart(mut self) -> Result<W> {
|
||||
self.finish_mdat()?;
|
||||
let mut moov = self.build_moov(0, false)?;
|
||||
let mut force = false;
|
||||
loop {
|
||||
let shift = moov.len() as u64;
|
||||
let next = self.build_moov(shift, force)?;
|
||||
if next.len() == moov.len() {
|
||||
moov = next;
|
||||
break;
|
||||
}
|
||||
// Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges).
|
||||
force = true;
|
||||
moov = next;
|
||||
}
|
||||
let shift = moov.len() as u64;
|
||||
let start = self.pad_pos;
|
||||
let end = self.pos;
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut cur = end;
|
||||
while cur > start {
|
||||
let n = (cur - start).min(buf.len() as u64) as usize;
|
||||
cur -= n as u64;
|
||||
self.w.seek(SeekFrom::Start(cur))?;
|
||||
self.w.read_exact(&mut buf[..n])?;
|
||||
self.w.seek(SeekFrom::Start(cur + shift))?;
|
||||
self.w.write_all(&buf[..n])?;
|
||||
}
|
||||
self.w.seek(SeekFrom::Start(start))?;
|
||||
self.w.write_all(&moov)?;
|
||||
self.w.seek(SeekFrom::Start(end + shift))?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffered fragment sample: (data, duration, composition offset, sync).
|
||||
type PendingSample = (Vec<u8>, u32, i32, bool);
|
||||
|
||||
/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by
|
||||
/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment).
|
||||
pub struct FragmentedWriter<W: Write> {
|
||||
w: W,
|
||||
opts: WriterOptions,
|
||||
tracks: Vec<WTrack>,
|
||||
pending: Vec<Vec<PendingSample>>,
|
||||
next_dts: Vec<u64>,
|
||||
seq: u32,
|
||||
init_written: bool,
|
||||
}
|
||||
|
||||
impl<W: Write> FragmentedWriter<W> {
|
||||
pub fn new(w: W, opts: WriterOptions) -> Self {
|
||||
FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false }
|
||||
}
|
||||
|
||||
/// Add a track (before the first fragment). PCM is not supported in fragments.
|
||||
pub fn add_track(&mut self, cfg: TrackConfig) -> Result<usize> {
|
||||
if self.init_written {
|
||||
return Err(Error::Mux("tracks must be added before the first fragment".into()));
|
||||
}
|
||||
if validate_track(&cfg)?.is_some() {
|
||||
return Err(Error::Mux("PCM is not supported by the fragmented writer".into()));
|
||||
}
|
||||
let handler = handler_of(&cfg.entry);
|
||||
self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: None, t: Tables::new(), cfg, tmcd_ref: None, tmcd_source: None });
|
||||
self.pending.push(Vec::new());
|
||||
self.next_dts.push(0);
|
||||
Ok(self.tracks.len() - 1)
|
||||
}
|
||||
|
||||
fn write_init(&mut self) -> Result<()> {
|
||||
let qt = self.opts.brand == Brand::Mov;
|
||||
let ts = self.opts.movie_timescale.max(1);
|
||||
let mut b = BoxBuf::new();
|
||||
write_ftyp(&mut b, self.opts.brand);
|
||||
let m = b.start(b"moov");
|
||||
write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1);
|
||||
for t in &self.tracks {
|
||||
write_trak(&mut b, t, ts, qt, 0, false)?;
|
||||
}
|
||||
let mv = b.start(b"mvex");
|
||||
for t in &self.tracks {
|
||||
let x = b.start_full(b"trex", 0, 0);
|
||||
b.u32(t.id);
|
||||
b.u32(1);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.end(x);
|
||||
}
|
||||
b.end(mv);
|
||||
write_udta(&mut b, &self.opts.metadata);
|
||||
b.end(m);
|
||||
self.w.write_all(&b.buf)?;
|
||||
self.init_written = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Buffer a sample for the next fragment.
|
||||
pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> {
|
||||
let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?;
|
||||
if s.data.len() > u32::MAX as usize {
|
||||
return Err(Error::Mux("sample larger than 4 GiB".into()));
|
||||
}
|
||||
p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_moof(&self, data_offsets: &[i32]) -> Vec<u8> {
|
||||
let mut b = BoxBuf::new();
|
||||
let m = b.start(b"moof");
|
||||
let h = b.start_full(b"mfhd", 0, 0);
|
||||
b.u32(self.seq);
|
||||
b.end(h);
|
||||
for (i, t) in self.tracks.iter().enumerate() {
|
||||
let p = &self.pending[i];
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tf = b.start(b"traf");
|
||||
let h = b.start_full(b"tfhd", 0, 0x20000);
|
||||
b.u32(t.id);
|
||||
b.end(h);
|
||||
let h = b.start_full(b"tfdt", 1, 0);
|
||||
b.u64(self.next_dts[i]);
|
||||
b.end(h);
|
||||
let neg = p.iter().any(|s| s.2 < 0);
|
||||
let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800);
|
||||
b.u32(p.len() as u32);
|
||||
b.i32(data_offsets[i]);
|
||||
for (d, dur, cto, sync) in p {
|
||||
b.u32(*dur);
|
||||
b.u32(d.len() as u32);
|
||||
b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 });
|
||||
b.i32(*cto);
|
||||
}
|
||||
b.end(h);
|
||||
b.end(tf);
|
||||
}
|
||||
b.end(m);
|
||||
b.buf
|
||||
}
|
||||
|
||||
/// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered).
|
||||
pub fn flush_fragment(&mut self) -> Result<()> {
|
||||
if !self.init_written {
|
||||
self.write_init()?;
|
||||
}
|
||||
if self.pending.iter().all(|p| p.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
self.seq += 1;
|
||||
let probe = self.build_moof(&vec![0; self.tracks.len()]);
|
||||
let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum();
|
||||
let large = data_len + 8 > u32::MAX as u64;
|
||||
let hdr = if large { 16 } else { 8 };
|
||||
let mut offs = Vec::with_capacity(self.tracks.len());
|
||||
let mut acc = probe.len() as u64 + hdr;
|
||||
for p in &self.pending {
|
||||
offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?);
|
||||
acc += p.iter().map(|s| s.0.len() as u64).sum::<u64>();
|
||||
}
|
||||
let moof = self.build_moof(&offs);
|
||||
self.w.write_all(&moof)?;
|
||||
if large {
|
||||
self.w.write_all(&1u32.to_be_bytes())?;
|
||||
self.w.write_all(b"mdat")?;
|
||||
self.w.write_all(&(data_len + 16).to_be_bytes())?;
|
||||
} else {
|
||||
self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?;
|
||||
self.w.write_all(b"mdat")?;
|
||||
}
|
||||
for (i, p) in self.pending.iter_mut().enumerate() {
|
||||
for (d, dur, _, _) in p.drain(..) {
|
||||
self.w.write_all(&d)?;
|
||||
self.next_dts[i] += dur as u64;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush remaining samples and return the writer.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.flush_fragment()?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codec::{AvcConfig, PcmConfig};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn avc_entry() -> SampleEntry {
|
||||
SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_pack() {
|
||||
assert_eq!(pack_language("und"), 0x55C4);
|
||||
assert_eq!(pack_language("eng"), 0x15C7);
|
||||
assert_eq!(pack_language("EN"), 0x55C4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_roundtrip_and_faststart() {
|
||||
for fast in [false, true] {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap();
|
||||
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 };
|
||||
let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let data = vec![i; 100 + i as usize];
|
||||
w.write_sample(v, WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 })
|
||||
.unwrap();
|
||||
w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner();
|
||||
let f = crate::open(out.as_slice()).unwrap();
|
||||
assert_eq!(f.tracks.len(), 2);
|
||||
let t = &f.tracks[0];
|
||||
assert_eq!(t.samples.len(), 10);
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
assert_eq!(s.size as usize, 100 + i);
|
||||
assert_eq!(s.dts, 512 * i as i64);
|
||||
assert_eq!(s.is_sync, i % 5 == 0);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]);
|
||||
}
|
||||
let at = &f.tracks[1];
|
||||
assert!(at.pcm_chunked);
|
||||
assert_eq!(at.samples.len(), 10);
|
||||
assert_eq!(at.samples[3].size, 400);
|
||||
assert_eq!(at.samples[3].duration, 100);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]);
|
||||
if fast {
|
||||
assert!(f.moov_offset < t.samples[0].offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_roundtrip() {
|
||||
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
||||
let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap();
|
||||
for i in 0..6u8 {
|
||||
w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap();
|
||||
if i % 3 == 2 {
|
||||
w.flush_fragment().unwrap();
|
||||
}
|
||||
}
|
||||
let out = w.finish().unwrap();
|
||||
let f = crate::open(out.as_slice()).unwrap();
|
||||
assert!(f.fragmented);
|
||||
assert_eq!(f.fragment_count, 2);
|
||||
let t = &f.tracks[0];
|
||||
assert_eq!(t.samples.len(), 6);
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
assert_eq!(s.dts, 40 * i as i64);
|
||||
assert_eq!(s.pts, s.dts - (i as i64 % 2));
|
||||
assert_eq!(s.is_sync, i % 3 == 0);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]);
|
||||
}
|
||||
assert_eq!(t.sync_sample_before(5), 3);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! Random-access byte sources for the demuxer.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Random-access, read-only byte source (file, in-memory buffer, web Blob…).
|
||||
///
|
||||
/// Implementations must be able to serve reads at arbitrary offsets through `&self`.
|
||||
pub trait ByteSource {
|
||||
/// Total length in bytes.
|
||||
fn len(&self) -> u64;
|
||||
/// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||
/// True if the source holds no bytes.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn eof() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source")
|
||||
}
|
||||
|
||||
impl ByteSource for [u8] {
|
||||
fn len(&self) -> u64 {
|
||||
<[u8]>::len(self) as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let start = usize::try_from(offset).map_err(|_| eof())?;
|
||||
let end = start.checked_add(buf.len()).ok_or_else(eof)?;
|
||||
let src = self.get(start..end).ok_or_else(eof)?;
|
||||
buf.copy_from_slice(src);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ByteSource for Vec<u8> {
|
||||
fn len(&self) -> u64 {
|
||||
self.as_slice().len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for &T {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Arc<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Box<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ByteSource for std::fs::File {
|
||||
fn len(&self) -> u64 {
|
||||
self.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(self, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !buf.is_empty() {
|
||||
let n = self.seek_read(buf, offset)?;
|
||||
if n == 0 {
|
||||
return Err(eof());
|
||||
}
|
||||
offset += n as u64;
|
||||
buf = &mut buf[n..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = self;
|
||||
f.seek(SeekFrom::Start(offset))?;
|
||||
f.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slice_reads() {
|
||||
let v = vec![1u8, 2, 3, 4];
|
||||
let mut b = [0u8; 2];
|
||||
v.read_at(1, &mut b).unwrap();
|
||||
assert_eq!(b, [2, 3]);
|
||||
assert!(v.read_at(3, &mut b).is_err());
|
||||
assert!(v.read_at(u64::MAX, &mut b).is_err());
|
||||
let a: Arc<[u8]> = Arc::from(v.clone());
|
||||
a.read_at(2, &mut b).unwrap();
|
||||
assert_eq!(b, [3, 4]);
|
||||
assert_eq!(ByteSource::len(&a), 4);
|
||||
let s: &[u8] = &v;
|
||||
assert_eq!(ByteSource::len(&s), 4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
//! Shared helpers for oracle tests: ffmpeg/ffprobe discovery and fixture generation.
|
||||
//! ffmpeg is used only as an external fixture generator and oracle (never linked).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
pub fn tool(name: &str) -> Option<PathBuf> {
|
||||
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
|
||||
let p = Path::new(dir).join(name);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
|
||||
ok.then(|| PathBuf::from(name))
|
||||
}
|
||||
|
||||
pub fn ffmpeg() -> Option<PathBuf> {
|
||||
let f = tool("ffmpeg");
|
||||
if f.is_none() {
|
||||
eprintln!("ffmpeg not found; skipping oracle test");
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
pub fn ffprobe() -> Option<PathBuf> {
|
||||
let f = tool("ffprobe");
|
||||
if f.is_none() {
|
||||
eprintln!("ffprobe not found; skipping oracle test");
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
pub fn fixture_dir() -> PathBuf {
|
||||
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/isobmff");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
pub fn out_dir() -> PathBuf {
|
||||
let d = fixture_dir().join("out");
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"];
|
||||
const A: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"];
|
||||
|
||||
/// (fixture name, ffmpeg arguments after the global options, output file name derived from the name).
|
||||
pub fn fixture_specs() -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
let cat = |parts: &[&[&'static str]]| parts.concat();
|
||||
vec![
|
||||
("h264_bframes.mp4", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-c:a", "aac"]])),
|
||||
("h264.mov", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "3", "-g", "25", "-pix_fmt", "yuv420p", "-c:a", "pcm_s16le"]])),
|
||||
("hevc.mp4", cat(&[V, &["-t", "2", "-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:bframes=3", "-pix_fmt", "yuv420p"]])),
|
||||
("prores.mov", cat(&[V, A, &["-t", "1", "-c:v", "prores_ks", "-profile:v", "3", "-c:a", "pcm_s24le"]])),
|
||||
("prores4444.mov", cat(&[V, &["-t", "0.5", "-c:v", "prores_ks", "-profile:v", "4", "-pix_fmt", "yuva444p10le"]])),
|
||||
("mjpeg.mov", cat(&[V, &["-t", "1", "-c:v", "mjpeg", "-q:v", "5"]])),
|
||||
(
|
||||
"dnxhd.mov",
|
||||
vec!["-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=25", "-t", "0.2", "-c:v", "dnxhd", "-profile:v", "dnxhr_lb", "-pix_fmt", "yuv422p"],
|
||||
),
|
||||
("pcm_f32.mov", cat(&[A, &["-t", "1", "-c:a", "pcm_f32le"]])),
|
||||
("pcm_s16be.mov", cat(&[A, &["-t", "1", "-c:a", "pcm_s16be"]])),
|
||||
("aac.mp4", cat(&[A, &["-t", "2", "-c:a", "aac", "-b:a", "96k"]])),
|
||||
("aac_stereo44.m4a", vec!["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100", "-t", "1.5", "-ac", "2", "-c:a", "aac"]),
|
||||
(
|
||||
"frag.mp4",
|
||||
cat(&[
|
||||
V,
|
||||
A,
|
||||
&["-t", "3", "-c:v", "libx264", "-bf", "2", "-g", "25", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "frag_keyframe+empty_moov"],
|
||||
]),
|
||||
),
|
||||
(
|
||||
"frag_dash.mp4",
|
||||
cat(&[
|
||||
V,
|
||||
&["-t", "3", "-c:v", "libx264", "-bf", "2", "-g", "25", "-pix_fmt", "yuv420p", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
||||
]),
|
||||
),
|
||||
("faststart.mp4", cat(&[V, A, &["-t", "2", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart"]])),
|
||||
("tmcd_2997df.mov", vec!["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=30000/1001", "-t", "1", "-c:v", "mjpeg", "-timecode", "01:00:00;00"]),
|
||||
(
|
||||
"tmcd_23976.mov",
|
||||
vec!["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=24000/1001", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-timecode", "01:00:00:00"],
|
||||
),
|
||||
("h264_2997.mp4", vec!["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=30000/1001", "-t", "2", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"]),
|
||||
("flac.mp4", cat(&[A, &["-t", "1", "-c:a", "flac"]])),
|
||||
("alac.m4a", cat(&[A, &["-t", "1", "-c:a", "alac"]])),
|
||||
("opus.mp4", cat(&[A, &["-t", "1", "-c:a", "libopus"]])),
|
||||
("ac3.mp4", cat(&[A, &["-t", "1", "-c:a", "ac3"]])),
|
||||
("vp9.mp4", cat(&[V, &["-t", "1", "-c:v", "libvpx-vp9", "-deadline", "realtime", "-cpu-used", "8"]])),
|
||||
("av1.mp4", cat(&[V, &["-t", "1", "-c:v", "libsvtav1", "-preset", "12"]])),
|
||||
(
|
||||
"colr_pasp.mov",
|
||||
cat(&[
|
||||
V,
|
||||
&[
|
||||
"-t",
|
||||
"0.5",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-color_primaries",
|
||||
"bt709",
|
||||
"-color_trc",
|
||||
"bt709",
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-aspect",
|
||||
"16:9",
|
||||
],
|
||||
]),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool {
|
||||
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let ext = out.extension().unwrap().to_str().unwrap();
|
||||
let tmp = out.with_extension(format!("tmp{}_{n}.{ext}", std::process::id()));
|
||||
let st = Command::new(ff).args(["-v", "error", "-y", "-nostdin"]).args(args).arg(&tmp).output();
|
||||
match st {
|
||||
Ok(o) if o.status.success() => std::fs::rename(&tmp, out).is_ok(),
|
||||
Ok(o) => {
|
||||
eprintln!("ffmpeg failed for {}: {}", out.display(), String::from_utf8_lossy(&o.stderr));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
false
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to a fixture, generating it with ffmpeg if needed. `None` if ffmpeg is missing or the
|
||||
/// encoder isn't available.
|
||||
pub fn fixture(name: &str) -> Option<PathBuf> {
|
||||
let ff = tool("ffmpeg")?;
|
||||
let path = fixture_dir().join(name);
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
if name == "editlist_cut.mp4" {
|
||||
let src = fixture("h264_bframes.mp4")?;
|
||||
let s = src.to_str()?.to_string();
|
||||
return run_ffmpeg(&ff, &["-ss", "0.5", "-i", &s, "-c", "copy"], &path).then_some(path);
|
||||
}
|
||||
let (_, args) = fixture_specs().into_iter().find(|(n, _)| *n == name)?;
|
||||
run_ffmpeg(&ff, &args, &path).then_some(path)
|
||||
}
|
||||
|
||||
pub fn all_fixture_names() -> Vec<&'static str> {
|
||||
let mut v: Vec<&str> = fixture_specs().iter().map(|(n, _)| *n).collect();
|
||||
v.push("editlist_cut.mp4");
|
||||
v
|
||||
}
|
||||
|
||||
pub fn probe_json(path: &Path, what: &[&str]) -> Option<Value> {
|
||||
let fp = tool("ffprobe")?;
|
||||
let o = Command::new(fp).args(["-v", "error", "-of", "json"]).args(what).arg(path).output().ok()?;
|
||||
if !o.status.success() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_slice(&o.stdout).ok()
|
||||
}
|
||||
|
||||
pub fn packets(path: &Path) -> Vec<Value> {
|
||||
probe_json(path, &["-show_packets"]).and_then(|v| v["packets"].as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn streams(path: &Path) -> Vec<Value> {
|
||||
probe_json(path, &["-show_streams"]).and_then(|v| v["streams"].as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn int(v: &Value) -> Option<i64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_i64(),
|
||||
Value::String(s) => s.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a file with ffmpeg to the null muxer; returns stderr (empty on success).
|
||||
pub fn ffmpeg_decode_errors(path: &Path) -> Option<String> {
|
||||
let ff = tool("ffmpeg")?;
|
||||
let o = Command::new(ff).args(["-v", "error", "-nostdin", "-i"]).arg(path).args(["-f", "null", "-"]).output().ok()?;
|
||||
let mut s = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
if !o.status.success() {
|
||||
s.push_str("\n(ffmpeg exited with failure)");
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
//! Deterministic mutation fuzzing: the demuxer must never panic on corrupted input.
|
||||
|
||||
mod common;
|
||||
|
||||
use filmcraft_isobmff::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
// xorshift64*
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_files() -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
for brand in [Brand::Mp4, Brand::Mov] {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(brand)).unwrap();
|
||||
let v = w.add_track(TrackConfig::new(SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x64, 0, 0x1F]], vec![vec![0x68]], 4), 64, 64), 25)).unwrap();
|
||||
let mut cfg = TrackConfig::new(SampleEntry::aac(vec![0x11, 0x90], 2, 48000), 48000);
|
||||
cfg.media_start = Some(1024);
|
||||
let a = w.add_track(cfg).unwrap();
|
||||
let p = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 1, sample_rate: 8000.0 };
|
||||
let pc = w.add_track(TrackConfig::new(SampleEntry::pcm(p), 8000)).unwrap();
|
||||
w.add_timecode_track(v, TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, ..Default::default() }, 1000)
|
||||
.unwrap();
|
||||
for i in 0..20u8 {
|
||||
w.write_sample(v, WriteSample { data: &[i; 20], duration: 1, composition_offset: (i % 3) as i32, is_sync: i % 10 == 0 }).unwrap();
|
||||
w.write_sample(a, WriteSample { data: &[i; 7], duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
||||
w.write_sample(pc, WriteSample { data: &[i; 16], duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
out.push(w.finish().unwrap().into_inner());
|
||||
}
|
||||
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
||||
let v = w.add_track(TrackConfig::new(SampleEntry::jpeg(16, 16), 1000)).unwrap();
|
||||
for i in 0..12u8 {
|
||||
w.write_sample(v, WriteSample { data: &[i; 9], duration: 40, composition_offset: -1, is_sync: i % 4 == 0 }).unwrap();
|
||||
if i % 4 == 3 {
|
||||
w.flush_fragment().unwrap();
|
||||
}
|
||||
}
|
||||
out.push(w.finish().unwrap());
|
||||
out
|
||||
}
|
||||
|
||||
fn exercise(data: &[u8]) -> bool {
|
||||
let r = open(data);
|
||||
let ok = r.is_ok();
|
||||
if let Ok(f) = r {
|
||||
for (ti, t) in f.tracks.iter().enumerate() {
|
||||
let n = t.samples.len();
|
||||
for i in [0, n / 2, n.saturating_sub(1)] {
|
||||
let _ = f.read_sample(data, ti, i);
|
||||
let _ = t.sync_sample_before(i);
|
||||
let _ = t.presentation_pts(i);
|
||||
}
|
||||
let _ = t.sample_at_pts(i64::MIN);
|
||||
let _ = t.sample_at_pts(i64::MAX);
|
||||
let _ = t.sample_at_presentation_time(0);
|
||||
if let Some(CodecConfig::Timecode(tc)) = t.codec() {
|
||||
let _ = tc.format_frame(tc.start_frame.unwrap_or(u32::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn mutate_all(base: &[u8], seed: u64, iterations: usize) {
|
||||
let mut rng = Rng(seed | 1);
|
||||
// Focus mutations on the moov/moof boxes (where parsing happens).
|
||||
let hot: Vec<(usize, usize)> = {
|
||||
let mut v = Vec::new();
|
||||
for name in [b"moov", b"moof"] {
|
||||
let mut from = 0;
|
||||
while let Some(p) = base[from..].windows(4).position(|w| w == name) {
|
||||
let at = from + p;
|
||||
let start = at.saturating_sub(4);
|
||||
let size = u32::from_be_bytes(base[start..start + 4].try_into().unwrap()) as usize;
|
||||
v.push((start, (start + size.max(8)).min(base.len())));
|
||||
from = at + 4;
|
||||
}
|
||||
}
|
||||
if v.is_empty() {
|
||||
v.push((0, base.len()));
|
||||
}
|
||||
v
|
||||
};
|
||||
let mut parsed = 0usize;
|
||||
for _ in 0..iterations {
|
||||
let mut d = base.to_vec();
|
||||
let (lo, hi) = hot[rng.below(hot.len())];
|
||||
let edits = 1 + rng.below(4);
|
||||
for _ in 0..edits {
|
||||
let pos = if rng.below(10) < 8 { lo + rng.below(hi - lo) } else { rng.below(d.len()) };
|
||||
let pos = pos.min(d.len() - 1);
|
||||
match rng.below(6) {
|
||||
0 => d[pos] ^= 1 << rng.below(8),
|
||||
1 => d[pos] = rng.next() as u8,
|
||||
2 | 3 => {
|
||||
// Overwrite a 32-bit field with an extreme value.
|
||||
let v: u32 = match rng.below(5) {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => u32::MAX,
|
||||
3 => 0x8000_0000,
|
||||
_ => rng.next() as u32,
|
||||
};
|
||||
let end = (pos + 4).min(d.len());
|
||||
d[pos..end].copy_from_slice(&v.to_be_bytes()[..end - pos]);
|
||||
}
|
||||
4 => d.truncate(pos.max(8)),
|
||||
_ => {
|
||||
let n = rng.below(16).min(d.len() - pos);
|
||||
d.drain(pos..pos + n);
|
||||
}
|
||||
}
|
||||
if d.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
parsed += exercise(&d) as usize;
|
||||
}
|
||||
eprintln!("seed {seed:#x}: {parsed}/{iterations} mutated files still parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_synthetic_files_never_panic() {
|
||||
for (i, f) in synthetic_files().iter().enumerate() {
|
||||
exercise(f);
|
||||
mutate_all(f, 0x5EED_0000 + i as u64, 20_000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_ffmpeg_fixtures_never_panic() {
|
||||
for (i, name) in ["h264_bframes.mp4", "frag.mp4", "tmcd_2997df.mov", "prores.mov", "hevc.mp4", "aac.mp4"].iter().enumerate() {
|
||||
let Some(p) = common::fixture(name) else {
|
||||
eprintln!("skipping {name}: fixture unavailable");
|
||||
continue;
|
||||
};
|
||||
let data = std::fs::read(p).unwrap();
|
||||
mutate_all(&data, 0xF00D + i as u64, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncations_never_panic() {
|
||||
for f in synthetic_files() {
|
||||
for n in 0..f.len().min(4096) {
|
||||
exercise(&f[..n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
//! Oracle tests: compare our demuxer's sample tables and codec parameters with ffprobe.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind, open};
|
||||
use serde_json::Value;
|
||||
|
||||
fn pcm_name_matches(ff: &str, c: &CodecConfig) -> bool {
|
||||
let CodecConfig::Pcm(p) = c else { return false };
|
||||
let Some(rest) = ff.strip_prefix("pcm_") else { return false };
|
||||
let kind = rest.as_bytes()[0];
|
||||
let digits: String = rest[1..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let bits: u16 = digits.parse().unwrap_or(0);
|
||||
let endian = &rest[1 + digits.len()..];
|
||||
let kind_ok = match kind {
|
||||
b'f' => p.float,
|
||||
b's' => p.signed && !p.float,
|
||||
b'u' => !p.signed && !p.float,
|
||||
_ => false,
|
||||
};
|
||||
let endian_ok = match endian {
|
||||
"le" => !p.big_endian,
|
||||
"be" => p.big_endian,
|
||||
_ => true, // 8-bit has no endianness
|
||||
};
|
||||
kind_ok && endian_ok && bits == p.bits
|
||||
}
|
||||
|
||||
fn check_stream(file: &Mp4File, idx: usize, st: &Value) -> Result<(), String> {
|
||||
let t = &file.tracks[idx];
|
||||
let codec = t.codec().ok_or("no sample entry")?;
|
||||
let ffname = st["codec_name"].as_str().unwrap_or("");
|
||||
let tag = st["codec_tag_string"].as_str().unwrap_or("");
|
||||
let entry = &t.entries[0];
|
||||
if tag != entry.format.to_string() {
|
||||
return Err(format!("tag {tag} vs {}", entry.format));
|
||||
}
|
||||
let name_ok = match codec {
|
||||
CodecConfig::Pcm(_) => pcm_name_matches(ffname, codec),
|
||||
CodecConfig::Timecode(_) => st["codec_type"] == "data",
|
||||
c => c.name() == ffname,
|
||||
};
|
||||
if !name_ok {
|
||||
return Err(format!("codec {ffname} vs {codec:?}"));
|
||||
}
|
||||
let tb = format!("1/{}", t.timescale);
|
||||
if st["time_base"].as_str() != Some(tb.as_str()) {
|
||||
return Err(format!("time_base {} vs {tb}", st["time_base"]));
|
||||
}
|
||||
match t.kind {
|
||||
TrackKind::Video => {
|
||||
let v = t.video().ok_or("no video params")?;
|
||||
if int(&st["width"]) != Some(v.width as i64) || int(&st["height"]) != Some(v.height as i64) {
|
||||
return Err(format!("size {}x{} vs {}x{}", st["width"], st["height"], v.width, v.height));
|
||||
}
|
||||
if let Some(n) = int(&st["nb_frames"])
|
||||
&& n != t.samples.len() as i64
|
||||
{
|
||||
return Err(format!("nb_frames {n} vs {}", t.samples.len()));
|
||||
}
|
||||
if let CodecConfig::Avc(a) = codec {
|
||||
if int(&st["level"]) != Some(a.level as i64) {
|
||||
return Err(format!("level {} vs {}", st["level"], a.level));
|
||||
}
|
||||
if st["nal_length_size"].as_str() != Some(&a.length_size.to_string()) {
|
||||
return Err("nal_length_size".into());
|
||||
}
|
||||
}
|
||||
if let Some(cp) = st["color_primaries"].as_str()
|
||||
&& cp == "bt709"
|
||||
{
|
||||
match &v.color {
|
||||
Some(filmcraft_isobmff::ColorInfo::Nclx { primaries: 1, .. }) | Some(filmcraft_isobmff::ColorInfo::Nclc { primaries: 1, .. }) => {}
|
||||
other => return Err(format!("colr {other:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(sar) = st["sample_aspect_ratio"].as_str()
|
||||
&& sar != "1:1"
|
||||
&& sar != "0:1"
|
||||
{
|
||||
let (h, vv) = v.pixel_aspect.ok_or("no pasp")?;
|
||||
let want = format!("{}:{}", h / gcd(h, vv), vv / gcd(h, vv));
|
||||
if sar != want {
|
||||
return Err(format!("sar {sar} vs {want}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackKind::Audio => {
|
||||
let a = t.audio().ok_or("no audio params")?;
|
||||
let sr = int(&st["sample_rate"]).unwrap_or(0);
|
||||
let ch = int(&st["channels"]).unwrap_or(0);
|
||||
let (our_sr, our_ch) = match codec {
|
||||
CodecConfig::Pcm(p) => (p.sample_rate, p.channels),
|
||||
CodecConfig::Flac(f) => (f.sample_rate as f64, f.channels as u32),
|
||||
CodecConfig::Opus(o) => (48000.0, o.output_channels as u32),
|
||||
_ => (a.sample_rate, a.channels),
|
||||
};
|
||||
if sr != our_sr.round() as i64 || ch != our_ch as i64 {
|
||||
return Err(format!("audio {sr}/{ch} vs {our_sr}/{our_ch}"));
|
||||
}
|
||||
if let CodecConfig::Aac(c) = codec
|
||||
&& c.object_type != 2
|
||||
{
|
||||
return Err(format!("aac object type {}", c.object_type));
|
||||
}
|
||||
}
|
||||
TrackKind::Timecode => {
|
||||
let CodecConfig::Timecode(tc) = codec else { return Err("tmcd codec".into()) };
|
||||
let want = st["tags"]["timecode"].as_str().ok_or("no timecode tag")?;
|
||||
let got = tc.format_frame(tc.start_frame.ok_or("no start frame")?);
|
||||
if got != want {
|
||||
return Err(format!("timecode {got} vs {want}"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gcd(a: u32, b: u32) -> u32 {
|
||||
if b == 0 { a.max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
|
||||
/// `strict`: ffprobe timestamps must equal ours + edit offset. Otherwise (edit starting mid-frame,
|
||||
/// which ffmpeg snaps to a frame boundary) only a constant shift within one frame is required.
|
||||
fn check_packets(file: &Mp4File, idx: usize, pk: &[&Value], strict: bool) -> Result<(), String> {
|
||||
let t = &file.tracks[idx];
|
||||
if t.pcm_chunked {
|
||||
// ffmpeg re-packetizes raw PCM; compare totals.
|
||||
let ours: u64 = t.samples.iter().map(|s| s.size as u64).sum();
|
||||
let theirs: i64 = pk.iter().filter_map(|p| int(&p["size"])).sum();
|
||||
let od: u64 = t.samples.iter().map(|s| s.duration as u64).sum();
|
||||
let td: i64 = pk.iter().filter_map(|p| int(&p["duration"])).sum();
|
||||
if ours as i64 != theirs || od as i64 != td {
|
||||
return Err(format!("pcm totals: bytes {ours} vs {theirs}, dur {od} vs {td}"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if pk.len() != t.samples.len() {
|
||||
return Err(format!("packet count {} vs {}", pk.len(), t.samples.len()));
|
||||
}
|
||||
let e = if strict || pk.is_empty() {
|
||||
t.edit_offset
|
||||
} else {
|
||||
let e = int(&pk[0]["pts"]).unwrap_or(0) - t.samples[0].pts;
|
||||
let max_dur = t.samples.iter().map(|s| s.duration as i64).max().unwrap_or(0);
|
||||
if (e - t.edit_offset).abs() >= max_dur {
|
||||
return Err(format!("shift {e} vs edit offset {}", t.edit_offset));
|
||||
}
|
||||
e
|
||||
};
|
||||
let last = pk.len() - 1;
|
||||
for (i, (p, s)) in pk.iter().zip(&t.samples).enumerate() {
|
||||
let size = int(&p["size"]);
|
||||
let pos = int(&p["pos"]);
|
||||
let pts = int(&p["pts"]);
|
||||
let dts = int(&p["dts"]);
|
||||
let key = p["flags"].as_str().unwrap_or("").starts_with('K');
|
||||
// ffmpeg extends the last packet's duration when it carries discard padding.
|
||||
let ok = size == Some(s.size as i64)
|
||||
&& pos == Some(s.offset as i64)
|
||||
&& pts == Some(s.pts + e)
|
||||
&& dts == Some(s.dts + e)
|
||||
&& key == s.is_sync
|
||||
&& (i == last || int(&p["duration"]).is_none_or(|d| d == s.duration as i64));
|
||||
if !ok {
|
||||
return Err(format!("packet {i}: ffprobe {p} vs ours {s:?} (edit_offset {e})"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_fixture(name: &str) {
|
||||
let Some(path) = fixture(name) else {
|
||||
eprintln!("skipping {name}: ffmpeg or encoder unavailable");
|
||||
return;
|
||||
};
|
||||
if ffprobe().is_none() {
|
||||
return;
|
||||
}
|
||||
let f = std::fs::File::open(&path).unwrap();
|
||||
let file = open(&f).unwrap_or_else(|e| panic!("{name}: open failed: {e}"));
|
||||
let streams = streams(&path);
|
||||
assert_eq!(streams.len(), file.tracks.len(), "{name}: stream count");
|
||||
let packets = packets(&path);
|
||||
let mut errors = Vec::new();
|
||||
for (i, st) in streams.iter().enumerate() {
|
||||
if let Err(e) = check_stream(&file, i, st) {
|
||||
errors.push(format!("stream {i}: {e}"));
|
||||
}
|
||||
if matches!(file.tracks[i].kind, TrackKind::Video | TrackKind::Audio) {
|
||||
let pk: Vec<&Value> = packets.iter().filter(|p| int(&p["stream_index"]) == Some(i as i64)).collect();
|
||||
if let Err(e) = check_packets(&file, i, &pk, name != "editlist_cut.mp4") {
|
||||
errors.push(format!("stream {i}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every sample must be readable.
|
||||
for (ti, t) in file.tracks.iter().enumerate() {
|
||||
for si in [0, t.samples.len() / 2, t.samples.len().saturating_sub(1)] {
|
||||
if si < t.samples.len() {
|
||||
let d = file.read_sample(&f, ti, si).unwrap();
|
||||
assert_eq!(d.len(), t.samples[si].size as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(errors.is_empty(), "{name}:\n{}", errors.join("\n"));
|
||||
eprintln!("{name}: ok ({} tracks)", file.tracks.len());
|
||||
}
|
||||
|
||||
macro_rules! fixture_tests {
|
||||
($($fn:ident => $name:expr),* $(,)?) => {
|
||||
$(#[test] fn $fn() { check_fixture($name); })*
|
||||
};
|
||||
}
|
||||
|
||||
fixture_tests! {
|
||||
h264_bframes_mp4 => "h264_bframes.mp4",
|
||||
h264_mov => "h264.mov",
|
||||
hevc_mp4 => "hevc.mp4",
|
||||
prores_mov => "prores.mov",
|
||||
prores4444_mov => "prores4444.mov",
|
||||
mjpeg_mov => "mjpeg.mov",
|
||||
dnxhd_mov => "dnxhd.mov",
|
||||
pcm_f32_mov => "pcm_f32.mov",
|
||||
pcm_s16be_mov => "pcm_s16be.mov",
|
||||
aac_mp4 => "aac.mp4",
|
||||
aac_m4a => "aac_stereo44.m4a",
|
||||
frag_mp4 => "frag.mp4",
|
||||
frag_dash_mp4 => "frag_dash.mp4",
|
||||
faststart_mp4 => "faststart.mp4",
|
||||
tmcd_2997df_mov => "tmcd_2997df.mov",
|
||||
tmcd_23976_mov => "tmcd_23976.mov",
|
||||
h264_2997_mp4 => "h264_2997.mp4",
|
||||
flac_mp4 => "flac.mp4",
|
||||
alac_m4a => "alac.m4a",
|
||||
opus_mp4 => "opus.mp4",
|
||||
ac3_mp4 => "ac3.mp4",
|
||||
vp9_mp4 => "vp9.mp4",
|
||||
av1_mp4 => "av1.mp4",
|
||||
colr_pasp_mov => "colr_pasp.mov",
|
||||
editlist_cut_mp4 => "editlist_cut.mp4",
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specific_properties() {
|
||||
let Some(p) = fixture("h264_bframes.mp4") else { return };
|
||||
let data = std::fs::read(&p).unwrap();
|
||||
let f = open(data.as_slice()).unwrap();
|
||||
assert!(!f.is_quicktime);
|
||||
let v = &f.tracks[f.track_of_kind(TrackKind::Video).unwrap()];
|
||||
// B-frames → non-trivial composition offsets.
|
||||
assert!(v.samples.iter().any(|s| s.pts != s.dts));
|
||||
assert!(v.samples.iter().filter(|s| s.is_sync).count() >= 4);
|
||||
let i = v.sample_at_pts(v.samples[7].pts).unwrap();
|
||||
assert_eq!(i, 7);
|
||||
assert_eq!(v.sync_sample_before(13), 12);
|
||||
let a = &f.tracks[f.track_of_kind(TrackKind::Audio).unwrap()];
|
||||
assert_eq!(a.edits.len(), 1);
|
||||
assert_eq!(a.edit_offset, -1024);
|
||||
|
||||
let Some(p) = fixture("tmcd_2997df.mov") else { return };
|
||||
let f = open(std::fs::read(&p).unwrap()).unwrap();
|
||||
assert!(f.is_quicktime);
|
||||
let t = &f.tracks[f.track_of_kind(TrackKind::Timecode).unwrap()];
|
||||
let CodecConfig::Timecode(tc) = t.codec().unwrap() else { panic!() };
|
||||
assert!(tc.drop_frame());
|
||||
assert_eq!(tc.frames_per_second, 30);
|
||||
assert_eq!(tc.start_frame, Some(107892));
|
||||
let v = &f.tracks[f.track_of_kind(TrackKind::Video).unwrap()];
|
||||
assert!(v.references.iter().any(|(k, ids)| k.to_string() == "tmcd" && ids == &vec![t.id]));
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
//! Muxer oracle tests: remux ffmpeg-made files with our writers and validate with ffmpeg/ffprobe.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_isobmff::*;
|
||||
use serde_json::Value;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// All samples of all tracks in decode order interleaved by decode time: (track, sample index).
|
||||
fn interleave(f: &Mp4File, tracks: &[usize]) -> Vec<(usize, usize)> {
|
||||
let mut v: Vec<(usize, usize)> = tracks.iter().flat_map(|&t| (0..f.tracks[t].samples.len()).map(move |i| (t, i))).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let ta = &f.tracks[a.0];
|
||||
let tb = &f.tracks[b.0];
|
||||
let x = ta.samples[a.1].dts as i128 * tb.timescale as i128;
|
||||
let y = tb.samples[b.1].dts as i128 * ta.timescale as i128;
|
||||
x.cmp(&y).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1))
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn track_config(f: &Mp4File, t: &Track, copy_edits: bool) -> TrackConfig {
|
||||
let mut cfg = TrackConfig::new(t.entries[0].clone(), t.timescale);
|
||||
cfg.language = t.language.clone();
|
||||
if copy_edits {
|
||||
cfg.edits = t.edits.clone();
|
||||
}
|
||||
let _ = f;
|
||||
cfg
|
||||
}
|
||||
|
||||
enum Finish {
|
||||
Normal,
|
||||
Faststart,
|
||||
}
|
||||
|
||||
fn remux(input: &Path, out_name: &str, brand: Brand, finish: Finish, timecode: Option<(TimecodeConfig, u32)>) -> Option<(Mp4File, PathBuf)> {
|
||||
let data = std::fs::read(input).ok()?;
|
||||
let f = open(data.as_slice()).unwrap();
|
||||
let mut opts = WriterOptions::new(brand);
|
||||
opts.movie_timescale = f.timescale;
|
||||
opts.metadata.push(("©nam".into(), "FilmCraft test".into()));
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), opts).unwrap();
|
||||
let av: Vec<usize> = (0..f.tracks.len()).filter(|&i| matches!(f.tracks[i].kind, TrackKind::Video | TrackKind::Audio)).collect();
|
||||
let mut map = vec![usize::MAX; f.tracks.len()];
|
||||
for &i in &av {
|
||||
map[i] = w.add_track(track_config(&f, &f.tracks[i], true)).unwrap();
|
||||
}
|
||||
if let Some((tc, start)) = timecode {
|
||||
let v = av.iter().copied().find(|&i| f.tracks[i].kind == TrackKind::Video).unwrap();
|
||||
w.add_timecode_track(map[v], tc, start).unwrap();
|
||||
}
|
||||
for (t, i) in interleave(&f, &av) {
|
||||
let s = f.tracks[t].samples[i];
|
||||
let bytes = f.read_sample(data.as_slice(), t, i).unwrap();
|
||||
w.write_sample(map[t], WriteSample { data: &bytes, duration: s.duration, composition_offset: (s.pts - s.dts) as i32, is_sync: s.is_sync }).unwrap();
|
||||
}
|
||||
let out = match finish {
|
||||
Finish::Normal => w.finish(),
|
||||
Finish::Faststart => w.finish_faststart(),
|
||||
}
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
let path = out_dir().join(out_name);
|
||||
std::fs::write(&path, &out).unwrap();
|
||||
// Our own reader must see identical sample tables and bytes.
|
||||
let g = open(out.as_slice()).unwrap();
|
||||
for (k, &i) in av.iter().enumerate() {
|
||||
let (a, b) = (&f.tracks[i], &g.tracks[k]);
|
||||
assert_eq!(a.samples.len(), b.samples.len(), "{out_name}: sample count");
|
||||
assert_eq!(a.edit_offset, b.edit_offset, "{out_name}: edit offset");
|
||||
assert_eq!(a.codec(), b.codec(), "{out_name}: codec");
|
||||
for (j, (x, y)) in a.samples.iter().zip(&b.samples).enumerate() {
|
||||
assert_eq!((x.size, x.dts, x.pts, x.duration, x.is_sync), (y.size, y.dts, y.pts, y.duration, y.is_sync), "{out_name}: sample {j}");
|
||||
}
|
||||
for j in 0..a.samples.len() {
|
||||
assert_eq!(f.read_sample(data.as_slice(), i, j).unwrap(), g.read_sample(out.as_slice(), k, j).unwrap());
|
||||
}
|
||||
}
|
||||
assert_eq!(g.metadata.title(), Some("FilmCraft test"));
|
||||
Some((f, path))
|
||||
}
|
||||
|
||||
fn stream_packets(path: &Path) -> Vec<Vec<Value>> {
|
||||
let pk = packets(path);
|
||||
let n = pk.iter().filter_map(|p| int(&p["stream_index"])).max().map(|m| m + 1).unwrap_or(0);
|
||||
(0..n).map(|i| pk.iter().filter(|p| int(&p["stream_index"]) == Some(i)).cloned().collect()).collect()
|
||||
}
|
||||
|
||||
/// Compare ffprobe's view of two files (per-stream packets: pts, dts, size, key flag).
|
||||
fn assert_same_packets(a: &Path, b: &Path) {
|
||||
let pa = stream_packets(a);
|
||||
let pb = stream_packets(b);
|
||||
for (i, (x, y)) in pa.iter().zip(&pb).enumerate() {
|
||||
let key = |p: &Value| (int(&p["pts"]), int(&p["dts"]), int(&p["size"]), p["flags"].as_str().map(|f| f.starts_with('K')));
|
||||
let kx: Vec<_> = x.iter().map(key).collect();
|
||||
let ky: Vec<_> = y.iter().map(key).collect();
|
||||
assert_eq!(kx.len(), ky.len(), "stream {i}: packet count {} vs {}", a.display(), b.display());
|
||||
for (j, (p, q)) in kx.iter().zip(&ky).enumerate() {
|
||||
assert_eq!(p, q, "stream {i} packet {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_same_streams(a: &Path, b: &Path) {
|
||||
let sa = streams(a);
|
||||
let sb = streams(b);
|
||||
for (x, y) in sa.iter().zip(&sb) {
|
||||
if x["codec_type"] == "data" {
|
||||
continue;
|
||||
}
|
||||
for k in ["codec_name", "width", "height", "sample_rate", "channels", "nb_frames", "duration_ts", "time_base", "profile", "pix_fmt"] {
|
||||
assert_eq!(x[k], y[k], "stream field {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_clean(path: &Path) {
|
||||
let err = ffmpeg_decode_errors(path).unwrap();
|
||||
assert!(err.trim().is_empty(), "ffmpeg reported errors for {}: {err}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remux_h264_aac_mp4_variants() {
|
||||
let (Some(src), Some(_)) = (fixture("h264_bframes.mp4"), ffprobe()) else { return };
|
||||
for (name, brand, finish) in [
|
||||
("remux_h264_aac.mp4", Brand::Mp4, Finish::Normal),
|
||||
("remux_h264_aac_fast.mp4", Brand::Mp4, Finish::Faststart),
|
||||
("remux_h264_aac.mov", Brand::Mov, Finish::Normal),
|
||||
] {
|
||||
let (_, out) = remux(&src, name, brand, finish, None).unwrap();
|
||||
decode_clean(&out);
|
||||
assert_same_packets(&src, &out);
|
||||
assert_same_streams(&src, &out);
|
||||
eprintln!("{name}: ok");
|
||||
}
|
||||
// Faststart layout: moov before mdat.
|
||||
let d = std::fs::read(out_dir().join("remux_h264_aac_fast.mp4")).unwrap();
|
||||
let moov = d.windows(4).position(|w| w == b"moov").unwrap();
|
||||
let mdat = d.windows(4).position(|w| w == b"mdat").unwrap();
|
||||
assert!(moov < mdat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remux_other_codecs() {
|
||||
if ffprobe().is_none() {
|
||||
return;
|
||||
}
|
||||
for (src, name, brand) in [
|
||||
("hevc.mp4", "remux_hevc.mp4", Brand::Mp4),
|
||||
("h264.mov", "remux_h264_pcm.mov", Brand::Mov),
|
||||
("mjpeg.mov", "remux_mjpeg.mov", Brand::Mov),
|
||||
("pcm_f32.mov", "remux_pcm_f32.mov", Brand::Mov),
|
||||
("pcm_s16be.mov", "remux_pcm_s16be.mov", Brand::Mov),
|
||||
("aac_stereo44.m4a", "remux_aac.m4a", Brand::Mp4),
|
||||
("colr_pasp.mov", "remux_colr_pasp.mov", Brand::Mov),
|
||||
("editlist_cut.mp4", "remux_editlist.mp4", Brand::Mp4),
|
||||
("alac.m4a", "remux_alac.m4a", Brand::Mp4),
|
||||
("flac.mp4", "remux_flac.mp4", Brand::Mp4),
|
||||
("opus.mp4", "remux_opus.mp4", Brand::Mp4),
|
||||
("av1.mp4", "remux_av1.mp4", Brand::Mp4),
|
||||
("vp9.mp4", "remux_vp9.mp4", Brand::Mp4),
|
||||
("ac3.mp4", "remux_ac3.mp4", Brand::Mp4),
|
||||
("dnxhd.mov", "remux_dnxhd.mov", Brand::Mov),
|
||||
] {
|
||||
let Some(p) = fixture(src) else { continue };
|
||||
let (f, out) = remux(&p, name, brand, Finish::Faststart, None).unwrap();
|
||||
decode_clean(&out);
|
||||
let pcm = f.tracks.iter().any(|t| t.pcm_chunked);
|
||||
if !pcm {
|
||||
assert_same_packets(&p, &out);
|
||||
}
|
||||
assert_same_streams(&p, &out);
|
||||
eprintln!("{name}: ok");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remux_prores_pcm24_with_timecode() {
|
||||
let (Some(src), Some(_)) = (fixture("prores.mov"), ffprobe()) else { return };
|
||||
let tc = TimecodeConfig { flags: 0, timescale: 25, frame_duration: 1, frames_per_second: 25, start_frame: None, name: None };
|
||||
let (f, out) = remux(&src, "remux_prores_tc.mov", Brand::Mov, Finish::Normal, Some((tc, 25 * 3600))).unwrap();
|
||||
decode_clean(&out);
|
||||
assert_same_streams(&src, &out);
|
||||
let st = streams(&out);
|
||||
assert!(st.iter().any(|s| s["tags"]["timecode"] == "01:00:00:00"), "timecode missing: {st:?}");
|
||||
// PCM byte totals survive ffmpeg's repacketisation.
|
||||
let g = open(std::fs::read(&out).unwrap()).unwrap();
|
||||
let a = f.track_of_kind(TrackKind::Audio).unwrap();
|
||||
let sum = |m: &Mp4File, i: usize| m.tracks[i].samples.iter().map(|s| s.size as u64).sum::<u64>();
|
||||
assert_eq!(sum(&f, a), sum(&g, g.track_of_kind(TrackKind::Audio).unwrap()));
|
||||
let t = &g.tracks[g.track_of_kind(TrackKind::Timecode).unwrap()];
|
||||
let CodecConfig::Timecode(tc) = t.codec().unwrap() else { panic!() };
|
||||
assert_eq!(tc.start_frame, Some(90000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm_in_mp4_ipcm() {
|
||||
let (Some(src), Some(_)) = (fixture("pcm_s16be.mov"), ffprobe()) else { return };
|
||||
let (_, out) = remux(&src, "remux_ipcm.mp4", Brand::Mp4, Finish::Normal, None).unwrap();
|
||||
let st = streams(&out);
|
||||
assert_eq!(st[0]["codec_tag_string"], "ipcm");
|
||||
assert_eq!(st[0]["codec_name"], "pcm_s16be");
|
||||
decode_clean(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aac_priming_edit() {
|
||||
let (Some(src), Some(_)) = (fixture("aac.mp4"), ffprobe()) else { return };
|
||||
let data = std::fs::read(&src).unwrap();
|
||||
let f = open(data.as_slice()).unwrap();
|
||||
let t = &f.tracks[0];
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let mut cfg = TrackConfig::new(t.entries[0].clone(), t.timescale);
|
||||
cfg.media_start = Some(1024);
|
||||
let k = w.add_track(cfg).unwrap();
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
let d = f.read_sample(data.as_slice(), 0, i).unwrap();
|
||||
w.write_sample(k, WriteSample { data: &d, duration: s.duration, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let out = out_dir().join("aac_priming.mp4");
|
||||
std::fs::write(&out, w.finish().unwrap().into_inner()).unwrap();
|
||||
decode_clean(&out);
|
||||
let pk = packets(&out);
|
||||
assert_eq!(int(&pk[0]["pts"]), Some(-1024));
|
||||
assert_eq!(int(&streams(&out)[0]["duration_ts"]), int(&streams(&src)[0]["duration_ts"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_writer_ffmpeg() {
|
||||
let (Some(src), Some(_)) = (fixture("h264_bframes.mp4"), ffprobe()) else { return };
|
||||
let data = std::fs::read(&src).unwrap();
|
||||
let f = open(data.as_slice()).unwrap();
|
||||
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
||||
let tracks: Vec<usize> = (0..f.tracks.len()).collect();
|
||||
for t in &f.tracks {
|
||||
w.add_track(TrackConfig::new(t.entries[0].clone(), t.timescale)).unwrap();
|
||||
}
|
||||
let v = f.track_of_kind(TrackKind::Video).unwrap();
|
||||
for (t, i) in interleave(&f, &tracks) {
|
||||
let s = f.tracks[t].samples[i];
|
||||
if t == v && s.is_sync && i > 0 {
|
||||
w.flush_fragment().unwrap();
|
||||
}
|
||||
let d = f.read_sample(data.as_slice(), t, i).unwrap();
|
||||
w.write_sample(t, WriteSample { data: &d, duration: s.duration, composition_offset: (s.pts - s.dts) as i32, is_sync: s.is_sync }).unwrap();
|
||||
}
|
||||
let bytes = w.finish().unwrap();
|
||||
let out = out_dir().join("our_frag.mp4");
|
||||
std::fs::write(&out, &bytes).unwrap();
|
||||
decode_clean(&out);
|
||||
let g = open(bytes.as_slice()).unwrap();
|
||||
assert!(g.fragmented && g.fragment_count >= 4);
|
||||
// ffprobe sees raw timestamps (no edit list in our init segment).
|
||||
let ours = stream_packets(&out);
|
||||
for (ti, t) in f.tracks.iter().enumerate() {
|
||||
assert_eq!(ours[ti].len(), t.samples.len());
|
||||
for (p, s) in ours[ti].iter().zip(&t.samples) {
|
||||
assert_eq!(int(&p["pts"]), Some(s.pts));
|
||||
assert_eq!(int(&p["dts"]), Some(s.dts));
|
||||
assert_eq!(int(&p["size"]), Some(s.size as i64));
|
||||
}
|
||||
for i in 0..t.samples.len() {
|
||||
assert_eq!(f.read_sample(data.as_slice(), ti, i).unwrap(), g.read_sample(bytes.as_slice(), ti, i).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
//! Property tests: write with our muxers, read back, compare sample tables and bytes.
|
||||
|
||||
use filmcraft_isobmff::*;
|
||||
use proptest::prelude::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct S {
|
||||
size: usize,
|
||||
duration: u32,
|
||||
cto: i32,
|
||||
sync: bool,
|
||||
}
|
||||
|
||||
fn sample_strategy() -> impl Strategy<Value = S> {
|
||||
(0usize..300, 1u32..3000, -200i32..2000, any::<bool>()).prop_map(|(size, duration, cto, sync)| S { size, duration, cto, sync })
|
||||
}
|
||||
|
||||
fn payload(track: usize, i: usize, n: usize) -> Vec<u8> {
|
||||
(0..n).map(|k| (track * 31 + i * 7 + k) as u8).collect()
|
||||
}
|
||||
|
||||
fn entry(kind: u8) -> SampleEntry {
|
||||
match kind {
|
||||
0 => SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x64, 0, 0x28, 1, 2]], vec![vec![0x68, 3]], 4), 320, 240),
|
||||
1 => SampleEntry::prores(FourCc(*b"apcn"), 1920, 1080),
|
||||
2 => SampleEntry::aac(vec![0x11, 0x90], 2, 48000),
|
||||
_ => SampleEntry::jpeg(64, 64),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||
|
||||
#[test]
|
||||
fn progressive_roundtrip(
|
||||
tracks in prop::collection::vec((0u8..4, prop::collection::vec(sample_strategy(), 0..40)), 1..4),
|
||||
order in prop::collection::vec(0usize..4, 0..200),
|
||||
mov in any::<bool>(),
|
||||
fast in any::<bool>(),
|
||||
with_pcm in any::<bool>(),
|
||||
start in prop::option::of(0i64..500),
|
||||
) {
|
||||
let brand = if mov { Brand::Mov } else { Brand::Mp4 };
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(brand)).unwrap();
|
||||
for (k, (kind, _)) in tracks.iter().enumerate() {
|
||||
let mut cfg = TrackConfig::new(entry(*kind), 1000 + k as u32 * 11);
|
||||
cfg.media_start = start;
|
||||
w.add_track(cfg).unwrap();
|
||||
}
|
||||
let pcm_track = if with_pcm {
|
||||
let p = PcmConfig { bits: 24, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 };
|
||||
Some(w.add_track(TrackConfig::new(SampleEntry::pcm(p), 48000)).unwrap())
|
||||
} else { None };
|
||||
// Interleave: follow `order`, then flush the rest.
|
||||
let mut next = vec![0usize; tracks.len()];
|
||||
let mut pcm_chunks = Vec::new();
|
||||
let emit = |w: &mut Mp4Writer<Cursor<Vec<u8>>>, t: usize, next: &mut Vec<usize>| {
|
||||
let i = next[t];
|
||||
let s = &tracks[t].1[i];
|
||||
let d = payload(t, i, s.size);
|
||||
w.write_sample(t, WriteSample { data: &d, duration: s.duration, composition_offset: s.cto, is_sync: s.sync }).unwrap();
|
||||
next[t] += 1;
|
||||
};
|
||||
for (step, &o) in order.iter().enumerate() {
|
||||
if let Some(p) = pcm_track && step % 5 == 0 {
|
||||
let d = payload(9, step, 6 * (1 + step % 7));
|
||||
w.write_sample(p, WriteSample { data: &d, duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
||||
pcm_chunks.push(d);
|
||||
}
|
||||
let t = o % tracks.len();
|
||||
if next[t] < tracks[t].1.len() {
|
||||
emit(&mut w, t, &mut next);
|
||||
}
|
||||
}
|
||||
for t in 0..tracks.len() {
|
||||
while next[t] < tracks[t].1.len() {
|
||||
emit(&mut w, t, &mut next);
|
||||
}
|
||||
}
|
||||
let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner();
|
||||
let f = open(out.as_slice()).unwrap();
|
||||
prop_assert_eq!(f.is_quicktime, mov);
|
||||
prop_assert_eq!(f.tracks.len(), tracks.len() + pcm_track.is_some() as usize);
|
||||
for (k, (kind, samples)) in tracks.iter().enumerate() {
|
||||
let t = &f.tracks[k];
|
||||
prop_assert_eq!(t.codec(), Some(&entry(*kind).codec));
|
||||
prop_assert_eq!(t.samples.len(), samples.len());
|
||||
let mut dts = 0i64;
|
||||
for (i, (s, r)) in samples.iter().zip(&t.samples).enumerate() {
|
||||
prop_assert_eq!(r.size as usize, s.size);
|
||||
prop_assert_eq!(r.duration, s.duration);
|
||||
prop_assert_eq!(r.dts, dts);
|
||||
prop_assert_eq!(r.pts, dts + s.cto as i64);
|
||||
prop_assert_eq!(r.is_sync, s.sync);
|
||||
prop_assert_eq!(f.read_sample(out.as_slice(), k, i).unwrap(), payload(k, i, s.size));
|
||||
dts += s.duration as i64;
|
||||
}
|
||||
prop_assert_eq!(t.duration, dts as u64);
|
||||
match start {
|
||||
Some(st) if !samples.is_empty() || st == 0 => prop_assert_eq!(t.edit_offset, -st),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(p) = pcm_track {
|
||||
let t = &f.tracks[p];
|
||||
let got: Vec<u8> = (0..t.samples.len()).flat_map(|i| f.read_sample(out.as_slice(), p, i).unwrap()).collect();
|
||||
prop_assert_eq!(got, pcm_chunks.concat());
|
||||
let frames: u64 = t.samples.iter().map(|s| s.duration as u64).sum();
|
||||
prop_assert_eq!(frames, pcm_chunks.iter().map(|c| c.len() as u64 / 6).sum::<u64>());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_roundtrip(
|
||||
samples in prop::collection::vec(sample_strategy(), 1..60),
|
||||
cuts in prop::collection::vec(any::<bool>(), 60),
|
||||
) {
|
||||
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
||||
let v = w.add_track(TrackConfig::new(entry(0), 90000)).unwrap();
|
||||
let a = w.add_track(TrackConfig::new(entry(2), 48000)).unwrap();
|
||||
for (i, s) in samples.iter().enumerate() {
|
||||
w.write_sample(v, WriteSample { data: &payload(0, i, s.size), duration: s.duration, composition_offset: s.cto, is_sync: s.sync }).unwrap();
|
||||
w.write_sample(a, WriteSample { data: &payload(1, i, s.size / 2), duration: 1024, composition_offset: 0, is_sync: true }).unwrap();
|
||||
if cuts[i] { w.flush_fragment().unwrap(); }
|
||||
}
|
||||
let out = w.finish().unwrap();
|
||||
let f = open(out.as_slice()).unwrap();
|
||||
prop_assert!(f.fragmented);
|
||||
let mut dts = 0i64;
|
||||
for (i, s) in samples.iter().enumerate() {
|
||||
let r = f.tracks[0].samples[i];
|
||||
prop_assert_eq!((r.size as usize, r.dts, r.pts, r.is_sync), (s.size, dts, dts + s.cto as i64, s.sync));
|
||||
prop_assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), payload(0, i, s.size));
|
||||
prop_assert_eq!(f.read_sample(out.as_slice(), 1, i).unwrap(), payload(1, i, s.size / 2));
|
||||
prop_assert_eq!(f.tracks[1].samples[i].dts, 1024 * i as i64);
|
||||
dts += s.duration as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_lookup() {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(entry(0), 25)).unwrap();
|
||||
// I P B B pattern: decode order I0 P3 B1 B2 | I4 P7 B5 B6
|
||||
let cto = [1, 3, 0, 0, 1, 3, 0, 0];
|
||||
for (i, c) in cto.iter().enumerate() {
|
||||
w.write_sample(t, WriteSample { data: &[i as u8], duration: 1, composition_offset: *c, is_sync: i % 4 == 0 }).unwrap();
|
||||
}
|
||||
let out = w.finish().unwrap().into_inner();
|
||||
let f = open(out.as_slice()).unwrap();
|
||||
let tr = &f.tracks[0];
|
||||
// pts: 1,4,2,3,5,8,6,7
|
||||
assert_eq!(tr.sample_at_pts(0), Some(0));
|
||||
assert_eq!(tr.sample_at_pts(2), Some(2));
|
||||
assert_eq!(tr.sample_at_pts(4), Some(1));
|
||||
assert_eq!(tr.sample_at_pts(7), Some(7));
|
||||
assert_eq!(tr.sample_at_pts(100), Some(5));
|
||||
assert_eq!(tr.sync_sample_before(3), 0);
|
||||
assert_eq!(tr.sync_sample_before(4), 4);
|
||||
assert_eq!(tr.sync_sample_before(7), 4);
|
||||
assert_eq!(tr.sync_samples(), &[0, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_offsets_use_co64_and_largesize_mdat() {
|
||||
// Files > 4 GiB are impractical in tests, so patch a small file into the same layout the
|
||||
// writer produces for them: co64 chunk offsets and a 16-byte large-size mdat header.
|
||||
let mut moov = Vec::new();
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let t = w.add_track(TrackConfig::new(entry(3), 30)).unwrap();
|
||||
w.write_sample(t, WriteSample { data: b"abc", duration: 1, composition_offset: 0, is_sync: true }).unwrap();
|
||||
let out = w.finish().unwrap().into_inner();
|
||||
// Replace stco with co64 (same entry count, 64-bit offsets) and rebuild.
|
||||
let f = open(out.as_slice()).unwrap();
|
||||
let off = f.tracks[0].samples[0].offset;
|
||||
let stco = out.windows(4).position(|w| w == b"stco").unwrap() - 4;
|
||||
let moov_at = out.windows(4).position(|w| w == b"moov").unwrap() - 4;
|
||||
moov.extend_from_slice(&out[moov_at..stco]);
|
||||
moov.extend_from_slice(&24u32.to_be_bytes());
|
||||
moov.extend_from_slice(b"co64");
|
||||
moov.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
moov.extend_from_slice(&off.to_be_bytes());
|
||||
// Fix up sizes of moov/trak/mdia/minf/stbl (+4 bytes each).
|
||||
for name in [b"moov", b"trak", b"mdia", b"minf", b"stbl"] {
|
||||
let p = moov.windows(4).position(|w| w == name).unwrap() - 4;
|
||||
let s = u32::from_be_bytes(moov[p..p + 4].try_into().unwrap()) + 4;
|
||||
moov[p..p + 4].copy_from_slice(&s.to_be_bytes());
|
||||
}
|
||||
let mut file = out[..moov_at].to_vec();
|
||||
file.extend_from_slice(&moov);
|
||||
// ftyp is 32 bytes for MP4; then `free` (8) + `mdat` (8) → one large-size mdat header.
|
||||
let ftyp_len = u32::from_be_bytes(file[0..4].try_into().unwrap()) as usize;
|
||||
assert_eq!(&file[ftyp_len + 4..ftyp_len + 8], b"free");
|
||||
let mdat_len = (moov_at - ftyp_len) as u64;
|
||||
file[ftyp_len..ftyp_len + 4].copy_from_slice(&1u32.to_be_bytes());
|
||||
file[ftyp_len + 4..ftyp_len + 8].copy_from_slice(b"mdat");
|
||||
file[ftyp_len + 8..ftyp_len + 16].copy_from_slice(&mdat_len.to_be_bytes());
|
||||
let g = open(file.as_slice()).unwrap();
|
||||
assert_eq!(g.read_sample(file.as_slice(), 0, 0).unwrap(), b"abc");
|
||||
// size == 0 ("extends to end of file") on a trailing box.
|
||||
let mut z = file.clone();
|
||||
z.extend_from_slice(&[0, 0, 0, 0, b'f', b'r', b'e', b'e', 1, 2, 3]);
|
||||
assert!(open(z.as_slice()).is_ok());
|
||||
}
|
||||
Loading…
Reference in New Issue