Skip to main content

ffcine_core/
bmff.rs

1//! A small ISO BMFF (MP4 / QuickTime MOV / CR3) metadata reader, without libav: the header
2//! fields `ffcine catalog` needs (duration, tracks, codecs, frame rate, bit depth, chroma, `colr`
3//! tags, maker atoms, Apple `mdta` keys, the first DJI `CAM meta` sample). Seeks over `mdat`, so
4//! it reads a few hundred KB of a multi-GB clip, and runs anywhere `std` does (macOS offload).
5
6#![allow(
7    clippy::many_single_char_names,
8    clippy::similar_names,
9    clippy::cast_possible_truncation,
10    clippy::doc_markdown
11)] // binary parsing; box sizes are small; QuickTime, BMFF are names
12
13use 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
20/// Seconds from 1904-01-01 (QuickTime epoch) to 1970-01-01.
21const QT_EPOCH_OFFSET: i64 = 2_082_844_800;
22/// Boxes larger than this are skipped rather than read into memory (cover art, thumbnails).
23const MAX_READ: u64 = 4 << 20;
24
25/// `colr` box contents (ITU-T H.273 code points).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct Colr {
28    pub primaries: u16,
29    pub transfer: u16,
30    pub matrix: u16,
31    /// `nclx` full-range flag; `None` for QuickTime `nclc`, which has none.
32    pub full_range: Option<bool>,
33}
34
35#[derive(Debug, Clone, PartialEq, Default)]
36pub struct Track {
37    /// `hdlr` type: `vide`, `soun`, `meta`, `tmcd`, ...
38    pub handler: String,
39    /// `hdlr` name (`CAM meta` on DJI).
40    pub handler_name: String,
41    /// Sample entry fourcc: `hvc1`, `avc1`, `apch`, `mp4a`, `djmd`, ...
42    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    /// `chroma_format_idc`: 0 = 4:0:0, 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4.
50    pub chroma: Option<u8>,
51    pub colr: Option<Colr>,
52    /// A `dvcC`/`dvvC` Dolby Vision configuration box in the sample entry.
53    pub dolby_vision: bool,
54    pub sample_rate: Option<u32>,
55    pub channels: Option<u16>,
56    /// First sample's file offset and size (used for DJI's `CAM meta` header).
57    pub first_sample: Option<(u64, u32)>,
58}
59
60#[derive(Debug, Clone, PartialEq, Default)]
61pub struct Mp4Info {
62    /// `ftyp` major brand (`isom`, `qt  `, `crx `, ...).
63    pub brand: String,
64    /// `mvhd` creation time, Unix seconds. UTC on DJI and iPhone, local wall time on Canon.
65    pub creation_time: Option<i64>,
66    pub duration: Option<f64>,
67    pub tracks: Vec<Track>,
68    /// Text atoms from `moov/udta` (and `udta/meta/ilst`), keyed by fourcc: `©too`, `modl`,
69    /// `manu`, `©mak`, `©mod`, ...
70    pub udta: BTreeMap<String, String>,
71    /// QuickTime `mdta` keys (`com.apple.quicktime.model`, `...creationdate`, ...).
72    pub keys: BTreeMap<String, String>,
73    /// Canon `CMT1` (TIFF IFD0) fields: `Make`, `Model`, `DateTime`.
74    pub canon: BTreeMap<String, String>,
75    /// First sample of DJI's `djmd` (`CAM meta`) track: a protobuf clip header.
76    pub dji_meta: Option<Vec<u8>>,
77}
78
79impl Mp4Info {
80    /// The first video track.
81    pub fn video(&self) -> Option<&Track> {
82        self.tracks.iter().find(|t| t.handler == "vide")
83    }
84
85    /// The first audio track.
86    pub fn audio(&self) -> Option<&Track> {
87        self.tracks.iter().find(|t| t.handler == "soun")
88    }
89
90    /// Camera make and model from whichever atoms the vendor writes.
91    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    /// Payload start and end.
114    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)] // < MAX_READ
132        self.read_at(b.start, n as usize)
133    }
134
135    /// Child boxes of `[start, end)`.
136    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                // Truncated tail (a copy that died mid-file): keep what parsed.
152                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
190/// Reads the metadata of an ISO BMFF file. Errors only if it isn't one (no `ftyp`/`moov`).
191pub 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            // ISO: NUL-terminated; QuickTime: Pascal string. Keep printable bytes.
287            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    // The dominant (longest) run sets the rate; a short last entry is common.
356    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                    // QuickTime `depth` is bits per pixel (24 = 8-bit RGB); only trust 8-bit.
403                    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
435/// Boxes inside a visual sample entry: `hvcC`, `avcC`, `colr`, `dvcC`, ...
436fn 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
468/// `avcC`: High profiles carry chroma/bit depth after the parameter sets; others are 8-bit 4:2:0.
469fn 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            // QuickTime international text: u16 length, u16 language, text.
518            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            // Canon: u32 version/flags, u16 language, text.
522            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
538/// `meta` box: either ISO (full box, then `hdlr`) or QuickTime (no version/flags).
539fn 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        // `data` box: size, 'data', u32 type, u32 locale, value.
569        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; // UTF-8 only
576        }
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
588/// Canon's `uuid` box (`85c0b687-820f-11e0-8111-f4ce462b6a48`) holds `CMT1`, a TIFF IFD0.
589fn 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
611/// An ASCII tag from IFD0 of a TIFF blob.
612pub 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        // Little-endian TIFF, one entry: Model = "EOS R50" (8 bytes incl. NUL, out of line).
660        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/// Real camera files under `FFCINE_SAMPLE_DIR`, when set (skipped otherwise).
675#[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}