Skip to main content

nom_exif/
mov.rs

1use std::{
2    collections::{btree_map, BTreeMap},
3    io::{Read, Seek},
4    ops::Range,
5};
6
7use chrono::DateTime;
8use nom::{bytes::streaming, IResult};
9
10#[allow(deprecated)]
11use crate::{
12    bbox::{
13        find_box, parse_video_tkhd_in_moov, travel_header, IlstBox, KeysBox, MvhdBox, ParseBox,
14    },
15    error::ParsingError,
16    loader::{BufLoader, Load},
17    partial_vec::PartialVec,
18    skip::Seekable,
19    video::TrackInfoTag,
20    EntryValue, FileFormat,
21};
22
23/// *Deprecated*: Please use [`MediaParser`] instead.
24///
25/// Analyze the byte stream in the `reader` as a MOV/MP4 file, attempting to
26/// extract any possible metadata it may contain, and return it in the form of
27/// key-value pairs.
28///
29/// Please note that the parsing routine itself provides a buffer, so the
30/// `reader` may not need to be wrapped with `BufRead`.
31///
32/// # Usage
33///
34/// ```rust
35/// use nom_exif::*;
36///
37/// use std::fs::File;
38/// use std::path::Path;
39///
40/// let f = File::open(Path::new("./testdata/meta.mov")).unwrap();
41/// let entries = parse_metadata(f).unwrap();
42///
43/// assert_eq!(
44///     entries
45///         .iter()
46///         .map(|x| format!("{x:?}"))
47///         .collect::<Vec<_>>()
48///         .join("\n"),
49///     r#"("com.apple.quicktime.make", Text("Apple"))
50/// ("com.apple.quicktime.model", Text("iPhone X"))
51/// ("com.apple.quicktime.software", Text("12.1.2"))
52/// ("com.apple.quicktime.location.ISO6709", Text("+27.1281+100.2508+000.000/"))
53/// ("com.apple.quicktime.creationdate", Time(2019-02-12T15:27:12+08:00))
54/// ("duration", U32(500))
55/// ("width", U32(720))
56/// ("height", U32(1280))"#,
57/// );
58/// ```
59#[deprecated(since = "2.0.0")]
60#[tracing::instrument(skip_all)]
61#[allow(deprecated)]
62pub fn parse_metadata<R: Read + Seek>(reader: R) -> crate::Result<Vec<(String, EntryValue)>> {
63    let mut loader = BufLoader::<Seekable, _>::new(reader);
64    let ff = FileFormat::try_from_load(&mut loader)?;
65    match ff {
66        FileFormat::Jpeg | FileFormat::Heif => {
67            return Err(crate::error::Error::ParseFailed(
68                "can not parse metadata from an image".into(),
69            ));
70        }
71        FileFormat::QuickTime | FileFormat::MP4 => (),
72        FileFormat::Ebml => {
73            return Err(crate::error::Error::ParseFailed(
74                "please use MediaParser to parse *.webm, *.mkv files".into(),
75            ))
76        }
77    };
78
79    let moov_body = extract_moov_body(loader)?;
80
81    let (_, mut entries) = match parse_moov_body(&moov_body) {
82        Ok((remain, Some(entries))) => (remain, entries),
83        Ok((remain, None)) => (remain, Vec::new()),
84        Err(_) => {
85            return Err("invalid moov body".into());
86        }
87    };
88
89    let map: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries.clone());
90    let mut extras = parse_mvhd_tkhd(&moov_body);
91
92    const CREATIONDATE_KEY: &str = "com.apple.quicktime.creationdate";
93    if map.contains_key(&TrackInfoTag::CreateDate) {
94        extras.remove(&TrackInfoTag::CreateDate);
95        let date = map.get(&TrackInfoTag::CreateDate);
96        if let Some(pos) = entries.iter().position(|x| x.0 == CREATIONDATE_KEY) {
97            if let Some(date) = date {
98                entries[pos] = (CREATIONDATE_KEY.to_string(), date.clone());
99            } else {
100                entries.remove(pos);
101            }
102        }
103    }
104
105    entries.extend(extras.into_iter().map(|(k, v)| match k {
106        TrackInfoTag::ImageWidth => ("width".to_string(), v),
107        TrackInfoTag::ImageHeight => ("height".to_string(), v),
108        TrackInfoTag::DurationMs => (
109            "duration".to_string(),
110            // For compatibility with older versions, convert to u32
111            EntryValue::U32(v.as_u64().unwrap() as u32),
112        ),
113        TrackInfoTag::CreateDate => (CREATIONDATE_KEY.to_string(), v),
114        _ => unreachable!(),
115    }));
116
117    // If the GPSInfo doesn't exist, then try to find GPS info from box
118    // `moov/udta/©xyz`. For mp4 files, Android phones store GPS info in that
119    // box.
120    if !map.contains_key(&TrackInfoTag::GpsIso6709) {
121        if let Some(gps) = parse_mp4_gps(&moov_body) {
122            const LOCATION_KEY: &str = "com.apple.quicktime.location.ISO6709";
123            entries.push((LOCATION_KEY.to_string(), gps.into()));
124        }
125    }
126
127    Ok(entries)
128}
129
130#[tracing::instrument(skip_all)]
131pub(crate) fn parse_qt(
132    moov_body: &[u8],
133) -> Result<BTreeMap<TrackInfoTag, EntryValue>, ParsingError> {
134    let (_, entries) = match parse_moov_body(moov_body) {
135        Ok((remain, Some(entries))) => (remain, entries),
136        Ok((remain, None)) => (remain, Vec::new()),
137        Err(_) => {
138            return Err("invalid moov body".into());
139        }
140    };
141
142    let mut entries: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries);
143    let extras = parse_mvhd_tkhd(moov_body);
144    if entries.contains_key(&TrackInfoTag::CreateDate) {
145        entries.remove(&TrackInfoTag::CreateDate);
146    }
147    entries.extend(extras);
148
149    Ok(entries)
150}
151
152#[tracing::instrument(skip_all)]
153pub(crate) fn parse_mp4(
154    moov_body: &[u8],
155) -> Result<BTreeMap<TrackInfoTag, EntryValue>, ParsingError> {
156    let (_, entries) = match parse_moov_body(moov_body) {
157        Ok((remain, Some(entries))) => (remain, entries),
158        Ok((remain, None)) => (remain, Vec::new()),
159        Err(_) => {
160            return Err("invalid moov body".into());
161        }
162    };
163
164    let mut entries: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries);
165    let extras = parse_mvhd_tkhd(moov_body);
166    entries.extend(extras);
167
168    // If the GPSInfo doesn't exist, then try to find GPS info from box
169    // `moov/udta/©xyz`. For mp4 files, Android phones store GPS info in that
170    // box.
171    if let btree_map::Entry::Vacant(e) = entries.entry(TrackInfoTag::GpsIso6709) {
172        if let Some(gps) = parse_mp4_gps(moov_body) {
173            e.insert(gps.into());
174        }
175    }
176
177    Ok(entries)
178}
179
180fn parse_mvhd_tkhd(moov_body: &[u8]) -> BTreeMap<TrackInfoTag, EntryValue> {
181    let mut entries = BTreeMap::new();
182    if let Ok((_, Some(bbox))) = find_box(moov_body, "mvhd") {
183        if let Ok((_, mvhd)) = MvhdBox::parse_box(bbox.data) {
184            entries.insert(TrackInfoTag::DurationMs, mvhd.duration_ms().into());
185
186            entries.insert(
187                TrackInfoTag::CreateDate,
188                EntryValue::Time(mvhd.creation_time()),
189            );
190        }
191    }
192
193    if let Ok(Some(tkhd)) = parse_video_tkhd_in_moov(moov_body) {
194        entries.insert(TrackInfoTag::ImageWidth, tkhd.width.into());
195        entries.insert(TrackInfoTag::ImageHeight, tkhd.height.into());
196    }
197
198    entries
199}
200
201fn map_qt_tag_to_video_tag(
202    entries: Vec<(String, EntryValue)>,
203) -> BTreeMap<TrackInfoTag, EntryValue> {
204    entries
205        .into_iter()
206        .filter_map(|(k, v)| {
207            if k == "com.apple.quicktime.creationdate" {
208                v.as_str()
209                    .and_then(|s| DateTime::parse_from_str(s, "%+").ok())
210                    .map(|t| (TrackInfoTag::CreateDate, EntryValue::Time(t)))
211            } else if k == "com.apple.quicktime.make" {
212                Some((TrackInfoTag::Make, v))
213            } else if k == "com.apple.quicktime.model" {
214                Some((TrackInfoTag::Model, v))
215            } else if k == "com.apple.quicktime.software" {
216                Some((TrackInfoTag::Software, v))
217            } else if k == "com.apple.quicktime.location.ISO6709" {
218                Some((TrackInfoTag::GpsIso6709, v))
219            } else {
220                None
221            }
222        })
223        .collect()
224}
225
226/// Try to find GPS info from box `moov/udta/©xyz`. For mp4 files, Android
227/// phones store GPS info in that box.
228fn parse_mp4_gps(moov_body: &[u8]) -> Option<String> {
229    let bbox = match find_box(moov_body, "udta/©xyz") {
230        Ok((_, b)) => b,
231        Err(_) => None,
232    };
233    if let Some(bbox) = bbox {
234        if bbox.body_data().len() <= 4 {
235            tracing::warn!("moov/udta/©xyz body is too small");
236        } else {
237            let location = bbox.body_data()[4..] // Safe-slice
238                .iter()
239                .map(|b| *b as char)
240                .collect::<String>();
241            return Some(location);
242        }
243    }
244    None
245}
246
247/// *Deprecated*: Please use [`crate::MediaParser`] instead.
248///
249/// Analyze the byte stream in the `reader` as a MOV file, attempting to extract
250/// any possible metadata it may contain, and return it in the form of key-value
251/// pairs.
252///
253/// Please note that the parsing routine itself provides a buffer, so the
254/// `reader` may not need to be wrapped with `BufRead`.
255///
256/// # Usage
257///
258/// ```rust
259/// use nom_exif::*;
260///
261/// use std::fs::File;
262/// use std::path::Path;
263///
264/// let f = File::open(Path::new("./testdata/meta.mov")).unwrap();
265/// let entries = parse_mov_metadata(f).unwrap();
266///
267/// assert_eq!(
268///     entries
269///         .iter()
270///         .map(|x| format!("{x:?}"))
271///         .collect::<Vec<_>>()
272///         .join("\n"),
273///     r#"("com.apple.quicktime.make", Text("Apple"))
274/// ("com.apple.quicktime.model", Text("iPhone X"))
275/// ("com.apple.quicktime.software", Text("12.1.2"))
276/// ("com.apple.quicktime.location.ISO6709", Text("+27.1281+100.2508+000.000/"))
277/// ("com.apple.quicktime.creationdate", Time(2019-02-12T15:27:12+08:00))
278/// ("duration", U32(500))
279/// ("width", U32(720))
280/// ("height", U32(1280))"#,
281/// );
282/// ```
283#[deprecated(since = "2.0.0")]
284pub fn parse_mov_metadata<R: Read + Seek>(reader: R) -> crate::Result<Vec<(String, EntryValue)>> {
285    #[allow(deprecated)]
286    parse_metadata(reader)
287}
288
289#[tracing::instrument(skip_all)]
290fn extract_moov_body<L: Load>(mut loader: L) -> Result<PartialVec, crate::Error> {
291    let moov_body_range = loader.load_and_parse(extract_moov_body_from_buf)?;
292
293    tracing::debug!(?moov_body_range);
294    Ok(PartialVec::from_vec_range(
295        loader.into_vec(),
296        moov_body_range,
297    ))
298}
299
300/// Parse the byte data of an ISOBMFF file and return the potential body data of
301/// moov atom it may contain.
302///
303/// Regarding error handling, please refer to [Error] for more information.
304#[tracing::instrument(skip_all)]
305pub(crate) fn extract_moov_body_from_buf(input: &[u8]) -> Result<Range<usize>, ParsingError> {
306    // parse metadata from moov/meta/keys & moov/meta/ilst
307    let remain = input;
308
309    let convert_error = |e: nom::Err<_>, msg: &str| match e {
310        nom::Err::Incomplete(needed) => match needed {
311            nom::Needed::Unknown => ParsingError::Need(1),
312            nom::Needed::Size(n) => ParsingError::Need(n.get()),
313        },
314        nom::Err::Failure(_) | nom::Err::Error(_) => ParsingError::Failed(msg.to_string()),
315    };
316
317    let mut to_skip = 0;
318    let mut skipped = 0;
319    let (remain, header) = travel_header(remain, |h, remain| {
320        tracing::debug!(?h.box_type, ?h.box_size, "Got");
321        if h.box_type == "moov" {
322            // stop travelling
323            skipped += h.header_size;
324            false
325        } else if (remain.len() as u64) < h.body_size() {
326            // stop travelling & skip unused box data
327            to_skip = h.body_size() as usize - remain.len();
328            false
329        } else {
330            // body has been read, so just consume it
331            skipped += h.box_size as usize;
332            true
333        }
334    })
335    .map_err(|e| convert_error(e, "search atom moov failed"))?;
336
337    if to_skip > 0 {
338        return Err(ParsingError::ClearAndSkip(to_skip + input.len()));
339    }
340
341    let (_, body) = streaming::take(header.body_size())(remain)
342        .map_err(|e| convert_error(e, "moov is too small"))?;
343
344    Ok(skipped..skipped + body.len())
345}
346
347type EntriesResult<'a> = IResult<&'a [u8], Option<Vec<(String, EntryValue)>>>;
348
349fn parse_moov_body(input: &[u8]) -> EntriesResult {
350    let (remain, Some(meta)) = find_box(input, "meta")? else {
351        return Ok((input, None));
352    };
353
354    let (_, Some(keys)) = find_box(meta.body_data(), "keys")? else {
355        return Ok((remain, None));
356    };
357
358    let (_, Some(ilst)) = find_box(meta.body_data(), "ilst")? else {
359        return Ok((remain, None));
360    };
361
362    let (_, keys) = KeysBox::parse_box(keys.data)?;
363    let (_, ilst) = IlstBox::parse_box(ilst.data)?;
364
365    let entries = keys
366        .entries
367        .into_iter()
368        .map(|k| k.key)
369        .zip(ilst.items.into_iter().map(|v| v.value))
370        .collect::<Vec<_>>();
371
372    Ok((input, Some(entries)))
373}
374
375/// Change timezone format from iso 8601 to rfc3339, e.g.:
376///
377/// - `2023-11-02T19:58:34+08` -> `2023-11-02T19:58:34+08:00`
378/// - `2023-11-02T19:58:34+0800` -> `2023-11-02T19:58:34+08:00`
379#[allow(dead_code)]
380fn tz_iso_8601_to_rfc3339(s: String) -> String {
381    use regex::Regex;
382
383    let ss = s.trim();
384    // Safe unwrap
385    let re = Regex::new(r"([+-][0-9][0-9])([0-9][0-9])?$").unwrap();
386
387    if let Some((offset, tz)) = re.captures(ss).map(|caps| {
388        (
389            // Safe unwrap
390            caps.get(1).unwrap().start(),
391            format!(
392                "{}:{}",
393                caps.get(1).map_or("00", |m| m.as_str()),
394                caps.get(2).map_or("00", |m| m.as_str())
395            ),
396        )
397    }) {
398        let s1 = &ss.as_bytes()[..offset]; // Safe-slice
399        let s2 = tz.as_bytes();
400        s1.iter().chain(s2.iter()).map(|x| *x as char).collect()
401    } else {
402        s
403    }
404}
405
406#[cfg(test)]
407#[allow(deprecated)]
408mod tests {
409    use super::*;
410    use crate::testkit::*;
411    use test_case::test_case;
412
413    #[test_case("meta.mov")]
414    fn mov_parse(path: &str) {
415        let reader = open_sample(path).unwrap();
416        let entries = parse_metadata(reader).unwrap();
417        assert_eq!(
418            entries
419                .iter()
420                .map(|x| format!("{x:?}"))
421                .collect::<Vec<_>>()
422                .join("\n"),
423            "(\"com.apple.quicktime.make\", Text(\"Apple\"))
424(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
425(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
426(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
427(\"com.apple.quicktime.creationdate\", Time(2019-02-12T15:27:12+08:00))
428(\"duration\", U32(500))
429(\"width\", U32(720))
430(\"height\", U32(1280))"
431        );
432    }
433
434    #[test_case("meta.mov")]
435    fn mov_extract_mov(path: &str) {
436        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
437
438        let buf = read_sample(path).unwrap();
439        tracing::info!(bytes = buf.len(), "File size.");
440        let range = extract_moov_body_from_buf(&buf).unwrap();
441        let (_, entries) = parse_moov_body(&buf[range]).unwrap();
442        assert_eq!(
443            entries
444                .unwrap()
445                .iter()
446                .map(|x| format!("{x:?}"))
447                .collect::<Vec<_>>()
448                .join("\n"),
449            "(\"com.apple.quicktime.make\", Text(\"Apple\"))
450(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
451(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
452(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
453(\"com.apple.quicktime.creationdate\", Text(\"2019-02-12T15:27:12+08:00\"))"
454        );
455    }
456
457    #[test_case("meta.mp4")]
458    fn parse_mp4(path: &str) {
459        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
460
461        let entries = parse_metadata(open_sample(path).unwrap()).unwrap();
462        assert_eq!(
463            entries
464                .iter()
465                .map(|x| format!("{x:?}"))
466                .collect::<Vec<_>>()
467                .join("\n"),
468            "(\"com.apple.quicktime.creationdate\", Time(2024-02-03T07:05:38+00:00))
469(\"duration\", U32(1063))
470(\"width\", U32(1920))
471(\"height\", U32(1080))
472(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.2939+112.6932/\"))"
473        );
474    }
475
476    #[test_case("embedded-in-heic.mov")]
477    fn parse_embedded_mov(path: &str) {
478        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
479
480        let entries = parse_mov_metadata(open_sample(path).unwrap()).unwrap();
481        assert_eq!(
482            entries
483                .iter()
484                .map(|x| format!("{x:?}"))
485                .collect::<Vec<_>>()
486                .join("\n"),
487            "(\"com.apple.quicktime.location.accuracy.horizontal\", Text(\"14.235563\"))
488(\"com.apple.quicktime.live-photo.auto\", U8(1))
489(\"com.apple.quicktime.content.identifier\", Text(\"DA1A7EE8-0925-4C9F-9266-DDA3F0BB80F0\"))
490(\"com.apple.quicktime.live-photo.vitality-score\", F32(0.93884003))
491(\"com.apple.quicktime.live-photo.vitality-scoring-version\", I64(4))
492(\"com.apple.quicktime.location.ISO6709\", Text(\"+22.5797+113.9380+028.396/\"))
493(\"com.apple.quicktime.make\", Text(\"Apple\"))
494(\"com.apple.quicktime.model\", Text(\"iPhone 15 Pro\"))
495(\"com.apple.quicktime.software\", Text(\"17.1\"))
496(\"com.apple.quicktime.creationdate\", Time(2023-11-02T19:58:34+08:00))
497(\"duration\", U32(2795))
498(\"width\", U32(1920))
499(\"height\", U32(1440))"
500        );
501    }
502
503    #[test]
504    fn test_iso_8601_tz_to_rfc3339() {
505        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
506
507        let s = "2023-11-02T19:58:34+08".to_string();
508        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
509
510        let s = "2023-11-02T19:58:34+0800".to_string();
511        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
512
513        let s = "2023-11-02T19:58:34+08:00".to_string();
514        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
515
516        let s = "2023-11-02T19:58:34Z".to_string();
517        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34Z");
518
519        let s = "2023-11-02T19:58:34".to_string();
520        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34");
521    }
522}