Skip to main content

videre_core/
video_meta.rs

1//! Date, location, dimensions, duration and codec from a QuickTime/MP4
2//! container, without decoding anything.
3//!
4//! videre extracted no metadata from video at all until this existed:
5//! `mime_probe::EXIF_MIMES` covers jpeg/tiff/heic, and `hasher` returns all
6//! `None` for anything else. Measured on a real library, that left **13,457
7//! videos with no date and no GPS**, so every feature keyed on either
8//! (`--after`/`--before`, `--near`, `videre locations`, and any compositional
9//! query mixing them) silently excluded 19% of the files while presenting
10//! itself as covering the library.
11//!
12//! Parsing is in-house rather than shelling out to `ffprobe`: `video_probe`
13//! already walks these boxes, so this is the same walk one level further. That
14//! also avoids putting a subprocess on a user-supplied path, which would need
15//! `io_timeout` bounding whose timeout could *not* be size-scaled, since
16//! reading a header is not proportional to file length.
17
18use std::io::{Seek, SeekFrom};
19use std::path::Path;
20
21/// Everything worth taking from a container.
22///
23/// `duration_secs` and `codec` are here rather than in a later release because
24/// existing rows cannot pick this up incrementally: `--retry-incomplete` keys
25/// on `mime IS NULL`, which does not catch "scanned before video metadata
26/// existed", so shipping this forces one full re-scan of every library
27/// regardless. Adding them separately would have forced a second one.
28#[derive(Debug, Default, Clone, PartialEq)]
29pub struct VideoMeta {
30    /// Local wall-clock, `YYYY-MM-DDTHH:MM:SS`, matching what `extract_exif`
31    /// writes for photos. See `parse_apple_date` for why not UTC.
32    pub date: Option<String>,
33    pub gps_lat: Option<f64>,
34    pub gps_lon: Option<f64>,
35    pub width: Option<u32>,
36    pub height: Option<u32>,
37    pub duration_secs: Option<f64>,
38    /// The `stsd` format tag verbatim, lowercased: `avc1`, `hvc1`, `prores`.
39    /// Not mapped to a friendly name; that is a display concern.
40    pub codec: Option<String>,
41}
42
43/// Iterate a box payload's direct children as `(type, payload)`.
44///
45/// Stops rather than erroring on a malformed size, so a truncated tail yields
46/// the children that did parse. Every caller here treats absence as "unknown",
47/// so partial data is strictly better than none.
48fn children(buf: &[u8]) -> Vec<([u8; 4], &[u8])> {
49    let mut out = Vec::new();
50    let mut i = 0usize;
51    while i + 8 <= buf.len() {
52        let Ok(size_bytes) = buf[i..i + 4].try_into() else {
53            break;
54        };
55        let size = u32::from_be_bytes(size_bytes) as usize;
56        let Ok(typ) = buf[i + 4..i + 8].try_into() else {
57            break;
58        };
59        let (size, header_len) = if size == 1 {
60            if i + 16 > buf.len() {
61                break;
62            }
63            let Ok(b) = buf[i + 8..i + 16].try_into() else {
64                break;
65            };
66            let Ok(s) = usize::try_from(u64::from_be_bytes(b)) else {
67                break;
68            };
69            (s, 16usize)
70        } else if size == 0 {
71            (buf.len() - i, 8usize)
72        } else {
73            (size, 8usize)
74        };
75        if size < header_len || i + size > buf.len() {
76            break;
77        }
78        out.push((typ, &buf[i + header_len..i + size]));
79        i += size;
80    }
81    out
82}
83
84fn find<'a>(buf: &'a [u8], typ: &[u8; 4]) -> Option<&'a [u8]> {
85    children(buf)
86        .into_iter()
87        .find(|(t, _)| t == typ)
88        .map(|(_, p)| p)
89}
90
91fn be32(b: &[u8], at: usize) -> Option<u32> {
92    b.get(at..at + 4)
93        .and_then(|s| s.try_into().ok())
94        .map(u32::from_be_bytes)
95}
96
97fn be64(b: &[u8], at: usize) -> Option<u64> {
98    b.get(at..at + 8)
99        .and_then(|s| s.try_into().ok())
100        .map(u64::from_be_bytes)
101}
102
103/// `duration / timescale` from `mvhd`.
104///
105/// Version 0 uses 32-bit times, version 1 uses 64-bit. The version byte must be
106/// read rather than assumed: getting it wrong reads the duration from the wrong
107/// offset and yields a plausible-looking wrong number, not an error.
108fn parse_mvhd(p: &[u8]) -> Option<f64> {
109    let version = *p.first()?;
110    let (timescale, duration) = if version == 1 {
111        (be32(p, 20)? as f64, be64(p, 24)? as f64)
112    } else {
113        (be32(p, 12)? as f64, be32(p, 16)? as f64)
114    };
115    if timescale == 0.0 {
116        return None;
117    }
118    Some(duration / timescale)
119}
120
121/// Seconds between 1904-01-01 (the QuickTime epoch) and 1970-01-01 (Unix).
122const QT_EPOCH_OFFSET: i64 = 2_082_844_800;
123
124/// `mvhd`'s creation time, as a fallback when the Apple key is absent.
125///
126/// **This one is UTC**, unlike `com.apple.quicktime.creationdate`, and there is
127/// no offset stored anywhere to recover the local time from. Measured on a real
128/// corpus, 10 of 260 clips carry only this - all of them re-encoded renders
129/// rather than camera originals. A date that may be wrong by a timezone is
130/// still far better than no date, which excludes the file from every date
131/// filter entirely; but prefer the Apple key whenever it exists.
132fn parse_mvhd_date(p: &[u8]) -> Option<String> {
133    let version = *p.first()?;
134    let secs = if version == 1 {
135        be64(p, 4)? as i64
136    } else {
137        be32(p, 4)? as i64
138    };
139    let unix = secs.checked_sub(QT_EPOCH_OFFSET)?;
140    // A zero or absurd creation time is common in re-muxed files; reject it
141    // rather than recording 1904 or 1970 as a capture date.
142    if unix <= 0 {
143        return None;
144    }
145    let dt = chrono::DateTime::from_timestamp(unix, 0)?;
146    Some(dt.format("%Y-%m-%dT%H:%M:%S").to_string())
147}
148
149/// Width and height from `tkhd`, stored as 16.16 fixed point.
150///
151/// The display matrix that precedes them can encode a rotation, so a portrait
152/// video commonly reports landscape dimensions here. Applying the matrix is out
153/// of scope: a wrong aspect ratio is cosmetic, where a wrong date is not.
154fn parse_tkhd(p: &[u8]) -> Option<(u32, u32)> {
155    let version = *p.first()?;
156    // version+flags(4) + times/id/reserved/duration + reserved(8) + layer(2)
157    // + alternate_group(2) + volume(2) + reserved(2) + matrix(36)
158    let at = if version == 1 {
159        4 + 32 + 16 + 36
160    } else {
161        4 + 20 + 16 + 36
162    };
163    let w = be32(p, at)? >> 16;
164    let h = be32(p, at + 4)? >> 16;
165    (w > 0 && h > 0).then_some((w, h))
166}
167
168/// First sample entry's 4-byte format tag from `stsd`.
169fn parse_stsd(p: &[u8]) -> Option<String> {
170    // version+flags(4) entry_count(4), then entry: size(4) format(4)
171    let tag = p.get(12..16)?;
172    let s = std::str::from_utf8(tag).ok()?.trim().to_lowercase();
173    (!s.is_empty()).then_some(s)
174}
175
176/// `+19.4290-099.1625+2248.823/` -> (lat, lon), altitude discarded.
177///
178/// Scans for the sign characters rather than assuming widths: the digit counts
179/// vary with precision and the altitude segment is optional. Returns None
180/// rather than guessing on anything unexpected, because a wrong coordinate puts
181/// a file on the wrong continent in `videre locations` and the clustering has
182/// no way to notice.
183pub fn parse_iso6709(s: &str) -> Option<(f64, f64)> {
184    let s = s.trim().trim_end_matches('/');
185    let mut parts: Vec<String> = Vec::new();
186    let mut cur = String::new();
187    for c in s.chars() {
188        if (c == '+' || c == '-') && !cur.is_empty() {
189            parts.push(std::mem::take(&mut cur));
190        }
191        if c == '+' || c == '-' || c.is_ascii_digit() || c == '.' {
192            cur.push(c);
193        } else {
194            return None;
195        }
196    }
197    if !cur.is_empty() {
198        parts.push(cur);
199    }
200    if parts.len() < 2 {
201        return None;
202    }
203    let lat: f64 = parts[0].parse().ok()?;
204    let lon: f64 = parts[1].parse().ok()?;
205    (-90.0..=90.0)
206        .contains(&lat)
207        .then_some(())
208        .and((-180.0..=180.0).contains(&lon).then_some(()))?;
209    Some((lat, lon))
210}
211
212/// `2024-12-15T21:49:24-0600` -> `2024-12-15T21:49:24`.
213///
214/// **The local wall-clock is kept and the offset dropped, deliberately.**
215/// Photos store `exif_date` as local wall-clock because EXIF carries no
216/// timezone, and every date filter, `EFFECTIVE_DATE_SQL` and
217/// `output::best_date` compares those strings. QuickTime's `mvhd` time is UTC,
218/// so storing that would put video on a different clock in the same column: a
219/// video shot at 21:49 local would land on the following day, `--on` would miss
220/// it, and a chronological sort would interleave it wrongly against photos
221/// taken minutes earlier. Anyone "simplifying" this to the UTC field is
222/// introducing a silent, permanent error.
223fn parse_apple_date(s: &str) -> Option<String> {
224    let s = s.trim();
225    if s.len() < 19 {
226        return None;
227    }
228    let head = &s[..19];
229    let b = head.as_bytes();
230    (b[4] == b'-' && b[7] == b'-' && b[10] == b'T' && b[13] == b':' && b[16] == b':')
231        .then(|| head.to_string())
232}
233
234/// Apple metadata lives in `moov/meta`, whose children are indexed by `keys`
235/// and valued by `ilst`.
236///
237/// `meta` is a full box (4 bytes of version/flags before its children) in MP4
238/// but a plain container in QuickTime. Rather than branch on brand, try both
239/// and keep whichever yields a `keys` child.
240fn apple_keys(moov: &[u8]) -> Option<(Option<String>, Option<String>)> {
241    let meta = find(moov, b"meta")?;
242    let body = [meta, meta.get(4..).unwrap_or(&[])]
243        .into_iter()
244        .find(|b| find(b, b"keys").is_some())?;
245
246    let keys = find(body, b"keys")?;
247    let ilst = find(body, b"ilst")?;
248
249    // keys: version/flags(4) entry_count(4), then entries of
250    // size(4) namespace(4) name(size-8)
251    let mut names: Vec<String> = Vec::new();
252    let mut i = 8usize;
253    while i + 8 <= keys.len() {
254        let size = be32(keys, i)? as usize;
255        if size < 8 || i + size > keys.len() {
256            break;
257        }
258        names.push(String::from_utf8_lossy(&keys[i + 8..i + size]).into_owned());
259        i += size;
260    }
261
262    let (mut date, mut loc) = (None, None);
263    // ilst children are 1-based indices into `names`, each holding a `data` box
264    // of type_indicator(4) locale(4) value(..).
265    for (typ, payload) in children(ilst) {
266        let idx = u32::from_be_bytes(typ) as usize;
267        let Some(name) = idx.checked_sub(1).and_then(|k| names.get(k)) else {
268            continue;
269        };
270        let Some(data) = find(payload, b"data") else {
271            continue;
272        };
273        let Some(value) = data.get(8..) else { continue };
274        let value = String::from_utf8_lossy(value).into_owned();
275        match name.as_str() {
276            "com.apple.quicktime.creationdate" => date = Some(value),
277            "com.apple.quicktime.location.ISO6709" => loc = Some(value),
278            _ => {}
279        }
280    }
281    Some((date, loc))
282}
283
284/// Parser core, over an in-memory `moov`. Split out so tests drive it with
285/// synthetic boxes: `videre-core` reads no fixture files.
286pub(crate) fn from_moov(moov: &[u8]) -> VideoMeta {
287    let mut m = VideoMeta::default();
288
289    if let Some(d) = find(moov, b"mvhd").and_then(parse_mvhd) {
290        m.duration_secs = Some(d);
291    }
292
293    if let Some((date, loc)) = apple_keys(moov) {
294        m.date = date.as_deref().and_then(parse_apple_date);
295        if let Some((lat, lon)) = loc.as_deref().and_then(parse_iso6709) {
296            m.gps_lat = Some(lat);
297            m.gps_lon = Some(lon);
298        }
299    }
300
301    // Only when the Apple key is missing: that one carries local time, this one
302    // is UTC, and mixing them silently would put some video on a different
303    // clock from the photos beside it.
304    if m.date.is_none() {
305        m.date = find(moov, b"mvhd").and_then(parse_mvhd_date);
306    }
307
308    // Dimensions and codec come from the *video* track, so audio traks must be
309    // skipped: taking the first trak would report the audio track's zero
310    // dimensions and its codec.
311    for (typ, trak) in children(moov) {
312        if &typ != b"trak" {
313            continue;
314        }
315        let is_video = find(trak, b"mdia")
316            .and_then(|mdia| find(mdia, b"hdlr"))
317            .is_some_and(|h| h.get(8..12) == Some(b"vide"));
318        if !is_video {
319            continue;
320        }
321        if let Some((w, h)) = find(trak, b"tkhd").and_then(parse_tkhd) {
322            m.width = Some(w);
323            m.height = Some(h);
324        }
325        m.codec = find(trak, b"mdia")
326            .and_then(|x| find(x, b"minf"))
327            .and_then(|x| find(x, b"stbl"))
328            .and_then(|x| find(x, b"stsd"))
329            .and_then(parse_stsd);
330        break;
331    }
332
333    m
334}
335
336/// Read what a container knows about itself.
337///
338/// Total by construction: a missing file, unreadable file, unknown layout or
339/// malformed box all yield `VideoMeta::default()`. A scan must never fail
340/// because one file has an odd header.
341///
342/// Bounded by the constant `DEFAULT_IO_TIMEOUT`, **not** the size-scaled one:
343/// this reads a header near the start of the file, so its cost is not
344/// proportional to length. See the whole-file-reads-only rule on
345/// `io_timeout::timeout_for_size`.
346pub fn read(path: &Path) -> VideoMeta {
347    let path = path.to_path_buf();
348    crate::io_timeout::run_with_timeout(crate::io_timeout::DEFAULT_IO_TIMEOUT, move || {
349        read_file_inner(std::fs::File::open(&path).ok()?)
350    })
351    .ok()
352    .flatten()
353    .unwrap_or_default()
354}
355
356/// Read metadata from an already-confined file handle.
357pub fn read_file(file: std::fs::File) -> VideoMeta {
358    crate::io_timeout::run_with_timeout(crate::io_timeout::DEFAULT_IO_TIMEOUT, move || {
359        read_file_inner(file)
360    })
361    .ok()
362    .flatten()
363    .unwrap_or_default()
364}
365
366fn read_file_inner(mut file: std::fs::File) -> Option<VideoMeta> {
367    let end = file.seek(SeekFrom::End(0)).ok()?;
368    file.seek(SeekFrom::Start(0)).ok()?;
369    crate::video_probe::read_moov(&mut file, end).map(|moov| from_moov(&moov))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn bx(typ: &[u8; 4], payload: &[u8]) -> Vec<u8> {
377        let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
378        v.extend_from_slice(typ);
379        v.extend_from_slice(payload);
380        v
381    }
382
383    #[test]
384    fn iso6709_parses_both_signs_and_optional_altitude() {
385        assert_eq!(
386            parse_iso6709("+52.5535+013.4299+050.897/"),
387            Some((52.5535, 13.4299))
388        );
389        assert_eq!(
390            parse_iso6709("+19.4290-099.1625+2248.823/"),
391            Some((19.4290, -99.1625))
392        );
393        assert_eq!(parse_iso6709("+52.5535+013.4299"), Some((52.5535, 13.4299)));
394        assert_eq!(
395            parse_iso6709("-33.8688+151.2093/"),
396            Some((-33.8688, 151.2093))
397        );
398    }
399
400    #[test]
401    fn iso6709_refuses_rather_than_guesses() {
402        // A wrong coordinate puts a file on the wrong continent in
403        // `videre locations`, and nothing downstream can notice.
404        assert_eq!(parse_iso6709(""), None);
405        assert_eq!(
406            parse_iso6709("+52.5535"),
407            None,
408            "one component is not a position"
409        );
410        assert_eq!(
411            parse_iso6709("52.5535,13.4299"),
412            None,
413            "comma form is not ISO6709"
414        );
415        assert_eq!(parse_iso6709("+99.0+013.0/"), None, "latitude out of range");
416        assert_eq!(
417            parse_iso6709("+52.0+200.0/"),
418            None,
419            "longitude out of range"
420        );
421    }
422
423    #[test]
424    fn apple_date_keeps_local_wallclock_and_drops_the_offset() {
425        // Photos store local wall-clock in the same column; storing UTC here
426        // would put a 21:49 local video on the following day.
427        assert_eq!(
428            parse_apple_date("2024-12-15T21:49:24-0600").as_deref(),
429            Some("2024-12-15T21:49:24")
430        );
431        assert_eq!(
432            parse_apple_date("2017-09-08T10:34:23+0200").as_deref(),
433            Some("2017-09-08T10:34:23")
434        );
435        assert_eq!(
436            parse_apple_date("2024-12-15"),
437            None,
438            "too short to be a time"
439        );
440        assert_eq!(parse_apple_date("not a date at all!!"), None);
441    }
442
443    #[test]
444    fn mvhd_duration_reads_the_version_rather_than_assuming() {
445        // v0: version/flags(4) create(4) modify(4) timescale(4) duration(4)
446        let mut v0 = vec![0u8; 4];
447        v0.extend_from_slice(&0u32.to_be_bytes());
448        v0.extend_from_slice(&0u32.to_be_bytes());
449        v0.extend_from_slice(&600u32.to_be_bytes());
450        v0.extend_from_slice(&9000u32.to_be_bytes());
451        assert_eq!(parse_mvhd(&v0), Some(15.0));
452
453        // v1: version=1, then 64-bit times, timescale(4), 64-bit duration
454        let mut v1 = vec![1u8, 0, 0, 0];
455        v1.extend_from_slice(&0u64.to_be_bytes());
456        v1.extend_from_slice(&0u64.to_be_bytes());
457        v1.extend_from_slice(&1000u32.to_be_bytes());
458        v1.extend_from_slice(&2500u64.to_be_bytes());
459        assert_eq!(parse_mvhd(&v1), Some(2.5));
460
461        let mut zero = v0.clone();
462        zero[12..16].copy_from_slice(&0u32.to_be_bytes());
463        assert_eq!(parse_mvhd(&zero), None, "timescale 0 must not divide");
464    }
465
466    #[test]
467    fn tkhd_dimensions_are_16_16_fixed_point() {
468        let mut p = vec![0u8; 4 + 20 + 16 + 36];
469        p.extend_from_slice(&(1920u32 << 16).to_be_bytes());
470        p.extend_from_slice(&(1080u32 << 16).to_be_bytes());
471        assert_eq!(parse_tkhd(&p), Some((1920, 1080)));
472    }
473
474    #[test]
475    fn stsd_yields_the_format_tag() {
476        let mut p = vec![0u8; 8];
477        p.extend_from_slice(&0u32.to_be_bytes());
478        p.extend_from_slice(b"hvc1");
479        assert_eq!(parse_stsd(&p).as_deref(), Some("hvc1"));
480    }
481
482    #[test]
483    fn dimensions_come_from_the_video_track_not_the_first_one() {
484        // An audio trak first would otherwise report its zero dimensions.
485        let hdlr = |kind: &[u8; 4]| {
486            let mut p = vec![0u8; 8];
487            p.extend_from_slice(kind);
488            bx(b"hdlr", &p)
489        };
490        let mut tkhd_payload = vec![0u8; 4 + 20 + 16 + 36];
491        tkhd_payload.extend_from_slice(&(1920u32 << 16).to_be_bytes());
492        tkhd_payload.extend_from_slice(&(1080u32 << 16).to_be_bytes());
493
494        let audio = bx(b"trak", &bx(b"mdia", &hdlr(b"soun")));
495        let mut video_children = bx(b"tkhd", &tkhd_payload);
496        video_children.extend_from_slice(&bx(b"mdia", &hdlr(b"vide")));
497        let video = bx(b"trak", &video_children);
498
499        let mut moov = audio;
500        moov.extend_from_slice(&video);
501        let m = from_moov(&moov);
502        assert_eq!((m.width, m.height), (Some(1920), Some(1080)));
503    }
504
505    #[test]
506    fn a_malformed_container_yields_defaults_rather_than_panicking() {
507        assert_eq!(from_moov(&[]), VideoMeta::default());
508        assert_eq!(from_moov(&[0xff; 7]), VideoMeta::default());
509        assert_eq!(
510            from_moov(&[0, 0, 0, 200, b'm', b'v', b'h', b'd']),
511            VideoMeta::default()
512        );
513    }
514
515    /// Real-file check. Ignored: `videre-core` reads no fixture files, and this
516    /// needs the local corpus. Run with
517    /// `cargo test -p videre-core --ignored real_video`.
518    #[test]
519    #[ignore]
520    fn real_video_from_the_corpus_parses() {
521        let dir = std::path::Path::new(concat!(env!("HOME"), "/videre-test/iphotos"));
522        if !dir.exists() {
523            return;
524        }
525        fn movs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
526            if out.len() >= 25 {
527                return;
528            }
529            let Ok(rd) = std::fs::read_dir(dir) else {
530                return;
531            };
532            for e in rd.flatten() {
533                let p = e.path();
534                if p.is_dir() {
535                    movs(&p, out);
536                } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("mov")) {
537                    out.push(p);
538                }
539                if out.len() >= 25 {
540                    return;
541                }
542            }
543        }
544        let mut found = Vec::new();
545        movs(dir, &mut found);
546        let checked = found.len();
547        let mut with_gps = 0;
548        for p in &found {
549            let m = read(p);
550            // Structural: every container carries these.
551            assert!(m.date.is_some(), "no date for {p:?}");
552            assert!(
553                m.duration_secs.is_some_and(|d| d > 0.0),
554                "no duration for {p:?}"
555            );
556            assert!(m.width.is_some_and(|w| w > 0), "no width for {p:?}");
557            assert!(m.codec.is_some(), "no codec for {p:?}");
558            // GPS is not structural. Measured on this corpus, 238 of 254 carry
559            // it: a clip recorded with location services off has none, and
560            // asserting it per file fails on a correct parse of a real file.
561            if m.gps_lat.is_some() {
562                with_gps += 1;
563            }
564            // Printed so a value-level diff against ffprobe is one command, not
565            // a rebuild: presence assertions cannot catch a swapped lat/lon or
566            // a timezone slip.
567            if std::env::var_os("VIDERE_DUMP_VIDEO_META").is_some() {
568                println!(
569                    "{}\t{:?}\t{:?}\t{:?}\t{:?}x{:?}\t{:?}\t{:?}",
570                    p.file_name().unwrap().to_string_lossy(),
571                    m.date,
572                    m.gps_lat,
573                    m.gps_lon,
574                    m.width,
575                    m.height,
576                    m.duration_secs.map(|d| (d * 100.0).round() / 100.0),
577                    m.codec
578                );
579            }
580        }
581        assert!(checked > 0, "corpus present but no .mov found");
582        assert!(
583            with_gps * 100 >= checked * 70,
584            "only {with_gps}/{checked} carried GPS; the corpus measured ~94%"
585        );
586    }
587}
588
589#[cfg(test)]
590mod mvhd_date_tests {
591    use super::*;
592
593    fn mvhd_v0(created_1904: u32) -> Vec<u8> {
594        let mut p = vec![0u8; 4];
595        p.extend_from_slice(&created_1904.to_be_bytes());
596        p.extend_from_slice(&0u32.to_be_bytes());
597        p.extend_from_slice(&600u32.to_be_bytes());
598        p.extend_from_slice(&600u32.to_be_bytes());
599        p
600    }
601
602    #[test]
603    fn mvhd_date_converts_from_the_1904_epoch() {
604        // 1970-01-01T00:00:00Z is exactly QT_EPOCH_OFFSET seconds in.
605        let one_hour_after_unix_epoch = (QT_EPOCH_OFFSET + 3600) as u32;
606        assert_eq!(
607            parse_mvhd_date(&mvhd_v0(one_hour_after_unix_epoch)).as_deref(),
608            Some("1970-01-01T01:00:00")
609        );
610    }
611
612    #[test]
613    fn a_zero_creation_time_is_refused_rather_than_recorded_as_1904() {
614        // Common in re-muxed files; recording 1904 as a capture date would put
615        // the file at the very top of every chronological sort.
616        assert_eq!(parse_mvhd_date(&mvhd_v0(0)), None);
617    }
618
619    #[test]
620    fn the_apple_key_wins_when_both_are_present() {
621        // The Apple key is local time and mvhd is UTC; taking mvhd when both
622        // exist would put some video on a different clock from the photos
623        // beside it.
624        fn bx(typ: &[u8; 4], payload: &[u8]) -> Vec<u8> {
625            let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
626            v.extend_from_slice(typ);
627            v.extend_from_slice(payload);
628            v
629        }
630        let mut keys = vec![0u8; 8];
631        let name = b"com.apple.quicktime.creationdate";
632        keys.extend_from_slice(&((name.len() + 8) as u32).to_be_bytes());
633        keys.extend_from_slice(b"mdta");
634        keys.extend_from_slice(name);
635
636        let mut data = vec![0u8; 8];
637        data.extend_from_slice(b"2020-05-06T07:08:09+0300");
638        let ilst = bx(&1u32.to_be_bytes(), &bx(b"data", &data));
639
640        let mut meta_body = bx(b"keys", &keys);
641        meta_body.extend_from_slice(&ilst_wrap(&ilst));
642
643        let mut moov = bx(b"mvhd", &mvhd_v0((QT_EPOCH_OFFSET + 3600) as u32));
644        moov.extend_from_slice(&bx(b"meta", &meta_body));
645
646        assert_eq!(
647            from_moov(&moov).date.as_deref(),
648            Some("2020-05-06T07:08:09"),
649            "local Apple time must beat UTC mvhd"
650        );
651    }
652
653    fn ilst_wrap(inner: &[u8]) -> Vec<u8> {
654        let mut v = ((inner.len() + 8) as u32).to_be_bytes().to_vec();
655        v.extend_from_slice(b"ilst");
656        v.extend_from_slice(inner);
657        v
658    }
659}