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 read_file_inner(std::fs::File::open(&path).ok()?)
350 })
351 .ok()
352 .flatten()
353 .unwrap_or_default()
354}
355
356pub 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 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 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 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 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 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 #[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 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 if m.gps_lat.is_some() {
562 with_gps += 1;
563 }
564 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 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 assert_eq!(parse_mvhd_date(&mvhd_v0(0)), None);
617 }
618
619 #[test]
620 fn the_apple_key_wins_when_both_are_present() {
621 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}