use std::{
collections::{btree_map, BTreeMap},
io::{Read, Seek},
ops::Range,
};
use chrono::DateTime;
use nom::{bytes::streaming, IResult};
#[allow(deprecated)]
use crate::{
bbox::{
find_box, parse_video_tkhd_in_moov, travel_header, IlstBox, KeysBox, MvhdBox, ParseBox,
},
error::ParsingError,
loader::{BufLoader, Load},
partial_vec::PartialVec,
skip::Seekable,
video::TrackInfoTag,
EntryValue, FileFormat,
};
#[deprecated(since = "2.0.0")]
#[tracing::instrument(skip_all)]
#[allow(deprecated)]
pub fn parse_metadata<R: Read + Seek>(reader: R) -> crate::Result<Vec<(String, EntryValue)>> {
let mut loader = BufLoader::<Seekable, _>::new(reader);
let ff = FileFormat::try_from_load(&mut loader)?;
match ff {
FileFormat::Jpeg | FileFormat::Heif => {
return Err(crate::error::Error::ParseFailed(
"can not parse metadata from an image".into(),
));
}
FileFormat::QuickTime | FileFormat::MP4 => (),
FileFormat::Ebml => {
return Err(crate::error::Error::ParseFailed(
"please use MediaParser to parse *.webm, *.mkv files".into(),
))
}
};
let moov_body = extract_moov_body(loader)?;
let (_, mut entries) = match parse_moov_body(&moov_body) {
Ok((remain, Some(entries))) => (remain, entries),
Ok((remain, None)) => (remain, Vec::new()),
Err(_) => {
return Err("invalid moov body".into());
}
};
let map: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries.clone());
let mut extras = parse_mvhd_tkhd(&moov_body);
const CREATIONDATE_KEY: &str = "com.apple.quicktime.creationdate";
if map.contains_key(&TrackInfoTag::CreateDate) {
extras.remove(&TrackInfoTag::CreateDate);
let date = map.get(&TrackInfoTag::CreateDate);
if let Some(pos) = entries.iter().position(|x| x.0 == CREATIONDATE_KEY) {
if let Some(date) = date {
entries[pos] = (CREATIONDATE_KEY.to_string(), date.clone());
} else {
entries.remove(pos);
}
}
}
entries.extend(extras.into_iter().map(|(k, v)| match k {
TrackInfoTag::ImageWidth => ("width".to_string(), v),
TrackInfoTag::ImageHeight => ("height".to_string(), v),
TrackInfoTag::DurationMs => (
"duration".to_string(),
EntryValue::U32(v.as_u64().unwrap() as u32),
),
TrackInfoTag::CreateDate => (CREATIONDATE_KEY.to_string(), v),
_ => unreachable!(),
}));
if !map.contains_key(&TrackInfoTag::GpsIso6709) {
if let Some(gps) = parse_mp4_gps(&moov_body) {
const LOCATION_KEY: &str = "com.apple.quicktime.location.ISO6709";
entries.push((LOCATION_KEY.to_string(), gps.into()));
}
}
Ok(entries)
}
#[tracing::instrument(skip_all)]
pub(crate) fn parse_qt(
moov_body: &[u8],
) -> Result<BTreeMap<TrackInfoTag, EntryValue>, ParsingError> {
let (_, entries) = match parse_moov_body(moov_body) {
Ok((remain, Some(entries))) => (remain, entries),
Ok((remain, None)) => (remain, Vec::new()),
Err(_) => {
return Err("invalid moov body".into());
}
};
let mut entries: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries);
let extras = parse_mvhd_tkhd(moov_body);
if entries.contains_key(&TrackInfoTag::CreateDate) {
entries.remove(&TrackInfoTag::CreateDate);
}
entries.extend(extras);
Ok(entries)
}
#[tracing::instrument(skip_all)]
pub(crate) fn parse_mp4(
moov_body: &[u8],
) -> Result<BTreeMap<TrackInfoTag, EntryValue>, ParsingError> {
let (_, entries) = match parse_moov_body(moov_body) {
Ok((remain, Some(entries))) => (remain, entries),
Ok((remain, None)) => (remain, Vec::new()),
Err(_) => {
return Err("invalid moov body".into());
}
};
let mut entries: BTreeMap<TrackInfoTag, EntryValue> = map_qt_tag_to_video_tag(entries);
let extras = parse_mvhd_tkhd(moov_body);
entries.extend(extras);
if let btree_map::Entry::Vacant(e) = entries.entry(TrackInfoTag::GpsIso6709) {
if let Some(gps) = parse_mp4_gps(moov_body) {
e.insert(gps.into());
}
}
Ok(entries)
}
fn parse_mvhd_tkhd(moov_body: &[u8]) -> BTreeMap<TrackInfoTag, EntryValue> {
let mut entries = BTreeMap::new();
if let Ok((_, Some(bbox))) = find_box(moov_body, "mvhd") {
if let Ok((_, mvhd)) = MvhdBox::parse_box(bbox.data) {
entries.insert(TrackInfoTag::DurationMs, mvhd.duration_ms().into());
entries.insert(
TrackInfoTag::CreateDate,
EntryValue::Time(mvhd.creation_time()),
);
}
}
if let Ok(Some(tkhd)) = parse_video_tkhd_in_moov(moov_body) {
entries.insert(TrackInfoTag::ImageWidth, tkhd.width.into());
entries.insert(TrackInfoTag::ImageHeight, tkhd.height.into());
}
entries
}
fn map_qt_tag_to_video_tag(
entries: Vec<(String, EntryValue)>,
) -> BTreeMap<TrackInfoTag, EntryValue> {
entries
.into_iter()
.filter_map(|(k, v)| {
if k == "com.apple.quicktime.creationdate" {
v.as_str()
.and_then(|s| DateTime::parse_from_str(s, "%+").ok())
.map(|t| (TrackInfoTag::CreateDate, EntryValue::Time(t)))
} else if k == "com.apple.quicktime.make" {
Some((TrackInfoTag::Make, v))
} else if k == "com.apple.quicktime.model" {
Some((TrackInfoTag::Model, v))
} else if k == "com.apple.quicktime.software" {
Some((TrackInfoTag::Software, v))
} else if k == "com.apple.quicktime.location.ISO6709" {
Some((TrackInfoTag::GpsIso6709, v))
} else {
None
}
})
.collect()
}
fn parse_mp4_gps(moov_body: &[u8]) -> Option<String> {
let bbox = match find_box(moov_body, "udta/©xyz") {
Ok((_, b)) => b,
Err(_) => None,
};
if let Some(bbox) = bbox {
if bbox.body_data().len() <= 4 {
tracing::warn!("moov/udta/©xyz body is too small");
} else {
let location = bbox.body_data()[4..] .iter()
.map(|b| *b as char)
.collect::<String>();
return Some(location);
}
}
None
}
#[deprecated(since = "2.0.0")]
pub fn parse_mov_metadata<R: Read + Seek>(reader: R) -> crate::Result<Vec<(String, EntryValue)>> {
#[allow(deprecated)]
parse_metadata(reader)
}
#[tracing::instrument(skip_all)]
fn extract_moov_body<L: Load>(mut loader: L) -> Result<PartialVec, crate::Error> {
let moov_body_range = loader.load_and_parse(extract_moov_body_from_buf)?;
tracing::debug!(?moov_body_range);
Ok(PartialVec::from_vec_range(
loader.into_vec(),
moov_body_range,
))
}
#[tracing::instrument(skip_all)]
pub(crate) fn extract_moov_body_from_buf(input: &[u8]) -> Result<Range<usize>, ParsingError> {
let remain = input;
let convert_error = |e: nom::Err<_>, msg: &str| match e {
nom::Err::Incomplete(needed) => match needed {
nom::Needed::Unknown => ParsingError::Need(1),
nom::Needed::Size(n) => ParsingError::Need(n.get()),
},
nom::Err::Failure(_) | nom::Err::Error(_) => ParsingError::Failed(msg.to_string()),
};
let mut to_skip = 0;
let mut skipped = 0;
let (remain, header) = travel_header(remain, |h, remain| {
tracing::debug!(?h.box_type, ?h.box_size, "Got");
if h.box_type == "moov" {
skipped += h.header_size;
false
} else if (remain.len() as u64) < h.body_size() {
to_skip = h.body_size() as usize - remain.len();
false
} else {
skipped += h.box_size as usize;
true
}
})
.map_err(|e| convert_error(e, "search atom moov failed"))?;
if to_skip > 0 {
return Err(ParsingError::ClearAndSkip(to_skip + input.len()));
}
let (_, body) = streaming::take(header.body_size())(remain)
.map_err(|e| convert_error(e, "moov is too small"))?;
Ok(skipped..skipped + body.len())
}
type EntriesResult<'a> = IResult<&'a [u8], Option<Vec<(String, EntryValue)>>>;
fn parse_moov_body(input: &[u8]) -> EntriesResult {
let (remain, Some(meta)) = find_box(input, "meta")? else {
return Ok((input, None));
};
let (_, Some(keys)) = find_box(meta.body_data(), "keys")? else {
return Ok((remain, None));
};
let (_, Some(ilst)) = find_box(meta.body_data(), "ilst")? else {
return Ok((remain, None));
};
let (_, keys) = KeysBox::parse_box(keys.data)?;
let (_, ilst) = IlstBox::parse_box(ilst.data)?;
let entries = keys
.entries
.into_iter()
.map(|k| k.key)
.zip(ilst.items.into_iter().map(|v| v.value))
.collect::<Vec<_>>();
Ok((input, Some(entries)))
}
#[allow(dead_code)]
fn tz_iso_8601_to_rfc3339(s: String) -> String {
use regex::Regex;
let ss = s.trim();
let re = Regex::new(r"([+-][0-9][0-9])([0-9][0-9])?$").unwrap();
if let Some((offset, tz)) = re.captures(ss).map(|caps| {
(
caps.get(1).unwrap().start(),
format!(
"{}:{}",
caps.get(1).map_or("00", |m| m.as_str()),
caps.get(2).map_or("00", |m| m.as_str())
),
)
}) {
let s1 = &ss.as_bytes()[..offset]; let s2 = tz.as_bytes();
s1.iter().chain(s2.iter()).map(|x| *x as char).collect()
} else {
s
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use crate::testkit::*;
use test_case::test_case;
#[test_case("meta.mov")]
fn mov_parse(path: &str) {
let reader = open_sample(path).unwrap();
let entries = parse_metadata(reader).unwrap();
assert_eq!(
entries
.iter()
.map(|x| format!("{x:?}"))
.collect::<Vec<_>>()
.join("\n"),
"(\"com.apple.quicktime.make\", Text(\"Apple\"))
(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
(\"com.apple.quicktime.creationdate\", Time(2019-02-12T15:27:12+08:00))
(\"duration\", U32(500))
(\"width\", U32(720))
(\"height\", U32(1280))"
);
}
#[test_case("meta.mov")]
fn mov_extract_mov(path: &str) {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let buf = read_sample(path).unwrap();
tracing::info!(bytes = buf.len(), "File size.");
let range = extract_moov_body_from_buf(&buf).unwrap();
let (_, entries) = parse_moov_body(&buf[range]).unwrap();
assert_eq!(
entries
.unwrap()
.iter()
.map(|x| format!("{x:?}"))
.collect::<Vec<_>>()
.join("\n"),
"(\"com.apple.quicktime.make\", Text(\"Apple\"))
(\"com.apple.quicktime.model\", Text(\"iPhone X\"))
(\"com.apple.quicktime.software\", Text(\"12.1.2\"))
(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.1281+100.2508+000.000/\"))
(\"com.apple.quicktime.creationdate\", Text(\"2019-02-12T15:27:12+08:00\"))"
);
}
#[test_case("meta.mp4")]
fn parse_mp4(path: &str) {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let entries = parse_metadata(open_sample(path).unwrap()).unwrap();
assert_eq!(
entries
.iter()
.map(|x| format!("{x:?}"))
.collect::<Vec<_>>()
.join("\n"),
"(\"com.apple.quicktime.creationdate\", Time(2024-02-03T07:05:38+00:00))
(\"duration\", U32(1063))
(\"width\", U32(1920))
(\"height\", U32(1080))
(\"com.apple.quicktime.location.ISO6709\", Text(\"+27.2939+112.6932/\"))"
);
}
#[test_case("embedded-in-heic.mov")]
fn parse_embedded_mov(path: &str) {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let entries = parse_mov_metadata(open_sample(path).unwrap()).unwrap();
assert_eq!(
entries
.iter()
.map(|x| format!("{x:?}"))
.collect::<Vec<_>>()
.join("\n"),
"(\"com.apple.quicktime.location.accuracy.horizontal\", Text(\"14.235563\"))
(\"com.apple.quicktime.live-photo.auto\", U8(1))
(\"com.apple.quicktime.content.identifier\", Text(\"DA1A7EE8-0925-4C9F-9266-DDA3F0BB80F0\"))
(\"com.apple.quicktime.live-photo.vitality-score\", F32(0.93884003))
(\"com.apple.quicktime.live-photo.vitality-scoring-version\", I64(4))
(\"com.apple.quicktime.location.ISO6709\", Text(\"+22.5797+113.9380+028.396/\"))
(\"com.apple.quicktime.make\", Text(\"Apple\"))
(\"com.apple.quicktime.model\", Text(\"iPhone 15 Pro\"))
(\"com.apple.quicktime.software\", Text(\"17.1\"))
(\"com.apple.quicktime.creationdate\", Time(2023-11-02T19:58:34+08:00))
(\"duration\", U32(2795))
(\"width\", U32(1920))
(\"height\", U32(1440))"
);
}
#[test]
fn test_iso_8601_tz_to_rfc3339() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let s = "2023-11-02T19:58:34+08".to_string();
assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
let s = "2023-11-02T19:58:34+0800".to_string();
assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
let s = "2023-11-02T19:58:34+08:00".to_string();
assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34+08:00");
let s = "2023-11-02T19:58:34Z".to_string();
assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34Z");
let s = "2023-11-02T19:58:34".to_string();
assert_eq!(tz_iso_8601_to_rfc3339(s), "2023-11-02T19:58:34");
}
}