oxideav-mp4 0.0.9

Pure-Rust MP4 / ISO base media file container for oxideav
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! Build MP4 `stsd` sample-entry payloads for specific codecs.
//!
//! This is the *only* place in the muxer where codec knowledge is encoded.
//! All other muxer code is codec-agnostic — it just appends opaque packet bytes.
//!
//! Each `sample_entry_for` returns:
//! - `fourcc`: the 4-byte sample entry type (e.g. `b"sowt"`, `b"mp4a"`, `b"fLaC"`,
//!   `b"avc1"`).
//! - `body`: the contents of the sample entry box (i.e. everything after the
//!   8-byte box header). For audio entries this begins with the 28-byte
//!   `AudioSampleEntryV0` preamble; for video entries it begins with the
//!   78-byte `VisualSampleEntry` preamble. Codec-specific subboxes
//!   (`dfLa`, `esds`, `avcC`, …) follow.
//!
//! References: ISO/IEC 14496-12 §8.5, ISO/IEC 14496-14, ISO/IEC 23003-5
//! (FLAC-in-ISOBMFF).

use oxideav_core::{CodecParameters, Error, MediaType, Result};

/// A complete sample-entry description.
pub(crate) struct SampleEntry {
    /// Sample-entry FourCC (the box type that goes inside `stsd`).
    pub fourcc: [u8; 4],
    /// Payload of the sample-entry box (everything after the 8-byte box header).
    pub body: Vec<u8>,
}

/// Build the sample entry for a stream. Errors with `Unsupported` if the codec
/// has no MP4 packaging in our table.
pub(crate) fn sample_entry_for(params: &CodecParameters) -> Result<SampleEntry> {
    match params.codec_id.as_str() {
        "pcm_s16le" => pcm_sowt(params),
        "flac" => flac_entry(params),
        "aac" => aac_entry(params),
        "h264" => h264_entry(params),
        "mjpeg" => mjpeg_entry(params),
        // Subtitle / timed-text packagings (ISO/IEC 14496-12 §12.5–6
        // + 3GPP TS 26.245 for tx3g/mov_text). All five accept the
        // demuxer's `extradata` (the post-preamble body) verbatim,
        // so a demux → mux round-trip preserves the inner config /
        // namespace / mime declarations.
        "mov_text" => subtitle_entry(params, *b"tx3g"),
        "webvtt" => subtitle_entry(params, *b"wvtt"),
        "ttml" => subtitle_entry(params, *b"stpp"),
        "sbtt" => subtitle_entry(params, *b"sbtt"),
        "stxt" => subtitle_entry(params, *b"stxt"),
        other => Err(Error::unsupported(format!(
            "mp4 muxer: no sample entry for codec {other}"
        ))),
    }
}

/// Pick the BMFF handler type four-char-code for a subtitle codec.
///
/// `tx3g` / `text` live under the QuickTime/BMFF `text` handler
/// (3GPP TS 26.245 + ISO/IEC 14496-12 §12.5.1). `wvtt` / `stpp` /
/// `sbtt` / `stxt` live under the BMFF `subt` handler (§12.6.1).
pub(crate) fn subtitle_handler_for(codec_id: &str) -> [u8; 4] {
    match codec_id {
        "mov_text" => *b"text",
        _ => *b"subt",
    }
}

/// Whether the subtitle codec's media-header box should be `sthd`
/// (BMFF §12.6.2 SubtitleMediaHeader, used by `subt` handler) rather
/// than `nmhd` (BMFF §12.5.2 null-media-header, used by `text` handler).
pub(crate) fn subtitle_uses_sthd(codec_id: &str) -> bool {
    !matches!(codec_id, "mov_text")
}

/// Build a subtitle sample entry. The 8-byte preamble (6 reserved +
/// `data_reference_index = 1`) is fixed; the body that follows comes
/// straight from `params.extradata`. For BMFF text/subtitle entries
/// the extradata is the post-preamble payload — see the demuxer's
/// `parse_subtitle_sample_entry` round-trip.
fn subtitle_entry(params: &CodecParameters, fourcc: [u8; 4]) -> Result<SampleEntry> {
    if params.media_type != MediaType::Subtitle {
        return Err(Error::invalid(format!(
            "mp4 muxer: subtitle codec {} must be Subtitle media",
            params.codec_id.as_str()
        )));
    }
    let mut body = Vec::with_capacity(8 + params.extradata.len());
    // 6 reserved bytes + 2-byte data_reference_index (= 1).
    body.extend_from_slice(&[0u8; 6]);
    body.extend_from_slice(&1u16.to_be_bytes());
    body.extend_from_slice(&params.extradata);
    Ok(SampleEntry { fourcc, body })
}

