1#![allow(
7 clippy::many_single_char_names,
8 clippy::similar_names,
9 clippy::cast_possible_truncation,
10 clippy::doc_markdown
11)] use std::collections::BTreeMap;
14use std::fs::File;
15use std::io::{self, Read, Seek, SeekFrom};
16use std::path::Path;
17
18use crate::{Error, Rational, Result};
19
20const QT_EPOCH_OFFSET: i64 = 2_082_844_800;
22const MAX_READ: u64 = 4 << 20;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Colr {
28 pub primaries: u16,
29 pub transfer: u16,
30 pub matrix: u16,
31 pub full_range: Option<bool>,
33}
34
35#[derive(Debug, Clone, PartialEq, Default)]
36pub struct Track {
37 pub handler: String,
39 pub handler_name: String,
41 pub codec: String,
43 pub duration: Option<f64>,
44 pub width: u32,
45 pub height: u32,
46 pub frame_rate: Option<Rational>,
47 pub samples: u64,
48 pub bit_depth: Option<u8>,
49 pub chroma: Option<u8>,
51 pub colr: Option<Colr>,
52 pub dolby_vision: bool,
54 pub sample_rate: Option<u32>,
55 pub channels: Option<u16>,
56 pub first_sample: Option<(u64, u32)>,
58}
59
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Mp4Info {
62 pub brand: String,
64 pub creation_time: Option<i64>,
66 pub duration: Option<f64>,
67 pub tracks: Vec<Track>,
68 pub udta: BTreeMap<String, String>,
71 pub keys: BTreeMap<String, String>,
73 pub canon: BTreeMap<String, String>,
75 pub dji_meta: Option<Vec<u8>>,
77}
78
79impl Mp4Info {
80 pub fn video(&self) -> Option<&Track> {
82 self.tracks.iter().find(|t| t.handler == "vide")
83 }
84
85 pub fn audio(&self) -> Option<&Track> {
87 self.tracks.iter().find(|t| t.handler == "soun")
88 }
89
90 pub fn model(&self) -> Option<&str> {
92 ["com.apple.quicktime.model"]
93 .iter()
94 .find_map(|k| self.keys.get(*k))
95 .or_else(|| self.canon.get("Model"))
96 .or_else(|| {
97 ["modl", "©mod", "©too"]
98 .iter()
99 .find_map(|k| self.udta.get(*k))
100 })
101 .map(String::as_str)
102 }
103}
104
105struct Reader {
106 f: File,
107 len: u64,
108}
109
110#[derive(Debug, Clone, Copy)]
111struct BoxHdr {
112 kind: [u8; 4],
113 start: u64,
115 end: u64,
116}
117
118impl Reader {
119 fn read_at(&mut self, pos: u64, n: usize) -> io::Result<Vec<u8>> {
120 self.f.seek(SeekFrom::Start(pos))?;
121 let mut buf = vec![0; n];
122 self.f.read_exact(&mut buf)?;
123 Ok(buf)
124 }
125
126 fn payload(&mut self, b: BoxHdr) -> io::Result<Vec<u8>> {
127 let n = b.end - b.start;
128 if n > MAX_READ {
129 return Err(invalid("box too large", &fourcc(b.kind)));
130 }
131 #[allow(clippy::cast_possible_truncation)] self.read_at(b.start, n as usize)
133 }
134
135 fn children(&mut self, start: u64, end: u64) -> io::Result<Vec<BoxHdr>> {
137 let mut out = Vec::new();
138 let mut off = start;
139 while off + 8 <= end {
140 let h = self.read_at(off, 8)?;
141 let mut size = u64::from(be32(&h[0..4]));
142 let kind = [h[4], h[5], h[6], h[7]];
143 let mut hdr = 8;
144 if size == 1 {
145 size = be64(&self.read_at(off + 8, 8)?);
146 hdr = 16;
147 } else if size == 0 {
148 size = end - off;
149 }
150 if size < hdr || off + size > end {
151 break;
153 }
154 if &kind == b"uuid" {
155 hdr += 16;
156 }
157 out.push(BoxHdr {
158 kind,
159 start: off + hdr,
160 end: off + size,
161 });
162 off += size;
163 }
164 Ok(out)
165 }
166}
167
168fn invalid(what: &'static str, value: &str) -> io::Error {
169 io::Error::new(io::ErrorKind::InvalidData, format!("{what} {value}"))
170}
171
172fn fourcc(k: [u8; 4]) -> String {
173 k.iter().map(|&b| char::from(b)).collect()
174}
175
176fn be16(b: &[u8]) -> u16 {
177 u16::from_be_bytes([b[0], b[1]])
178}
179fn be32(b: &[u8]) -> u32 {
180 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
181}
182fn be64(b: &[u8]) -> u64 {
183 u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
184}
185
186fn find(boxes: &[BoxHdr], kind: [u8; 4]) -> Option<BoxHdr> {
187 boxes.iter().copied().find(|b| b.kind == kind)
188}
189
190pub fn read(path: &Path) -> Result<Mp4Info> {
192 read_io(path).map_err(|source| Error::Io {
193 path: path.to_owned(),
194 source,
195 })
196}
197
198fn read_io(path: &Path) -> io::Result<Mp4Info> {
199 let f = File::open(path)?;
200 let len = f.metadata()?.len();
201 let mut r = Reader { f, len };
202 let top = r.children(0, r.len)?;
203 let mut info = Mp4Info::default();
204 if let Some(ftyp) = find(&top, *b"ftyp") {
205 let p = r.read_at(ftyp.start, 4)?;
206 info.brand = fourcc([p[0], p[1], p[2], p[3]]);
207 }
208 let moov = find(&top, *b"moov").ok_or_else(|| invalid("MP4/MOV (no moov box)", ""))?;
209 let kids = r.children(moov.start, moov.end)?;
210 for b in &kids {
211 match &b.kind {
212 b"mvhd" => mvhd(&mut r, *b, &mut info)?,
213 b"trak" => {
214 if let Some(t) = trak(&mut r, *b)? {
215 info.tracks.push(t);
216 }
217 }
218 b"udta" => udta(&mut r, *b, &mut info)?,
219 b"meta" => meta(&mut r, *b, &mut info)?,
220 b"uuid" => canon_uuid(&mut r, *b, &mut info)?,
221 _ => {}
222 }
223 }
224 if let Some(t) = info.tracks.iter().find(|t| t.codec == "djmd")
225 && let Some((off, size)) = t.first_sample
226 && u64::from(size) <= MAX_READ
227 && off + u64::from(size) <= r.len
228 {
229 info.dji_meta = Some(r.read_at(off, size as usize)?);
230 }
231 Ok(info)
232}
233
234#[allow(clippy::cast_precision_loss, clippy::cast_possible_wrap)]
235fn mvhd(r: &mut Reader, b: BoxHdr, info: &mut Mp4Info) -> io::Result<()> {
236 let p = r.payload(b)?;
237 let (created, scale, dur) = if p.first() == Some(&1) && p.len() >= 32 {
238 (be64(&p[4..12]), be32(&p[20..24]), be64(&p[24..32]))
239 } else if p.len() >= 20 {
240 (
241 u64::from(be32(&p[4..8])),
242 be32(&p[12..16]),
243 u64::from(be32(&p[16..20])),
244 )
245 } else {
246 return Ok(());
247 };
248 if created != 0 {
249 info.creation_time = Some(created as i64 - QT_EPOCH_OFFSET);
250 }
251 if scale != 0 {
252 info.duration = Some(dur as f64 / f64::from(scale));
253 }
254 Ok(())
255}
256
257#[allow(clippy::cast_precision_loss)]
258fn trak(r: &mut Reader, b: BoxHdr) -> io::Result<Option<Track>> {
259 let kids = r.children(b.start, b.end)?;
260 let Some(mdia) = find(&kids, *b"mdia") else {
261 return Ok(None);
262 };
263 let mk = r.children(mdia.start, mdia.end)?;
264 let mut t = Track::default();
265 let mut timescale = 0;
266 if let Some(h) = find(&mk, *b"mdhd") {
267 let p = r.payload(h)?;
268 let (scale, dur) = if p.first() == Some(&1) && p.len() >= 32 {
269 (be32(&p[20..24]), be64(&p[24..32]))
270 } else if p.len() >= 20 {
271 (be32(&p[12..16]), u64::from(be32(&p[16..20])))
272 } else {
273 (0, 0)
274 };
275 timescale = scale;
276 if scale != 0 {
277 t.duration = Some(dur as f64 / f64::from(scale));
278 }
279 }
280 if let Some(h) = find(&mk, *b"hdlr") {
281 let p = r.payload(h)?;
282 if p.len() >= 12 {
283 t.handler = fourcc([p[8], p[9], p[10], p[11]]);
284 }
285 if p.len() > 24 {
286 t.handler_name.clone_from(
288 &p[24..]
289 .iter()
290 .filter(|c| c.is_ascii_graphic() || **c == b' ')
291 .map(|&c| char::from(c))
292 .collect::<String>()
293 .trim()
294 .to_owned(),
295 );
296 }
297 }
298 let Some(minf) = find(&mk, *b"minf") else {
299 return Ok(Some(t));
300 };
301 let mi = r.children(minf.start, minf.end)?;
302 let Some(stbl) = find(&mi, *b"stbl") else {
303 return Ok(Some(t));
304 };
305 let st = r.children(stbl.start, stbl.end)?;
306 if let Some(sd) = find(&st, *b"stsd") {
307 stsd(r, sd, &mut t);
308 }
309 if let Some(b) = find(&st, *b"stts") {
310 stts(r, b, timescale, &mut t)?;
311 }
312 let first_size = match find(&st, *b"stsz") {
313 Some(b) => {
314 let p = r.read_at(b.start, 16.min((b.end - b.start) as usize))?;
315 if p.len() >= 12 {
316 let fixed = be32(&p[4..8]);
317 if t.samples == 0 {
318 t.samples = u64::from(be32(&p[8..12]));
319 }
320 if fixed != 0 {
321 Some(fixed)
322 } else if p.len() >= 16 {
323 Some(be32(&p[12..16]))
324 } else {
325 None
326 }
327 } else {
328 None
329 }
330 }
331 None => None,
332 };
333 let first_off = if let Some(b) = find(&st, *b"stco") {
334 let p = r.read_at(b.start, 12.min((b.end - b.start) as usize))?;
335 (p.len() >= 12 && be32(&p[4..8]) > 0).then(|| u64::from(be32(&p[8..12])))
336 } else if let Some(b) = find(&st, *b"co64") {
337 let p = r.read_at(b.start, 16.min((b.end - b.start) as usize))?;
338 (p.len() >= 16 && be32(&p[4..8]) > 0).then(|| be64(&p[8..16]))
339 } else {
340 None
341 };
342 if let (Some(o), Some(s)) = (first_off, first_size) {
343 t.first_sample = Some((o, s));
344 }
345 Ok(Some(t))
346}
347
348fn stts(r: &mut Reader, b: BoxHdr, timescale: u32, t: &mut Track) -> io::Result<()> {
349 let p = r.payload(b)?;
350 if p.len() < 8 || timescale == 0 {
351 return Ok(());
352 }
353 let n = be32(&p[4..8]) as usize;
354 let (mut count, mut total) = (0u64, 0u64);
355 let mut best = (0u64, 0u32);
357 for e in p[8..].as_chunks::<8>().0.iter().take(n) {
358 let (c, d) = (u64::from(be32(&e[0..4])), be32(&e[4..8]));
359 count += c;
360 total += c * u64::from(d);
361 if c > best.0 {
362 best = (c, d);
363 }
364 }
365 t.samples = count;
366 if t.handler == "vide" && best.1 != 0 {
367 let g = gcd(timescale, best.1);
368 if let (Ok(num), Ok(den)) = (i32::try_from(timescale / g), i32::try_from(best.1 / g)) {
369 t.frame_rate = Some(Rational { num, den });
370 }
371 }
372 let _ = total;
373 Ok(())
374}
375
376fn gcd(mut a: u32, mut b: u32) -> u32 {
377 while b != 0 {
378 (a, b) = (b, a % b);
379 }
380 a.max(1)
381}
382
383fn stsd(r: &mut Reader, b: BoxHdr, t: &mut Track) {
384 let Ok(p) = r.payload(b) else {
385 return;
386 };
387 if p.len() < 16 {
388 return;
389 }
390 let entry = &p[8..];
391 let size = (be32(&entry[0..4]) as usize).min(entry.len());
392 t.codec = fourcc([entry[4], entry[5], entry[6], entry[7]]);
393 let e = &entry[..size];
394 match t.handler.as_str() {
395 "vide" if e.len() >= 86 => {
396 t.width = u32::from(be16(&e[32..34]));
397 t.height = u32::from(be16(&e[34..36]));
398 let depth = be16(&e[82..84]);
399 sample_entry_boxes(&e[86..], t);
400 if t.bit_depth.is_none() {
401 t.bit_depth = prores_depth(&t.codec).or(match depth {
402 24 => Some(8),
404 _ => None,
405 });
406 }
407 if t.chroma.is_none() {
408 t.chroma = prores_chroma(&t.codec);
409 }
410 }
411 "soun" if e.len() >= 28 => {
412 t.channels = Some(be16(&e[16..18]));
413 t.sample_rate = Some(be32(&e[24..28]) >> 16);
414 }
415 _ => {}
416 }
417}
418
419fn prores_depth(codec: &str) -> Option<u8> {
420 match codec {
421 "apch" | "apcn" | "apcs" | "apco" => Some(10),
422 "ap4h" | "ap4x" => Some(12),
423 _ => None,
424 }
425}
426
427fn prores_chroma(codec: &str) -> Option<u8> {
428 match codec {
429 "apch" | "apcn" | "apcs" | "apco" => Some(2),
430 "ap4h" | "ap4x" => Some(3),
431 _ => None,
432 }
433}
434
435fn sample_entry_boxes(mut b: &[u8], t: &mut Track) {
437 while b.len() >= 8 {
438 let size = be32(&b[0..4]) as usize;
439 if size < 8 || size > b.len() {
440 break;
441 }
442 let (kind, body) = (&b[4..8], &b[8..size]);
443 match kind {
444 b"hvcC" if body.len() >= 19 => {
445 t.chroma = Some(body[16] & 3);
446 t.bit_depth = Some((body[17] & 7) + 8);
447 }
448 b"avcC" if body.len() >= 4 => avcc(body, t),
449 b"colr" if body.len() >= 10 => {
450 let full =
451 (&body[0..4] == b"nclx" && body.len() >= 11).then(|| body[10] & 0x80 != 0);
452 if &body[0..4] == b"nclx" || &body[0..4] == b"nclc" {
453 t.colr = Some(Colr {
454 primaries: be16(&body[4..6]),
455 transfer: be16(&body[6..8]),
456 matrix: be16(&body[8..10]),
457 full_range: full,
458 });
459 }
460 }
461 b"dvcC" | b"dvvC" | b"dvwC" => t.dolby_vision = true,
462 _ => {}
463 }
464 b = &b[size..];
465 }
466}
467
468fn avcc(body: &[u8], t: &mut Track) {
470 let profile = body[1];
471 t.chroma = Some(1);
472 t.bit_depth = Some(8);
473 if !matches!(profile, 100 | 110 | 122 | 144 | 244) {
474 return;
475 }
476 let mut i = 5;
477 let Some(&nsps) = body.get(i) else { return };
478 i += 1;
479 for _ in 0..(nsps & 0x1f) {
480 let Some(l) = body.get(i..i + 2) else { return };
481 i += 2 + be16(l) as usize;
482 }
483 let Some(&npps) = body.get(i) else { return };
484 i += 1;
485 for _ in 0..npps {
486 let Some(l) = body.get(i..i + 2) else { return };
487 i += 2 + be16(l) as usize;
488 }
489 if let Some(ext) = body.get(i..i + 3) {
490 t.chroma = Some(ext[0] & 3);
491 t.bit_depth = Some((ext[1] & 7) + 8);
492 } else if profile == 110 {
493 t.bit_depth = Some(10);
494 } else if profile == 122 {
495 (t.chroma, t.bit_depth) = (Some(2), Some(10));
496 }
497}
498
499fn text(b: &[u8]) -> String {
500 String::from_utf8_lossy(b)
501 .trim_matches(|c: char| c == '\0' || c.is_whitespace())
502 .to_owned()
503}
504
505fn udta(r: &mut Reader, b: BoxHdr, info: &mut Mp4Info) -> io::Result<()> {
506 for c in r.children(b.start, b.end)? {
507 let key = fourcc(c.kind);
508 if &c.kind == b"meta" {
509 meta(r, c, info)?;
510 continue;
511 }
512 if c.end - c.start > 1024 {
513 continue;
514 }
515 let p = r.payload(c)?;
516 let value = if key.starts_with('©') && p.len() >= 4 {
517 let n = (be16(&p[0..2]) as usize).min(p.len() - 4);
519 text(&p[4..4 + n])
520 } else if matches!(&c.kind, b"manu" | b"modl") && p.len() > 6 {
521 text(&p[6..])
523 } else if p
524 .iter()
525 .all(|&ch| ch == 0 || ch.is_ascii_graphic() || ch == b' ' || ch == b'=' || ch == b';')
526 {
527 text(&p)
528 } else {
529 continue;
530 };
531 if !value.is_empty() {
532 info.udta.insert(key, value);
533 }
534 }
535 Ok(())
536}
537
538fn meta(r: &mut Reader, b: BoxHdr, info: &mut Mp4Info) -> io::Result<()> {
540 let head = r.read_at(b.start, 8.min((b.end - b.start) as usize))?;
541 let start = if head.len() >= 8 && &head[4..8] == b"hdlr" {
542 b.start
543 } else {
544 b.start + 4
545 };
546 let kids = r.children(start, b.end)?;
547 let mut keys: Vec<String> = Vec::new();
548 if let Some(k) = find(&kids, *b"keys") {
549 let p = r.payload(k)?;
550 let mut i = 8;
551 while i + 8 <= p.len() {
552 let size = be32(&p[i..i + 4]) as usize;
553 if size < 8 || i + size > p.len() {
554 break;
555 }
556 keys.push(text(&p[i + 8..i + size]));
557 i += size;
558 }
559 }
560 let Some(ilst) = find(&kids, *b"ilst") else {
561 return Ok(());
562 };
563 for item in r.children(ilst.start, ilst.end)? {
564 if item.end - item.start > 64 * 1024 {
565 continue;
566 }
567 let p = r.payload(item)?;
568 if p.len() < 16 || &p[4..8] != b"data" {
570 continue;
571 }
572 let dsize = (be32(&p[0..4]) as usize).min(p.len());
573 let kind = be32(&p[8..12]) & 0x00ff_ffff;
574 if kind != 1 {
575 continue; }
577 let value = text(&p[16..dsize]);
578 let idx = be32(&[item.kind[0], item.kind[1], item.kind[2], item.kind[3]]) as usize;
579 if !keys.is_empty() && idx >= 1 && idx <= keys.len() {
580 info.keys.insert(keys[idx - 1].clone(), value);
581 } else {
582 info.udta.insert(fourcc(item.kind), value);
583 }
584 }
585 Ok(())
586}
587
588fn canon_uuid(r: &mut Reader, b: BoxHdr, info: &mut Mp4Info) -> io::Result<()> {
590 const CANON: [u8; 16] = [
591 0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a,
592 0x48,
593 ];
594 if r.read_at(b.start - 16, 16)? != CANON {
595 return Ok(());
596 }
597 for c in r.children(b.start, b.end)? {
598 if &c.kind == b"CMT1"
599 && let Ok(p) = r.payload(c)
600 {
601 for (tag, name) in [(0x010f, "Make"), (0x0110, "Model"), (0x0132, "DateTime")] {
602 if let Some(v) = tiff_ascii(&p, tag) {
603 info.canon.insert(name.to_owned(), v);
604 }
605 }
606 }
607 }
608 Ok(())
609}
610
611pub fn tiff_ascii(p: &[u8], tag: u16) -> Option<String> {
613 let le = match p.get(0..2)? {
614 b"II" => true,
615 b"MM" => false,
616 _ => return None,
617 };
618 let u16_at = |i: usize| -> Option<u16> {
619 let b = p.get(i..i + 2)?;
620 Some(if le {
621 u16::from_le_bytes([b[0], b[1]])
622 } else {
623 u16::from_be_bytes([b[0], b[1]])
624 })
625 };
626 let u32_at = |i: usize| -> Option<u32> {
627 let b = p.get(i..i + 4)?;
628 Some(if le {
629 u32::from_le_bytes([b[0], b[1], b[2], b[3]])
630 } else {
631 u32::from_be_bytes([b[0], b[1], b[2], b[3]])
632 })
633 };
634 let ifd = u32_at(4)? as usize;
635 let n = u16_at(ifd)? as usize;
636 for i in 0..n {
637 let e = ifd + 2 + i * 12;
638 if u16_at(e)? != tag || u16_at(e + 2)? != 2 {
639 continue;
640 }
641 let count = u32_at(e + 4)? as usize;
642 let data = if count <= 4 {
643 p.get(e + 8..e + 8 + count)?
644 } else {
645 let off = u32_at(e + 8)? as usize;
646 p.get(off..off + count)?
647 };
648 return Some(text(data));
649 }
650 None
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn tiff_ifd0_ascii() {
659 let mut t = b"II*\0".to_vec();
661 t.extend_from_slice(&8u32.to_le_bytes());
662 t.extend_from_slice(&1u16.to_le_bytes());
663 t.extend_from_slice(&0x0110u16.to_le_bytes());
664 t.extend_from_slice(&2u16.to_le_bytes());
665 t.extend_from_slice(&8u32.to_le_bytes());
666 t.extend_from_slice(&26u32.to_le_bytes());
667 t.extend_from_slice(&0u32.to_le_bytes());
668 t.extend_from_slice(b"EOS R50\0");
669 assert_eq!(tiff_ascii(&t, 0x0110).as_deref(), Some("EOS R50"));
670 assert_eq!(tiff_ascii(&t, 0x010f), None);
671 }
672}
673
674#[cfg(test)]
676mod samples {
677 use std::path::{Path, PathBuf};
678
679 use crate::profile::{self, PictureProfile};
680
681 fn sample(rel: &str) -> Option<PathBuf> {
682 let p = Path::new(&std::env::var_os("FFCINE_SAMPLE_DIR")?).join(rel);
683 p.exists().then_some(p)
684 }
685
686 #[test]
687 fn osmo_pocket_4_dlog_and_normal() {
688 for (path, want) in [
689 (
690 "pocket4-dlog/DJI_20260704112401_0001_D.MP4",
691 PictureProfile::Dlog,
692 ),
693 (
694 "pocket4-normal/DCIM/DJI_001/DJI_20260605164744_0001_D.MP4",
695 PictureProfile::Rec709,
696 ),
697 ] {
698 let Some(p) = sample(path) else { continue };
699 let info = super::read(&p).unwrap();
700 let v = info.video().unwrap();
701 assert_eq!((v.codec.as_str(), v.width, v.height), ("hvc1", 3840, 2160));
702 assert_eq!((v.bit_depth, v.chroma), (Some(10), Some(1)));
703 assert!(v.frame_rate.is_some());
704 assert_eq!(profile::detect(&info).profile, want, "{path}");
705 }
706 }
707
708 #[test]
709 fn canon_r10_mp4() {
710 let Some(p) = sample("r10/MVI_2884.MP4") else {
711 return;
712 };
713 let info = super::read(&p).unwrap();
714 assert_eq!(info.model(), Some("Canon EOS R10"));
715 assert_eq!(info.video().unwrap().bit_depth, Some(8));
716 assert_eq!(profile::detect(&info).profile, PictureProfile::Rec709);
717 }
718}