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 size: usize = header.body_size().try_into().expect("must fit");
342    let (_, body) =
343        streaming::take(size)(remain).map_err(|e| convert_error(e, "moov is too small"))?;
344
345    Ok(skipped..skipped + body.len())
346}
347
348type EntriesResult<'a> = IResult<&'a [u8], Option<Vec<(String, EntryValue)>>>;
349
350fn parse_moov_body(input: &[u8]) -> EntriesResult {
351    let (remain, Some(meta)) = find_box(input, "meta")? else {
352        return Ok((input, None));
353    };
354
355    let (_, Some(keys)) = find_box(meta.body_data(), "keys")? else {
356        return Ok((remain, None));
357    };
358
359    let (_, Some(ilst)) = find_box(meta.body_data(), "ilst")? else {
360        return Ok((remain, None));
361    };
362
363    let (_, keys) = KeysBox::parse_box(keys.data)?;
364    let (_, ilst) = IlstBox::parse_box(ilst.data)?;
365
366    let entries = keys
367        .entries
368        .into_iter()
369        .map(|k| k.key)
370        .zip(ilst.items.into_iter().map(|v| v.value))
371        .collect::<Vec<_>>();
372
373    Ok((input, Some(entries)))
374}
375
376/// Change timezone format from iso 8601 to rfc3339, e.g.:
377///
378/// - `2023-11-02T19:58:34+08` -> `2023-11-02T19:58:34+08:00`
379/// - `2023-11-02T19:58:34+0800` -> `2023-11-02T19:58:34+08:00`
380#[allow(dead_code)]
381fn tz_iso_8601_to_rfc3339(s: String) -> String {
382    use regex::Regex;
383
384    let ss = s.trim();
385    // Safe unwrap
386    let re = Regex::new(r"([+-][0-9][0-9])([0-9][0-9])?$").unwrap();
387
388    if let Some((offset, tz)) = re.captures(ss).map(|caps| {
389        (
390            // Safe unwrap
391            caps.get(1).unwrap().start(),
392            format!(
393                "{}:{}",
394                caps.get(1).map_or("00", |m| m.as_str()),
395                caps.get(2).map_or("00", |m| m.as_str())
396            ),
397        )
398    }) {
399        let s1 = &ss.as_bytes()[..offset]; // Safe-slice
400        let s2 = tz.as_bytes();
401        s1.iter().chain(s2.iter()).map(|x| *x as char).collect()
402    } else {
403        s
404    }
405}
406
407#[cfg(test)]
408#[allow(deprecated)]
409mod tests {
410    use super::*;
411    use crate::testkit::*;
412    use test_case::test_case;
413
414    #[test_case("meta.mov")]
415    fn mov_parse(path: &str) {
416        let reader = open_sample(path).unwrap();
417        let entries = parse_metadata(reader).unwrap();
418        assert_eq!(
419            entries
420                .iter()
421                .map(|x| format!("{x:?}"))
422                .collect::<Vec<_>>()
423                .join("\n"),
424            "(\"com.apple.quicktime.make\", Text(\"Apple\"))
425(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
426(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
427(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
428(\"com.apple.quicktime.creationdate\", Time(2019-02-12T15:27:12+08:00))
429(\"duration\", U32(500))
430(\"width\", U32(720))
431(\"height\", U32(1280))"
432        );
433    }
434
435    #[test_case("meta.mov")]
436    fn mov_extract_mov(path: &str) {
437        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
438
439        let buf = read_sample(path).unwrap();
440        tracing::info!(bytes = buf.len(), "File size.");
441        let range = extract_moov_body_from_buf(&buf).unwrap();
442        let (_, entries) = parse_moov_body(&buf[range]).unwrap();
443        assert_eq!(
444            entries
445                .unwrap()
446                .iter()
447                .map(|x| format!("{x:?}"))
448                .collect::<Vec<_>>()
449                .join("\n"),
450            "(\"com.apple.quicktime.make\", Text(\"Apple\"))
451(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
452(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
453(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
454(\"com.apple.quicktime.creationdate\", Text(\"2019-02-12T15:27:12+08:00\"))"
455        );
456    }
457
458    #[test_case("meta.mp4")]
459    fn parse_mp4(path: &str) {
460        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
461
462        let entries = parse_metadata(open_sample(path).unwrap()).unwrap();
463        assert_eq!(
464            entries
465                .iter()
466                .map(|x| format!("{x:?}"))
467                .collect::<Vec<_>>()
468                .join("\n"),
469            "(\"com.apple.quicktime.creationdate\", Time(2024-02-03T07:05:38+00:00))
470(\"duration\", U32(1063))
471(\"width\", U32(1920))
472(\"height\", U32(1080))
473(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.2939+112.6932/\"))"
474        );
475    }
476
477    #[test_case("embedded-in-heic.mov")]
478    fn parse_embedded_mov(path: &str) {
479        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
480
481        let entries = parse_mov_metadata(open_sample(path).unwrap()).unwrap();
482        assert_eq!(
483            entries
484                .iter()
485                .map(|x| format!("{x:?}"))
486                .collect::<Vec<_>>()
487                .join("\n"),
488            "(\"com.apple.quicktime.location.accuracy.horizontal\", Text(\"14.235563\"))
489(\"com.apple.quicktime.live-photo.auto\", U8(1))
490(\"com.apple.quicktime.content.identifier\", Text(\"DA1A7EE8-0925-4C9F-9266-DDA3F0BB80F0\"))
491(\"com.apple.quicktime.live-photo.vitality-score\", F32(0.93884003))
492(\"com.apple.quicktime.live-photo.vitality-scoring-version\", I64(4))
493(\"com.apple.quicktime.location.ISO6709\", Text(\"+22.5797+113.9380+028.396/\"))
494(\"com.apple.quicktime.make\", Text(\"Apple\"))
495(\"com.apple.quicktime.model\", Text(\"iPhone 15 Pro\"))
496(\"com.apple.quicktime.software\", Text(\"17.1\"))
497(\"com.apple.quicktime.creationdate\", Time(2023-11-02T19:58:34+08:00))
498(\"duration\", U32(2795))
499(\"width\", U32(1920))
500(\"height\", U32(1440))"
501        );
502    }
503
504    #[test]
505    fn test_iso_8601_tz_to_rfc3339() {
506        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
507
508        let s = "2023-11-02T19:58:34+08".to_string();
509        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
510
511        let s = "2023-11-02T19:58:34+0800".to_string();
512        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
513
514        let s = "2023-11-02T19:58:34+08:00".to_string();
515        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
516
517        let s = "2023-11-02T19:58:34Z".to_string();
518        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34Z");
519
520        let s = "2023-11-02T19:58:34".to_string();
521        assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34");
522    }
523}