1use std::io::{Seek, SeekFrom};
19use std::path::Path;
20
21#[derive(Debug, Default, Clone, PartialEq)]
29pub struct VideoMeta {
30 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 pub codec: Option<String>,
41}
42
43fn 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
103fn 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
121const QT_EPOCH_OFFSET: i64 = 2_082_844_800;
123
124fn 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 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
149fn parse_tkhd(p: &[u8]) -> Option<(u32, u32)> {
155 let version = *p.first()?;
156 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
168fn parse_stsd(p: &[u8]) -> Option<String> {
170 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
176pub 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
212fn 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
234fn 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 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 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
284pub(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 if m.date.is_none() {
305 m.date = find(moov, b"mvhd").and_then(parse_mvhd_date);
306 }
307
308 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
336pub 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 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 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 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 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 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 #[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 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 if m.gps_lat.is_some() {
549 with_gps += 1;
550 }
551 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 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 assert_eq!(parse_mvhd_date(&mvhd_v0(0)), None);
604 }
605
606 #[test]
607 fn the_apple_key_wins_when_both_are_present() {
608 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}