/// Motion JPEG sample entry. Modern ISOBMFF uses the `jpeg` FourCC with a
/// plain VisualSampleEntry; each sample is a self-contained JPEG byte
/// stream. (The legacy QuickTime `mjpa`/`mjpb` forms have extra quirks
/// we don't emit today.)
fn mjpeg_entry(params: &CodecParameters) -> Result<SampleEntry> {
    if params.media_type != MediaType::Video {
        return Err(Error::invalid("mp4 muxer: mjpeg must be video"));
    }
    let width = params
        .width
        .ok_or_else(|| Error::invalid("mp4 muxer: mjpeg requires width"))?;
    let height = params
        .height
        .ok_or_else(|| Error::invalid("mp4 muxer: mjpeg requires height"))?;
    let body = visual_preamble(width, height).to_vec();
    Ok(SampleEntry {
        fourcc: *b"jpeg",
        body,
    })
}

/// 28-byte AudioSampleEntryV0 preamble.
fn audio_preamble(channels: u16, sample_size: u16, sample_rate: u32) -> [u8; 28] {
    let mut out = [0u8; 28];
    // 6 bytes reserved
    // data_reference_index = 1
    out[6] = 0;
    out[7] = 1;
    // 8 bytes reserved (version/revision/vendor in QT-style, all zero in ISO)
    // channel_count at offset 16
    out[16..18].copy_from_slice(&channels.to_be_bytes());
    // sample_size at offset 18
    out[18..20].copy_from_slice(&sample_size.to_be_bytes());
    // 2 bytes pre_defined + 2 bytes reserved
    // sample_rate as 16.16 fixed-point at offset 24
    let sr_fixed = sample_rate << 16;
    out[24..28].copy_from_slice(&sr_fixed.to_be_bytes());
    out
}

/// 78-byte VisualSampleEntry preamble.
fn visual_preamble(width: u32, height: u32) -> [u8; 78] {
    let mut out = [0u8; 78];
    // 6 bytes reserved
    // data_reference_index = 1
    out[6] = 0;
    out[7] = 1;
    // 16 bytes pre_defined/reserved (offsets 8..24)
    // width at offset 24 (u16)
    let w = width as u16;
    let h = height as u16;
    out[24..26].copy_from_slice(&w.to_be_bytes());
    out[26..28].copy_from_slice(&h.to_be_bytes());
    // horizresolution 72 dpi as 16.16 at offset 28
    let dpi = 72u32 << 16;
    out[28..32].copy_from_slice(&dpi.to_be_bytes());
    // vertresolution 72 dpi as 16.16 at offset 32
    out[32..36].copy_from_slice(&dpi.to_be_bytes());
    // reserved u32 at offset 36
    // frame_count u16 = 1 at offset 40
    out[40..42].copy_from_slice(&1u16.to_be_bytes());
    // 32 bytes compressorname (length-prefixed Pascal string) at offset 42
    // depth u16 = 0x0018 at offset 74
    out[74..76].copy_from_slice(&0x0018u16.to_be_bytes());
    // pre_defined i16 = -1 at offset 76
    out[76..78].copy_from_slice(&(-1i16).to_be_bytes());
    out
}

fn pcm_sowt(params: &CodecParameters) -> Result<SampleEntry> {
    let channels = params
        .channels
        .ok_or_else(|| Error::invalid("mp4 muxer: PCM requires channels"))?;
    let sample_rate = params
        .sample_rate
        .ok_or_else(|| Error::invalid("mp4 muxer: PCM requires sample_rate"))?;
    // sowt is 16-bit signed little-endian PCM; hard-coded 16 bps.
    let body = audio_preamble(channels, 16, sample_rate).to_vec();
    Ok(SampleEntry {
        fourcc: *b"sowt",
        body,
    })
}

