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        let mut f = std::fs::File::open(&path).ok()?;
350        let end = f.seek(SeekFrom::End(0)).ok()?;
351        f.seek(SeekFrom::Start(0)).ok()?;
352        crate::video_probe::read_moov(&mut f, end).map(|moov| from_moov(&moov))
353    })
354    .ok()
355    .flatten()
356    .unwrap_or_default()
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    fn bx(typ: &[u8; 4], payload: &[u8]) -> Vec<u8> {
364        let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
365        v.extend_from_slice(typ);
366        v.extend_from_slice(payload);
367        v
368    }
369
370    #[test]
371    fn iso6709_parses_both_signs_and_optional_altitude() {
372        assert_eq!(
373            parse_iso6709("+52.5535+013.4299+050.897/"),
374            Some((52.5535, 13.4299))
375        );
376        assert_eq!(
377            parse_iso6709("+19.4290-099.1625+2248.823/"),
378            Some((19.4290, -99.1625))
379        );
380        assert_eq!(parse_iso6709("+52.5535+013.4299"), Some((52.5535, 13.4299)));
381        assert_eq!(
382            parse_iso6709("-33.8688+151.2093/"),
383            Some((-33.8688, 151.2093))
384        );
385    }
386
387    #[test]
388    fn iso6709_refuses_rather_than_guesses() {
389        // A wrong coordinate puts a file on the wrong continent in
390        // `videre locations`, and nothing downstream can notice.
391        assert_eq!(parse_iso6709(""), None);
392        assert_eq!(
393            parse_iso6709("+52.5535"),
394            None,
395            "one component is not a position"
396        );
397        assert_eq!(
398            parse_iso6709("52.5535,13.4299"),
399            None,
400            "comma form is not ISO6709"
401        );
402        assert_eq!(parse_iso6709("+99.0+013.0/"), None, "latitude out of range");
403        assert_eq!(
404            parse_iso6709("+52.0+200.0/"),
405            None,
406            "longitude out of range"
407        );
408    }
409
410    #[test]
411    fn apple_date_keeps_local_wallclock_and_drops_the_offset() {
412        // Photos store local wall-clock in the same column; storing UTC here
413        // would put a 21:49 local video on the following day.
414        assert_eq!(
415            parse_apple_date("2024-12-15T21:49:24-0600").as_deref(),
416            Some("2024-12-15T21:49:24")
417        );
418        assert_eq!(
419            parse_apple_date("2017-09-08T10:34:23+0200").as_deref(),
420            Some("2017-09-08T10:34:23")
421        );
422        assert_eq!(
423            parse_apple_date("2024-12-15"),
424            None,
425            "too short to be a time"
426        );
427        assert_eq!(parse_apple_date("not a date at all!!"), None);
428    }
429
430    #[test]
431    fn mvhd_duration_reads_the_version_rather_than_assuming() {
432        // v0: version/flags(4) create(4) modify(4) timescale(4) duration(4)
433        let mut v0 = vec![0u8; 4];
434        v0.extend_from_slice(&0u32.to_be_bytes());
435        v0.extend_from_slice(&0u32.to_be_bytes());
436        v0.extend_from_slice(&600u32.to_be_bytes());
437        v0.extend_from_slice(&9000u32.to_be_bytes());
438        assert_eq!(parse_mvhd(&v0), Some(15.0));
439
440        // v1: version=1, then 64-bit times, timescale(4), 64-bit duration
441        let mut v1 = vec![1u8, 0, 0, 0];
442        v1.extend_from_slice(&0u64.to_be_bytes());
443        v1.extend_from_slice(&0u64.to_be_bytes());
444        v1.extend_from_slice(&1000u32.to_be_bytes());
445        v1.extend_from_slice(&2500u64.to_be_bytes());
446        assert_eq!(parse_mvhd(&v1), Some(2.5));
447
448        let mut zero = v0.clone();
449        zero[12..16].copy_from_slice(&0u32.to_be_bytes());
450        assert_eq!(parse_mvhd(&zero), None, "timescale 0 must not divide");
451    }
452
453    #[test]
454    fn tkhd_dimensions_are_16_16_fixed_point() {
455        let mut p = vec![0u8; 4 + 20 + 16 + 36];
456        p.extend_from_slice(&(1920u32 << 16).to_be_bytes());
457        p.extend_from_slice(&(1080u32 << 16).to_be_bytes());
458        assert_eq!(parse_tkhd(&p), Some((1920, 1080)));
459    }
460
461    #[test]
462    fn stsd_yields_the_format_tag() {
463        let mut p = vec![0u8; 8];
464        p.extend_from_slice(&0u32.to_be_bytes());
465        p.extend_from_slice(b"hvc1");
466        assert_eq!(parse_stsd(&p).as_deref(), Some("hvc1"));
467    }
468
469    #[test]
470    fn dimensions_come_from_the_video_track_not_the_first_one() {
471        // An audio trak first would otherwise report its zero dimensions.
472        let hdlr = |kind: &[u8; 4]| {
473            let mut p = vec![0u8; 8];
474            p.extend_from_slice(kind);
475            bx(b"hdlr", &p)
476        };
477        let mut tkhd_payload = vec![0u8; 4 + 20 + 16 + 36];
478        tkhd_payload.extend_from_slice(&(1920u32 << 16).to_be_bytes());
479        tkhd_payload.extend_from_slice(&(1080u32 << 16).to_be_bytes());
480
481        let audio = bx(b"trak", &bx(b"mdia", &hdlr(b"soun")));
482        let mut video_children = bx(b"tkhd", &tkhd_payload);
483        video_children.extend_from_slice(&bx(b"mdia", &hdlr(b"vide")));
484        let video = bx(b"trak", &video_children);
485
486        let mut moov = audio;
487        moov.extend_from_slice(&video);
488        let m = from_moov(&moov);
489        assert_eq!((m.width, m.height), (Some(1920), Some(1080)));
490    }
491
492    #[test]
493    fn a_malformed_container_yields_defaults_rather_than_panicking() {
494        assert_eq!(from_moov(&[]), VideoMeta::default());
495        assert_eq!(from_moov(&[0xff; 7]), VideoMeta::default());
496        assert_eq!(
497            from_moov(&[0, 0, 0, 200, b'm', b'v', b'h', b'd']),
498            VideoMeta::default()
499        );
500    }
501
502    /// Real-file check. Ignored: `videre-core` reads no fixture files, and this
503    /// needs the local corpus. Run with
504    /// `cargo test -p videre-core --ignored real_video`.
505    #[test]
506    #[ignore]
507    fn real_video_from_the_corpus_parses() {
508        let dir = std::path::Path::new(concat!(env!("HOME"), "/videre-test/iphotos"));
509        if !dir.exists() {
510            return;
511        }
512        fn movs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
513            if out.len() >= 25 {
514                return;
515            }
516            let Ok(rd) = std::fs::read_dir(dir) else {
517                return;
518            };
519            for e in rd.flatten() {
520                let p = e.path();
521                if p.is_dir() {
522                    movs(&p, out);
523                } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("mov")) {
524                    out.push(p);
525                }
526                if out.len() >= 25 {
527                    return;
528                }
529            }
530        }
531        let mut found = Vec::new();
532        movs(dir, &mut found);
533        let checked = found.len();
534        let mut with_gps = 0;
535        for p in &found {
536            let m = read(p);
537            // Structural: every container carries these.
538            assert!(m.date.is_some(), "no date for {p:?}");
539            assert!(
540                m.duration_secs.is_some_and(|d| d > 0.0),
541                "no duration for {p:?}"
542            );
543            assert!(m.width.is_some_and(|w| w > 0), "no width for {p:?}");
544            assert!(m.codec.is_some(), "no codec for {p:?}");
545            // GPS is not structural. Measured on this corpus, 238 of 254 carry
546            // it: a clip recorded with location services off has none, and
547            // asserting it per file fails on a correct parse of a real file.
548            if m.gps_lat.is_some() {
549                with_gps += 1;
550            }
551            // Printed so a value-level diff against ffprobe is one command, not
552            // a rebuild: presence assertions cannot catch a swapped lat/lon or
553            // a timezone slip.
554            if std::env::var_os("VIDERE_DUMP_VIDEO_META").is_some() {
555                println!(
556                    "{}\t{:?}\t{:?}\t{:?}\t{:?}x{:?}\t{:?}\t{:?}",
557                    p.file_name().unwrap().to_string_lossy(),
558                    m.date,
559                    m.gps_lat,
560                    m.gps_lon,
561                    m.width,
562                    m.height,
563                    m.duration_secs.map(|d| (d * 100.0).round() / 100.0),
564                    m.codec
565                );
566            }
567        }
568        assert!(checked > 0, "corpus present but no .mov found");
569        assert!(
570            with_gps * 100 >= checked * 70,
571            "only {with_gps}/{checked} carried GPS; the corpus measured ~94%"
572        );
573    }
574}
575
576#[cfg(test)]
577mod mvhd_date_tests {
578    use super::*;
579
580    fn mvhd_v0(created_1904: u32) -> Vec<u8> {
581        let mut p = vec![0u8; 4];
582        p.extend_from_slice(&created_1904.to_be_bytes());
583        p.extend_from_slice(&0u32.to_be_bytes());
584        p.extend_from_slice(&600u32.to_be_bytes());
585        p.extend_from_slice(&600u32.to_be_bytes());
586        p
587    }
588
589    #[test]
590    fn mvhd_date_converts_from_the_1904_epoch() {
591        // 1970-01-01T00:00:00Z is exactly QT_EPOCH_OFFSET seconds in.
592        let one_hour_after_unix_epoch = (QT_EPOCH_OFFSET + 3600) as u32;
593        assert_eq!(
594            parse_mvhd_date(&mvhd_v0(one_hour_after_unix_epoch)).as_deref(),
595            Some("1970-01-01T01:00:00")
596        );
597    }
598
599    #[test]
600    fn a_zero_creation_time_is_refused_rather_than_recorded_as_1904() {
601        // Common in re-muxed files; recording 1904 as a capture date would put
602        // the file at the very top of every chronological sort.
603        assert_eq!(parse_mvhd_date(&mvhd_v0(0)), None);
604    }
605
606    #[test]
607    fn the_apple_key_wins_when_both_are_present() {
608        // The Apple key is local time and mvhd is UTC; taking mvhd when both
609        // exist would put some video on a different clock from the photos
610        // beside it.
611        fn bx(typ: &[u8; 4], payload: &[u8]) -> Vec<u8> {
612            let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
613            v.extend_from_slice(typ);
614            v.extend_from_slice(payload);
615            v
616        }
617        let mut keys = vec![0u8; 8];
618        let name = b"com.apple.quicktime.creationdate";
619        keys.extend_from_slice(&((name.len() + 8) as u32).to_be_bytes());
620        keys.extend_from_slice(b"mdta");
621        keys.extend_from_slice(name);
622
623        let mut data = vec![0u8; 8];
624        data.extend_from_slice(b"2020-05-06T07:08:09+0300");
625        let ilst = bx(&1u32.to_be_bytes(), &bx(b"data", &data));
626
627        let mut meta_body = bx(b"keys", &keys);
628        meta_body.extend_from_slice(&ilst_wrap(&ilst));
629
630        let mut moov = bx(b"mvhd", &mvhd_v0((QT_EPOCH_OFFSET + 3600) as u32));
631        moov.extend_from_slice(&bx(b"meta", &meta_body));
632
633        assert_eq!(
634            from_moov(&moov).date.as_deref(),
635            Some("2020-05-06T07:08:09"),
636            "local Apple time must beat UTC mvhd"
637        );
638    }
639
640    fn ilst_wrap(inner: &[u8]) -> Vec<u8> {
641        let mut v = ((inner.len() + 8) as u32).to_be_bytes().to_vec();
642        v.extend_from_slice(b"ilst");
643        v.extend_from_slice(inner);
644        v
645    }
646}