fn flac_entry(params: &CodecParameters) -> Result<SampleEntry> {
    if params.media_type != MediaType::Audio {
        return Err(Error::invalid("mp4 muxer: flac must be audio"));
    }
    let channels = params
        .channels
        .ok_or_else(|| Error::invalid("mp4 muxer: flac requires channels"))?;
    let sample_rate = params
        .sample_rate
        .ok_or_else(|| Error::invalid("mp4 muxer: flac requires sample_rate"))?;
    // Bits per sample: pick from sample_format; default to 16.
    let bps = params
        .sample_format
        .map(|f| (f.bytes_per_sample() * 8) as u16)
        .unwrap_or(16);
    let mut body = audio_preamble(channels, bps, sample_rate).to_vec();

    // dfLa subbox: FullBox (version 0 + 3 bytes flags) followed by the
    // FLAC metadata blocks. oxideav-flac extradata is already the concatenated
    // metadata blocks (each with 4-byte header + payload).
    if params.extradata.is_empty() {
        return Err(Error::invalid(
            "mp4 muxer: flac stream missing extradata (STREAMINFO)",
        ));
    }
    let mut dfla_body = Vec::with_capacity(4 + params.extradata.len());
    dfla_body.extend_from_slice(&[0, 0, 0, 0]); // version 0 + 3 bytes flags
    dfla_body.extend_from_slice(&params.extradata);
    body.extend_from_slice(&write_simple_box(b"dfLa", &dfla_body));

    Ok(SampleEntry {
        fourcc: *b"fLaC",
        body,
    })
}

fn aac_entry(params: &CodecParameters) -> Result<SampleEntry> {
    if params.media_type != MediaType::Audio {
        return Err(Error::invalid("mp4 muxer: aac must be audio"));
    }
    let channels = params
        .channels
        .ok_or_else(|| Error::invalid("mp4 muxer: aac requires channels"))?;
    let sample_rate = params
        .sample_rate
        .ok_or_else(|| Error::invalid("mp4 muxer: aac requires sample_rate"))?;
    if params.extradata.is_empty() {
        return Err(Error::invalid(
            "mp4 muxer: aac stream missing extradata (AudioSpecificConfig)",
        ));
    }
    let mut body = audio_preamble(channels, 16, sample_rate).to_vec();

    // esds box (full box): ES_Descriptor wrapping DecoderConfigDescriptor wrapping
    // DecoderSpecificInfo (the AudioSpecificConfig). ObjectTypeIndication = 0x40
    // (AAC). StreamType = 0x05 (audio). See ISO/IEC 14496-1 §7.2.6.
    let asc = &params.extradata;
    // DecoderSpecificInfo (tag 0x05): length = asc.len()
    let mut dsi = Vec::new();
    dsi.push(0x05);
    append_ber_length(&mut dsi, asc.len() as u32);
    dsi.extend_from_slice(asc);

    // DecoderConfigDescriptor (tag 0x04): 13 bytes header + DSI
    let mut dcd = Vec::new();
    dcd.push(0x04);
    let dcd_payload_len = 13 + dsi.len() as u32;
    append_ber_length(&mut dcd, dcd_payload_len);
    dcd.push(0x40); // object type: AAC
    dcd.push((0x05 << 2) | 0x01); // stream type (audio=5) | upstream=0 | reserved=1
                                  // buffer_size_db (24-bit) = 0
    dcd.extend_from_slice(&[0, 0, 0]);
    // max_bitrate (32-bit) = 0
    dcd.extend_from_slice(&[0, 0, 0, 0]);
    // avg_bitrate (32-bit) = 0
    dcd.extend_from_slice(&[0, 0, 0, 0]);
    dcd.extend_from_slice(&dsi);

    // SLConfigDescriptor (tag 0x06): 1 byte predefined=2
    let mut slc = Vec::new();
    slc.push(0x06);
    append_ber_length(&mut slc, 1);
    slc.push(0x02);

    // ES_Descriptor (tag 0x03): 3-byte header + DCD + SLC
    let mut esd = Vec::new();
    esd.push(0x03);
    let esd_payload_len = 3 + dcd.len() as u32 + slc.len() as u32;
    append_ber_length(&mut esd, esd_payload_len);
    // ES_ID = 0, flags = 0
    esd.extend_from_slice(&[0, 0, 0]);
    esd.extend_from_slice(&dcd);
    esd.extend_from_slice(&slc);

    // esds FullBox: 4 bytes version/flags + ES_Descriptor
    let mut esds_body = Vec::with_capacity(4 + esd.len());
    esds_body.extend_from_slice(&[0, 0, 0, 0]);
    esds_body.extend_from_slice(&esd);
    body.extend_from_slice(&write_simple_box(b"esds", &esds_body));

    Ok(SampleEntry {
        fourcc: *b"mp4a",
        body,
    })
}

fn h264_entry(params: &CodecParameters) -> Result<SampleEntry> {
    if params.media_type != MediaType::Video {
        return Err(Error::invalid("mp4 muxer: h264 must be video"));
    }
    let width = params
        .width
        .ok_or_else(|| Error::invalid("mp4 muxer: h264 requires width"))?;
    let height = params
        .height
        .ok_or_else(|| Error::invalid("mp4 muxer: h264 requires height"))?;
    if params.extradata.is_empty() {
        return Err(Error::invalid(
            "mp4 muxer: h264 stream missing extradata (AVCC configuration)",
        ));
    }
    let mut body = visual_preamble(width, height).to_vec();
    // avcC box: extradata assumed to already be AVCConfigurationRecord bytes.
    body.extend_from_slice(&write_simple_box(b"avcC", &params.extradata));
    Ok(SampleEntry {
        fourcc: *b"avc1",
        body,
    })
}

/// Write a simple (non-FullBox) box: 4-byte size + 4-byte fourcc + body.
fn write_simple_box(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
    let total = 8 + body.len() as u32;
    let mut out = Vec::with_capacity(total as usize);
    out.extend_from_slice(&total.to_be_bytes());
    out.extend_from_slice(kind);
    out.extend_from_slice(body);
    out
}

/// Append a BER-style variable-length encoding (as used in MPEG-4 descriptors).
fn append_ber_length(out: &mut Vec<u8>, mut value: u32) {
    // Emit 4 bytes: high-7-bits first, continuation flag = 0x80. We always emit
    // 4 bytes so the length is stable and easy to parse.
    let mut bytes = [0u8; 4];
    for i in (0..4).rev() {
        bytes[i] = (value & 0x7F) as u8;
        value >>= 7;
    }
    for b in &mut bytes[..3] {
        *b |= 0x80;
    }
    out.extend_from_slice(&bytes);
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxideav_core::{CodecId, CodecParameters, SampleFormat};

    #[test]
    fn pcm_sowt_shape() {
        let mut p = CodecParameters::audio(CodecId::new("pcm_s16le"));
        p.channels = Some(2);
        p.sample_rate = Some(48_000);
        p.sample_format = Some(SampleFormat::S16);
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"sowt");
        assert_eq!(e.body.len(), 28);
        // channels big-endian at offset 16
        assert_eq!(u16::from_be_bytes([e.body[16], e.body[17]]), 2);
        // sample size at offset 18
        assert_eq!(u16::from_be_bytes([e.body[18], e.body[19]]), 16);
    }

    #[test]
    fn flac_entry_has_dfla() {
        let mut p = CodecParameters::audio(CodecId::new("flac"));
        p.channels = Some(2);
        p.sample_rate = Some(48_000);
        p.sample_format = Some(SampleFormat::S16);
        // Minimal extradata: one STREAMINFO metadata block header+payload.
        let mut extradata = Vec::new();
        extradata.extend_from_slice(&[0x80, 0, 0, 34]); // last block, type=STREAMINFO, length=34
        extradata.extend_from_slice(&[0u8; 34]);
        p.extradata = extradata;
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"fLaC");
        // Body: 28 byte audio preamble + dfLa box (8 header + 4 version/flags + 38 metadata)
        assert_eq!(e.body.len(), 28 + 8 + 4 + 38);
        // Check the dfLa box is present at offset 28.
        assert_eq!(&e.body[32..36], b"dfLa");
    }

    #[test]
    fn unsupported_codec_errors() {
        let p = CodecParameters::audio(CodecId::new("vorbis"));
        assert!(sample_entry_for(&p).is_err());
    }

    #[test]
    fn mov_text_entry_shape() {
        let mut p = CodecParameters::subtitle(CodecId::new("mov_text"));
        // 18-byte tx3g default header (display flags + text colours +
        // default text box + default style record). Exact contents are
        // opaque to the muxer.
        let tx3g_header: [u8; 18] = [
            0x00, 0x00, 0x00, 0x00, // display_flags
            0x01, 0x00, 0x00, 0x00, // horiz_justify + vert_justify + bg colour rgba
            0x00, 0x00, 0x00, 0x00, // bg colour (cont.) + reserved
            0x00, 0x00, 0x00, 0x00, // default_text_box (top,left)
            0x00, 0x00, // default_text_box (bottom,right)
        ];
        p.extradata = tx3g_header.to_vec();
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"tx3g");
        // 6 reserved + 2 dri + 18 extradata = 26.
        assert_eq!(e.body.len(), 26);
        // data_reference_index at offset 6 is big-endian 1.
        assert_eq!(u16::from_be_bytes([e.body[6], e.body[7]]), 1);
        assert_eq!(&e.body[8..], &tx3g_header);
    }

    #[test]
    fn webvtt_entry_shape() {
        let mut p = CodecParameters::subtitle(CodecId::new("webvtt"));
        // Minimal `vttC` config box: 4-byte size + "vttC" + "WEBVTT".
        let mut vttc = Vec::new();
        vttc.extend_from_slice(&14u32.to_be_bytes());
        vttc.extend_from_slice(b"vttC");
        vttc.extend_from_slice(b"WEBVTT");
        p.extradata = vttc.clone();
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"wvtt");
        assert_eq!(e.body.len(), 8 + vttc.len());
        // The inner `vttC` box header should be present at offset 12 (8 preamble + 4 size).
        assert_eq!(&e.body[12..16], b"vttC");
    }

    #[test]
    fn ttml_entry_shape() {
        let mut p = CodecParameters::subtitle(CodecId::new("ttml"));
        // stpp body: namespace + \0 + schema_location? + \0 + auxiliary_mime_types? + \0.
        let strings = b"http://www.w3.org/ns/ttml\0\0\0";
        p.extradata = strings.to_vec();
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"stpp");
        assert_eq!(e.body.len(), 8 + strings.len());
        assert!(e.body[8..].starts_with(b"http://www.w3.org/ns/ttml"));
    }

    #[test]
    fn sbtt_entry_shape() {
        let mut p = CodecParameters::subtitle(CodecId::new("sbtt"));
        // sbtt body: content_encoding? + \0 + mime_format + \0.
        let strings = b"\0text/plain\0";
        p.extradata = strings.to_vec();
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"sbtt");
        assert_eq!(e.body.len(), 8 + strings.len());
    }

    #[test]
    fn stxt_entry_shape() {
        let mut p = CodecParameters::subtitle(CodecId::new("stxt"));
        let strings = b"\0text/html\0";
        p.extradata = strings.to_vec();
        let e = sample_entry_for(&p).unwrap();
        assert_eq!(&e.fourcc, b"stxt");
        assert_eq!(e.body.len(), 8 + strings.len());
    }

    #[test]
    fn subtitle_handler_routing() {
        // mov_text under BMFF text handler.
        assert_eq!(&subtitle_handler_for("mov_text"), b"text");
        // wvtt/stpp/sbtt/stxt under the subtitle (subt) handler.
        for c in ["webvtt", "ttml", "sbtt", "stxt"] {
            assert_eq!(&subtitle_handler_for(c), b"subt");
        }
    }

    #[test]
    fn subtitle_header_routing() {
        assert!(!subtitle_uses_sthd("mov_text"));
        for c in ["webvtt", "ttml", "sbtt", "stxt"] {
            assert!(subtitle_uses_sthd(c));
        }
    }

    #[test]
    fn subtitle_rejects_wrong_media_type() {
        // codec_id says mov_text but the params claim Audio — must error.
        let p = CodecParameters::audio(CodecId::new("mov_text"));
        assert!(sample_entry_for(&p).is_err());
    }
}